Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 0fe2e844dc ci: re-run after a macOS file-observer flake
server-tests (macos-14, file observation) failed on file-observer/index.test.ts 'classifies a removed file as deleted', which this branch does not touch.
2026-09-23 22:09:06 +02:00
paseo-bot[bot] 4a97a60e5a test(server): stub the background fetch in the no-upstream origin/main test
The test's checkout has a remote URL, which starts the background fetch,
and it was the only one not stubbing runGitFetch, so it ran real git.
A remote-ref event that lands during a fetch is buffered until the fetch
ends, so on a loaded runner the second refresh arrived after the 1s
waitFor. Under local CPU load it failed 12 of 20 runs before, 0 of 20
after.
2026-09-23 21:52:12 +02:00
paseo-bot[bot] 48b16353e9 Merge remote-tracking branch 'origin/main' into diagnose-omp-duplicate-prompt-echo-4960-2 2026-09-23 21:50:58 +02:00
paseo-bot[bot] 24b5fd5010 fix(omp): claim the oldest pending correlation and drop refused prompts
OMP echoes prompts in the order Paseo sent them, so the oldest unclaimed
correlation with the echoed text belongs to the echo. Claiming the newest
swapped client ids when a prompt timed out after OMP recorded it and the
same text was resubmitted: each echo reconciled to the other submission.

A prompt OMP answers with a failure response is never echoed, so its
correlation is dropped on dispatch. JsonlRpcProcess now rejects those
responses with JsonlRpcRequestRejectedError, which keeps a timeout
(where OMP may still carry the prompt out) distinguishable from a refusal.
2026-09-23 21:44:08 +02:00
paseo-bot 9b088df7bc fix(omp): claim the newest pending correlation for a prompt echo
Paseo runs one foreground turn at a time, so two unclaimed correlations
with the same text mean the older prompt's turn ended without an echo.
The newest one belongs to the submission OMP is echoing now.

This replaces dropping a correlation on dispatch failure, which was
wrong for a request that timed out after OMP had already accepted the
prompt: OMP still echoes it, and the echo needs its id.

Tests wait on observable session state instead of a fixed number of
event-loop cycles.
2026-09-22 20:31:34 +02:00
paseo-bot bcb9724907 fix(omp): drop a prompt's client correlation when its dispatch fails 2026-09-22 20:13:33 +02:00
paseo-bot 163b6df4b3 fix(omp): keep a prompt's client correlation until OMP echoes it
An OMP prompt's client correlation lived on the active turn, so any turn
completion that landed before OMP echoed the prompt cleared it. The echo
then arrived untagged, the manager could not match it to the submission
it belonged to, and recorded it as a second user row.

Two paths reach it: an interrupt-and-replace where OMP flushes the
aborted turn's agent_end after the replacement turn started, and a
custom message that completes a turn before the prompt is echoed.

Correlations now belong to the dispatched prompt and are claimed by the
echo that emits them, so the same text on different turns stays separate
and a re-emitted frame cannot steal a later submission's id.
2026-09-22 20:01:27 +02:00
172 changed files with 962 additions and 10629 deletions
-60
View File
@@ -1,65 +1,5 @@
# Changelog
## 0.9.2 - 2026-09-24
### Added
- Added structured Claude Code launch arguments for session configuration and plugins ([#5206](https://github.com/getpaseo/paseo/pull/5206))
### Improved
- Reduced background Git polling for repositories the watcher cannot observe ([#5170](https://github.com/getpaseo/paseo/pull/5170))
- Reduced Add Project directory search time on large home directories ([#5190](https://github.com/getpaseo/paseo/pull/5190))
### Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens ([c3e1e08](https://github.com/getpaseo/paseo/commit/c3e1e084a068e5895710c43b034ced8ebef256e8) by [@Marcus172](https://github.com/Marcus172))
- Fixed daemon memory growing after client connections close ([9978988](https://github.com/getpaseo/paseo/commit/9978988e35409a018a52d0d7646f18b51e562346))
- Fixed a project folder ending in a space crashing the app on load ([#5205](https://github.com/getpaseo/paseo/pull/5205) by [@L4XB](https://github.com/L4XB))
- Fixed workspace labels missing when an agent screen opens before the sidebar ([#5079](https://github.com/getpaseo/paseo/pull/5079) by [@morven-ai](https://github.com/morven-ai))
- Fixed workspaces disappearing when their disk or network share is unavailable ([#5227](https://github.com/getpaseo/paseo/pull/5227))
- Fixed a failed session import making an archived worktree impossible to restore ([#5238](https://github.com/getpaseo/paseo/pull/5238))
- Fixed archived agent logs failing after Paseo removes their worktree ([#5229](https://github.com/getpaseo/paseo/pull/5229))
- Fixed a newly created branch pushing to the default branch through an inherited upstream ([#5249](https://github.com/getpaseo/paseo/pull/5249))
- Fixed fork checkout pull requests disappearing from the workspace after refresh ([#5221](https://github.com/getpaseo/paseo/pull/5221))
- Fixed agent-created local workspaces accepting a missing path or file ([#5322](https://github.com/getpaseo/paseo/pull/5322))
- Fixed chat uploads replacing valid characters in original file names with underscores ([#5317](https://github.com/getpaseo/paseo/pull/5317))
- Fixed multi-select questions dropping checked options or a typed Other answer ([#5320](https://github.com/getpaseo/paseo/pull/5320) by [@ThePharmer](https://github.com/ThePharmer))
- Fixed Android Back leaving the screen while a bottom sheet is open ([#5245](https://github.com/getpaseo/paseo/pull/5245))
- Fixed voice mode playing the thinking tone between spoken reply segments ([#5281](https://github.com/getpaseo/paseo/pull/5281))
- Fixed a Claude slash command sent with an attachment reaching Claude as plain text ([#5240](https://github.com/getpaseo/paseo/pull/5240) by [@joecorkerton](https://github.com/joecorkerton))
- Fixed Claude rewind after a turn that received no response ([#5285](https://github.com/getpaseo/paseo/pull/5285))
- Fixed Claude rewind after a turn containing subagent messages ([#5289](https://github.com/getpaseo/paseo/pull/5289))
- Fixed repeated agent history after refresh when a durable timeline store is configured ([#5286](https://github.com/getpaseo/paseo/pull/5286))
- Fixed an OMP custom message ending a turn before the provider finished ([#3258](https://github.com/getpaseo/paseo/pull/3258))
- Fixed a stopped OMP turn appearing as a failed turn ([#5243](https://github.com/getpaseo/paseo/pull/5243))
- Fixed Stop refusing to settle a Pi or OMP agent whose runtime has exited ([#5235](https://github.com/getpaseo/paseo/pull/5235))
- Fixed OpenCode agents ignoring their configured permission rules ([#5296](https://github.com/getpaseo/paseo/pull/5296) by [@gurvancampion](https://github.com/gurvancampion))
- Fixed Codex Default and Read-only modes sending approval requests to Auto-review ([#5239](https://github.com/getpaseo/paseo/pull/5239) by [@HMWCS](https://github.com/HMWCS))
- Fixed Cursor agent creation after switching from a Fast model to one without Fast ([#5274](https://github.com/getpaseo/paseo/pull/5274) by [@gengjiawen](https://github.com/gengjiawen))
- Fixed the Fast control missing for GPT-6 Sol and GPT-6 Luna in Codex ([#5273](https://github.com/getpaseo/paseo/pull/5273) by [@basilk15](https://github.com/basilk15), [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed Claude's configured Fable model missing from the model picker ([#5326](https://github.com/getpaseo/paseo/pull/5326) by [@noahg9](https://github.com/noahg9))
- Fixed ACP terminals started by the daemon using the wrong agent identity ([#5248](https://github.com/getpaseo/paseo/pull/5248))
- Fixed an agent's completed plugin session appearing failed after a daemon restart ([#5253](https://github.com/getpaseo/paseo/pull/5253))
- Fixed plugin reload crashing its subprocess while a provider session closes ([#5231](https://github.com/getpaseo/paseo/pull/5231))
- Fixed a failed plugin provider request crashing the daemon ([#5298](https://github.com/getpaseo/paseo/pull/5298))
- Fixed Hub executions being unable to title workspaces they create ([#5302](https://github.com/getpaseo/paseo/pull/5302))
- Fixed an invalid schedule file preventing the daemon from starting ([#5301](https://github.com/getpaseo/paseo/pull/5301))
- Fixed an empty `paseo.pid` preventing the daemon from starting ([#5306](https://github.com/getpaseo/paseo/pull/5306))
- Fixed a recycled daemon PID preventing startup after a reboot ([#5277](https://github.com/getpaseo/paseo/pull/5277))
- Fixed daemon startup failing on `config.json` saved with a UTF-8 byte order mark ([#5315](https://github.com/getpaseo/paseo/pull/5315))
- Fixed background daemon startup errors missing from the reported log file ([#5332](https://github.com/getpaseo/paseo/pull/5332))
- Fixed CLI errors for invalid `config.json` omitting the file and failing field ([#5337](https://github.com/getpaseo/paseo/pull/5337))
- Fixed the CLI suggesting daemon startup after a password rejection ([#5310](https://github.com/getpaseo/paseo/pull/5310))
- Fixed Open in editor missing for a password-protected desktop daemon ([#5335](https://github.com/getpaseo/paseo/pull/5335))
- Fixed `paseo permit ls --json` shortening request IDs needed by `permit allow` and `permit deny` ([#5305](https://github.com/getpaseo/paseo/pull/5305))
- Fixed a replica cache read spinning when its store keeps rejecting writes ([#5290](https://github.com/getpaseo/paseo/pull/5290))
- Fixed the MiniMax card showing a raw error for an inactive token subscription ([#5258](https://github.com/getpaseo/paseo/pull/5258))
- Fixed modified Backspace being rejected as a custom shortcut ([#5224](https://github.com/getpaseo/paseo/pull/5224))
- Fixed multi-step shortcuts failing when the app updates between steps ([#5255](https://github.com/getpaseo/paseo/pull/5255) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed multi-step shortcuts failing when the second step holds a modifier ([#5272](https://github.com/getpaseo/paseo/pull/5272))
- Fixed rebound pane-focus shortcuts failing while typing ([#5287](https://github.com/getpaseo/paseo/pull/5287))
## 0.9.1 - 2026-09-22
### Added
+1 -1
View File
@@ -714,7 +714,7 @@ Each entry in the `models` array:
### Claude settings.json model discovery
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_FABLE_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
+1 -1
View File
@@ -10,7 +10,7 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
+2 -5
View File
@@ -28,15 +28,12 @@ manage their own relationship or permissions.
## Session grants and agent operations
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
browser control, and permission management still require their own permissions. See
[permissions.md](permissions.md).
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
workspace through `workspace.title.set.request` without `workspace.manage`.
Clients using this contract check `server_info.features.hubAgentRpc` and
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
fall back to creating a fresh agent when continuation was requested.
+1 -1
View File
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
| `automation.manage` | Schedules, heartbeats, and loops |
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
+8 -21
View File
@@ -8,8 +8,7 @@ This guide walks through adding a new agent provider end-to-end. There are two i
names and nesting are the provider's native contract; options are not portable between providers.
Paseo validates the object with the selected provider's strict schema before constructing a session.
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
top-level provider options.
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
This Paseo version accepts these keys:
@@ -20,18 +19,10 @@ This Paseo version accepts these keys:
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
`settings`. `providerOptions.extraArgs` passes the SDK's documented
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
`permissions.{allow,ask,deny}` and sandbox settings. See the
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
and [Claude settings reference](https://code.claude.com/docs/en/settings).
@@ -69,7 +60,7 @@ model; it does not override the model list returned by a resolver.
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
@@ -95,13 +86,9 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
@@ -1,12 +0,0 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -1,12 +0,0 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -1,12 +0,0 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
@@ -1,12 +0,0 @@
Added
- Added structured Claude Code launch arguments for session configuration and plugins
Improved
- Reduced background Git polling for repositories the watcher cannot observe
- Reduced Add Project directory search time on large home directories
Fixed
- Fixed the daemon becoming unresponsive when ignored directories appear after a workspace opens
- Fixed daemon memory growing after client connections close (9978988)
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-UXnB6q5tubKpTs+A5+u/NLSzc8ZK6rAsQs+kEphEKd8=
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
+51 -266
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.9.2",
"version": "0.9.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.9.2",
"version": "0.9.1",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -42,13 +42,6 @@
"ws": "^8.20.0"
}
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
"dev": true,
"license": "MIT"
},
"node_modules/@0no-co/graphql.web": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz",
@@ -8278,84 +8271,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -8729,48 +8644,6 @@
"node": ">=20.0"
}
},
"node_modules/@opencode/client": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.10.tgz",
"integrity": "sha512-Pz4FD6jVtfZYX97AghVTM+cedi3HfI+U6gg/we5Luf08L85TewKsSXR4jHOZTCvggl0vACTquz1/hOjsJWE0Sg==",
"license": "MIT",
"dependencies": {
"@opencode/protocol": "2.0.10",
"@opencode/schema": "2.0.10"
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"effect": {
"optional": true
},
"solid-js": {
"optional": true
}
}
},
"node_modules/@opencode/protocol": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.10.tgz",
"integrity": "sha512-cHr0aSK39Q9c3JwPDvG45FFlE6rU/ntTFDSaqm4LRl74tki88G4WpzKk6Q56G06K6hKIONrezcPdXtDKtnZzyw==",
"license": "MIT",
"dependencies": {
"@opencode/schema": "2.0.10",
"effect": "4.0.0-rc.112"
}
},
"node_modules/@opencode/schema": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.10.tgz",
"integrity": "sha512-RpRhRZFH1YFnM+OcrxOZc2ObIW3RQ63z/LkcHrZKa47kWF0OiaZVkbAOITeAoAoofcnS3O5ekZBvgT/7kBwRUA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "1.1.0",
"effect": "4.0.0-rc.112"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -14390,6 +14263,13 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/7zip-bin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz",
"integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==",
"dev": true,
"license": "MIT"
},
"node_modules/abab": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz",
@@ -18742,16 +18622,6 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/effect": {
"version": "4.0.0-rc.112",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz",
"integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==",
"license": "MIT",
"dependencies": {
"fast-check": "^4.9.0",
"msgpackr": "^2.0.5"
}
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -21996,28 +21866,6 @@
"node": ">=18"
}
},
"node_modules/fast-check": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/fast-copy": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
@@ -28293,37 +28141,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.4"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
@@ -28649,21 +28466,6 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-gyp/node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -30339,22 +30141,6 @@
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -33341,6 +33127,23 @@
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/string-length": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz",
@@ -33515,23 +33318,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true,
"license": "MIT"
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@@ -36410,7 +36196,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.9.2",
"version": "0.9.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37476,12 +37262,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.9.2",
"version": "0.9.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/server": "0.9.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/server": "0.9.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37728,10 +37514,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.9.2",
"version": "0.9.1",
"dependencies": {
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37742,7 +37528,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.9.2",
"version": "0.9.1",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37986,7 +37772,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.2",
"version": "0.9.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38882,7 +38668,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.9.2",
"version": "0.9.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38911,14 +38697,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.9.2",
"version": "0.9.1",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38928,8 +38714,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38951,7 +38737,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.9.2",
"version": "0.9.1",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38978,7 +38764,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.9.2",
"version": "0.9.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39196,20 +38982,19 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.9.2",
"version": "0.9.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.2",
"@getpaseo/highlight": "0.9.2",
"@getpaseo/plugin": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/highlight": "0.9.1",
"@getpaseo/plugin": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -40110,7 +39895,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.9.2",
"version": "0.9.1",
"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.9.2",
"version": "0.9.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -6,16 +6,13 @@ import {
expectAgentProfilesEmptyPrompt,
expectProfileEditTooltip,
expectComposerMode,
expectCreateProfileFromModelRow,
expectComposerModel,
expectModelRowProfileActionBesideRow,
expectModelRowSelected,
expectProfileEditIsPencilOnly,
expectProfileVisibleForProvider,
openModelPicker,
openAgentProfilesFromEmptyPrompt,
seedAgentProfiles,
selectModelRow,
} from "../support/helpers/agent-profiles";
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
import { expectComposerVisible } from "../support/helpers/composer";
@@ -118,34 +115,4 @@ test.describe("Agent profiles in the model picker", () => {
await seed.restore();
}
});
test("a model row and its create-profile action are separate buttons", async ({ page }) => {
const seed = await seedAgentProfiles([]);
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-profiles-row-action-",
title: "Agent profiles row action",
model: "ten-second-stream",
modeId: "load-test",
});
try {
const oneMinute = {
provider: "mock",
modelId: "one-minute-stream",
modelLabel: "One minute stream",
};
await openAgentRoute(page, workspace);
await expectComposerVisible(page);
await openModelPicker(page);
await expectModelRowProfileActionBesideRow(page, oneMinute);
await expectCreateProfileFromModelRow(page, oneMinute);
await expectComposerModel(page, "Ten second stream");
await openModelPicker(page);
await selectModelRow(page, oneMinute);
await expectComposerModel(page, oneMinute.modelLabel);
} finally {
await workspace.cleanup();
await seed.restore();
}
});
});
@@ -1,7 +1,4 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
@@ -28,39 +25,3 @@ test("settings restart waits for a replacement worker while retaining the superv
await daemon.close();
}
});
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
page,
}) => {
test.skip(process.platform === "win32", "The slow provider stub is a POSIX script");
const daemon = await startIsolatedHostDaemon(randomUUID(), {
paseoHome: await createHomeWithSlowProvider(1_800),
});
try {
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
} finally {
await daemon.close();
}
});
async function createHomeWithSlowProvider(versionDelayMs: number): Promise<string> {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-slow-provider-"));
const provider = path.join(paseoHome, "slow-provider");
await writeFile(
provider,
`#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), ${versionDelayMs});\n`,
{ mode: 0o700 },
);
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify({
version: 1,
agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } },
})}\n`,
);
return paseoHome;
}
@@ -2,7 +2,6 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { openSettingsSection } from "../support/helpers/settings";
@@ -121,75 +120,3 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await rm(directory, { recursive: true, force: true });
}
});
const MANY_THEMES_PLUGIN_ID = "plugin-theme-many-e2e";
const MANY_THEMES_COUNT = 40;
const LAST_THEME_NAME = `Pack theme ${MANY_THEMES_COUNT}`;
const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
for (let index = 1; index <= ${MANY_THEMES_COUNT}; index += 1) {
plugin.addTheme({
id: "pack-" + index,
name: "Pack theme " + index,
appearance: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
raised: "#313244",
control: "#45475a",
border: "#45475a",
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
},
});
}
return () => {};
}`;
async function scrollThemeMenuToLastTheme(page: Page) {
const firstTheme = page.getByText("Pack theme 1", { exact: true });
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
await firstTheme.hover();
await page.mouse.wheel(0, 4000);
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
}
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: MANY_THEMES_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.ts"), MANY_THEMES_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto("/settings");
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
await page.getByLabel("Theme: System", { exact: true }).click();
await scrollThemeMenuToLastTheme(page);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
animations: "disabled",
});
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
} finally {
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
@@ -1,5 +1,4 @@
import path from "node:path";
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
@@ -12,7 +11,6 @@ import {
import { seedWorkspace } from "../support/helpers/seed-client";
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
import { getServerId } from "../support/helpers/server-id";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { escapeRegex } from "../support/helpers/regex";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
@@ -110,207 +108,7 @@ async function withPaseoOwnedWorktree(
}
}
async function readCachedRowIds(
page: Page,
rowKind: "workspace" | "checkpoint",
): Promise<string[]> {
return page.evaluate(
async ({ serverId, kind }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("paseo-replica-row-store");
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
try {
return await new Promise<string[]>((resolve, reject) => {
const transaction = database.transaction("rows", "readonly");
const request = transaction.objectStore("rows").getAll();
request.onsuccess = () =>
resolve(
request.result
.filter(
(row: { serverId: string; kind: string }) =>
row.serverId === serverId && row.kind === kind,
)
.map((row: { id: string }) => row.id)
.sort(),
);
request.addEventListener("error", () => reject(request.error));
});
} finally {
database.close();
}
},
{ serverId: getServerId(), kind: rowKind },
);
}
async function simulateDamagedLegacyDirectoryCache(
page: Page,
workspaceIdToRemove: string,
): Promise<void> {
// Stop the app before altering storage so no pending app write can repair the fixture.
const fixtureUrl = new URL("/__directory_cache_fixture", page.url()).href;
await page.route(fixtureUrl, (route) =>
route.fulfill({ contentType: "text/html", body: "<html><body>Cache fixture</body></html>" }),
);
await page.goto(fixtureUrl);
await page.evaluate(
async ({ serverId, missingWorkspaceId }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("paseo-replica-row-store");
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
try {
await new Promise<void>((resolve, reject) => {
const transaction = database.transaction("rows", "readwrite");
const store = transaction.objectStore("rows");
const request = store.getAll();
request.onsuccess = () => {
const rows = request.result as Array<{
serverId: string;
kind: string;
id: string;
payload: string;
}>;
const checkpoint = rows.find(
(row) => row.serverId === serverId && row.kind === "checkpoint",
);
if (!checkpoint) {
transaction.abort();
return;
}
const stored = JSON.parse(checkpoint.payload);
// Accept either writer so this fixture also reproduces on the unfixed client.
const cursors = stored.version === 1 ? stored.cursors : stored;
if (!cursors.workspaces?.generation) {
transaction.abort();
return;
}
store.put({ ...checkpoint, payload: JSON.stringify(cursors) });
for (const row of rows) {
if (
row.serverId === serverId &&
row.kind === "workspace" &&
row.id === missingWorkspaceId
)
store.delete([serverId, row.kind, row.id]);
}
};
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("abort", () =>
reject(transaction.error ?? new Error("Missing current directory checkpoint")),
);
transaction.addEventListener("error", () => reject(transaction.error));
});
} finally {
database.close();
}
},
{ serverId: getServerId(), missingWorkspaceId: workspaceIdToRemove },
);
expect(await readCachedRowIds(page, "workspace")).not.toContain(workspaceIdToRemove);
}
async function holdHostResponsesUntilCacheIsVisible(page: Page): Promise<() => void> {
let release!: () => void;
const ready = new Promise<void>((resolve) => {
release = resolve;
});
await page.routeWebSocket(new RegExp(`:${getE2EDaemonPort()}(?:/|$)`), (socket) => {
const server = socket.connectToServer();
server.onMessage(async (message) => {
await ready;
socket.send(message);
});
});
return release;
}
async function createPinnedSiblingWorkspace(
project: Awaited<ReturnType<typeof seedWorkspace>>,
): Promise<string> {
const sibling = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
title: "Pinned sibling",
});
if (!sibling.workspace) throw new Error(sibling.error ?? "Sibling workspace was not created");
await project.client.setWorkspacePinned(sibling.workspace.id, true);
return sibling.workspace.id;
}
async function expectSidebarWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
for (const workspaceId of workspaceIds) {
await waitForSidebarWorkspace(page, workspaceId);
}
}
async function cacheWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
await gotoAppShell(page);
await expectSidebarWorkspaces(page, workspaceIds);
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
}
async function reopenDamagedCacheBeforeHostResponds(
page: Page,
missingWorkspaceId: string,
): Promise<() => void> {
await simulateDamagedLegacyDirectoryCache(page, missingWorkspaceId);
const releaseHost = await holdHostResponsesUntilCacheIsVisible(page);
await gotoAppShell(page);
return releaseHost;
}
async function expectDamagedCacheHydrated(
page: Page,
retainedWorkspaceId: string,
missingWorkspaceId: string,
): Promise<void> {
await waitForSidebarWorkspace(page, retainedWorkspaceId);
await expect(page.getByTestId(getWorkspaceRowTestId(missingWorkspaceId))).toHaveCount(0);
await expect.poll(() => readCachedRowIds(page, "checkpoint")).toEqual([]);
}
async function expectRecoveredWorkspacesAfterReload(
page: Page,
workspaceIds: string[],
testInfo: TestInfo,
): Promise<void> {
await expectSidebarWorkspaces(page, workspaceIds);
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
await page.reload();
await expectSidebarWorkspaces(page, workspaceIds);
await page.screenshot({ path: testInfo.outputPath("recovered-workspaces.png") });
await testInfo.attach("recovered-workspaces", {
path: testInfo.outputPath("recovered-workspaces.png"),
contentType: "image/png",
});
}
test.describe("Sidebar workspace list", () => {
test("recovers missing workspaces from a damaged legacy cache after reload", async ({
page,
}, testInfo) => {
const project = await seedWorkspace({ repoPrefix: "directory-cache-recovery-" });
try {
const siblingId = await createPinnedSiblingWorkspace(project);
const ids = [project.workspaceId, siblingId].sort();
await test.step("cache both workspaces", () => cacheWorkspaces(page, ids));
const releaseHost = await test.step("reopen damaged cache before host responds", () =>
reopenDamagedCacheBeforeHostResponds(page, project.workspaceId));
await test.step("keep cached workspace and discard stale checkpoint", () =>
expectDamagedCacheHydrated(page, siblingId, project.workspaceId));
releaseHost();
await test.step("recover missing workspace and retain both after reload", () =>
expectRecoveredWorkspacesAfterReload(page, ids, testInfo));
} finally {
await project.cleanup();
}
});
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-remote-",
@@ -482,50 +482,6 @@ export async function expectModelRowSelected(
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
}
function modelRow(page: Page, input: { provider: string; modelId: string }): Locator {
return pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`);
}
/**
* A model row's profile action is its own button. On web, a button inside the
* row's button is invalid HTML that React reports on every render.
*/
export async function expectModelRowProfileActionBesideRow(
page: Page,
input: { provider: string; modelId: string },
): Promise<void> {
const row = modelRow(page, input);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(pickerViewport(page).locator("button button")).toHaveCount(0);
await expect(row.getByRole("button")).toHaveCount(0);
}
/** The hover-revealed plus on an unprofiled model row opens a seeded profile form. */
export async function expectCreateProfileFromModelRow(
page: Page,
input: { provider: string; modelId: string; modelLabel: string },
): Promise<void> {
await modelRow(page, input).hover();
await pickerViewport(page)
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
.click();
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
input.modelLabel,
);
await expect(modal.getByRole("button", { name: `Model (${input.modelLabel})` })).toBeVisible();
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(modal).toHaveCount(0, { timeout: 30_000 });
}
export async function selectModelRow(
page: Page,
input: { provider: string; modelId: string },
): Promise<void> {
await modelRow(page, input).click();
}
// ─── Cross-provider model search ───────────────────────────────────────────
export async function searchAllModels(page: Page, query: string): Promise<void> {
+1 -2
View File
@@ -19,8 +19,7 @@
"APP_VARIANT": "production"
},
"android": {
"resourceClass": "large",
"gradleCommand": ":app:bundleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
"resourceClass": "large"
}
},
"production-apk": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.9.2",
"version": "0.9.1",
"private": true,
"main": "index.ts",
"scripts": {
+11 -17
View File
@@ -625,6 +625,13 @@ function ModelRowProfileAction({
}) {
const isCompact = useIsCompactFormFactor();
const visible = hovered || isNative || isCompact;
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onPress();
},
[onPress],
);
const pressableStyle = useCallback(
({ hovered: buttonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.rowIconButton,
@@ -638,7 +645,7 @@ function ModelRowProfileAction({
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={onPress}
onPress={handlePress}
hitSlop={8}
style={pressableStyle}
pointerEvents={visible ? "auto" : "none"}
@@ -771,6 +778,7 @@ function ModelRow({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.browserRow,
styles.browserModelRow,
Boolean(hovered) && styles.browserRowHovered,
pressed && styles.browserRowPressed,
],
@@ -779,7 +787,7 @@ function ModelRow({
return (
<View
style={[styles.modelRowHoverBoundary, styles.browserModelRow]}
style={styles.modelRowHoverBoundary}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
@@ -807,17 +815,10 @@ function ModelRow({
<ThemedCheck size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
) : null}
</View>
{profileAction ? <View style={styles.rowIconButton} /> : null}
{profileAction}
</View>
</View>
</ModelBrowserPressable>
{/* The row renders a <button> on web, so its profile action sits beside it,
over the slot reserved above, rather than inside it. */}
{profileAction ? (
<View style={styles.modelRowProfileActionSlot} pointerEvents="box-none">
{profileAction}
</View>
) : null}
</View>
);
}
@@ -1589,13 +1590,6 @@ const styles = StyleSheet.create((theme) => ({
modelRowHoverBoundary: {
position: "relative",
},
modelRowProfileActionSlot: {
position: "absolute",
top: 0,
bottom: 0,
right: isWeb ? theme.spacing[3] : theme.spacing[6],
justifyContent: "center",
},
browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] },
browserRowHovered: {
backgroundColor: theme.colors.surface1,
@@ -54,54 +54,6 @@ describe("question form card core", () => {
});
});
test("keeps checked options and appends the other answer for multi-select", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Which fruits do you like?",
header: "Fruits",
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
multiSelect: true,
allowOther: true,
},
],
});
if (!questions) throw new Error("questions did not parse");
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, { 0: " durian " })).toEqual({
Fruits: "Apple, Cherry, durian",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, {})).toEqual({
Fruits: "Apple, Cherry",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set() }, { 0: "durian" })).toEqual({
Fruits: "durian",
});
expect(buildQuestionFormAnswers(questions, {}, { 0: "durian" })).toEqual({ Fruits: "durian" });
});
test("replaces the selected option with the other answer for single-select", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Which provider?",
header: "Provider",
options: [{ label: "Claude Code" }, { label: "Codex" }],
multiSelect: false,
allowOther: true,
},
],
});
if (!questions) throw new Error("questions did not parse");
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, { 0: "OpenCode" })).toEqual({
Provider: "OpenCode",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, {})).toEqual({
Provider: "Codex",
});
});
test("shows text input for explicit other questions", () => {
const questions = parseQuestionFormQuestions({
questions: [
@@ -111,13 +111,10 @@ export function buildQuestionFormAnswers(
const q = questions[i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
if (questionShowsTextInput(q)) {
if (otherText && otherText.length > 0) {
// Multi-select keeps the checked options and appends the custom answer, the way
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
answers[q.header] = q.multiSelect ? [...labels, otherText].join(", ") : otherText;
answers[q.header] = otherText;
continue;
}
if (q.allowEmpty && q.options.length === 0) {
@@ -126,7 +123,8 @@ export function buildQuestionFormAnswers(
}
}
if (labels.length > 0) {
if (selected && selected.size > 0) {
const labels = Array.from(selected).map((idx) => q.options[idx].label);
answers[q.header] = labels.join(", ");
}
}
@@ -1,149 +0,0 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import type { PendingPermission } from "@/types/shared";
import { QuestionFormCard } from "./question-form-card";
// Load translations so controls expose their real accessible names.
void testI18n;
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
/**
* A real browser with the real web `EditingTextInput`, because the bug under test lives in the
* gap between that input and React state: the input owns its text and never replays state, so a
* card that drops the Other text from state alone keeps showing it while submit ignores it.
*/
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
afterEach(() => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
});
function buildPermission(question: Record<string, unknown>): PendingPermission {
return {
key: "perm-1",
agentId: "agent-1",
request: {
id: "perm-1",
provider: "claude",
name: "AskUserQuestion",
kind: "question",
input: { questions: [question] },
},
};
}
function mountCard(question: Record<string, unknown>) {
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() =>
root.render(
<QuestionFormCard
permission={buildPermission(question)}
onRespond={onRespond}
isResponding={false}
/>,
),
);
mounted.push({ root, container });
const view = within(container);
const optionRole = question.multiSelect ? "checkbox" : "radio";
const otherInput = () =>
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
const type = (text: string) => {
const input = otherInput();
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
if (!valueSetter) throw new Error("HTML input value setter is unavailable");
act(() => {
valueSetter.call(input, text);
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
});
};
const submit = () => act(() => view.getByRole("button", { name: "Submit" }).click());
const submittedAnswers = (): Record<string, string> => {
const response = onRespond.mock.calls[0]?.[0];
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
return (response.updatedInput as { answers: Record<string, string> }).answers;
};
return { check, type, otherInput, submit, submittedAnswers };
}
const multiSelectQuestion = {
question: "Which fruits do you like?",
header: "Fruits",
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
multiSelect: true,
allowOther: true,
};
const singleSelectQuestion = {
question: "Which provider?",
header: "Provider",
options: [{ label: "Claude Code" }, { label: "Codex" }],
multiSelect: false,
allowOther: true,
};
describe("QuestionFormCard other answers", () => {
it("keeps checked options when the other answer is typed afterwards (multi-select)", () => {
const card = mountCard(multiSelectQuestion);
card.check("Apple");
card.check("Cherry");
card.type("durian");
card.submit();
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Cherry, durian" });
});
it("keeps the typed other answer when options are checked afterwards (multi-select)", () => {
const card = mountCard(multiSelectQuestion);
card.type("durian");
card.check("Apple");
card.check("Banana");
expect(card.otherInput().value).toBe("durian");
card.submit();
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Banana, durian" });
});
it("replaces the selected option with the typed other answer (single-select)", () => {
const card = mountCard(singleSelectQuestion);
card.check("Codex");
card.type("OpenCode");
card.submit();
expect(card.submittedAnswers()).toEqual({ Provider: "OpenCode" });
});
it("clears the typed other answer on screen when an option is picked afterwards (single-select)", () => {
const card = mountCard(singleSelectQuestion);
card.type("OpenCode");
card.check("Codex");
expect(card.otherInput().value).toBe("");
card.submit();
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
});
});
@@ -1,5 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
import { useState, useCallback, useMemo } from "react";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -9,7 +9,6 @@ import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
import {
areQuestionsAnswered,
buildQuestionFormAnswers,
@@ -259,7 +258,6 @@ function QuestionNav({
interface QuestionOtherInputProps {
qIndex: number;
inputRef: RefObject<EditingTextInputHandle | null>;
accessibilityLabel: string;
value: string;
placeholder: string;
@@ -270,7 +268,6 @@ interface QuestionOtherInputProps {
function QuestionOtherInput({
qIndex,
inputRef,
accessibilityLabel,
value,
placeholder,
@@ -306,7 +303,6 @@ function QuestionOtherInput({
);
return (
<TextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={otherInputStyle}
accessibilityLabel={accessibilityLabel}
@@ -332,7 +328,6 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const otherInputRef = useRef<EditingTextInputHandle | null>(null);
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
@@ -354,40 +349,29 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}
setSelections((prev) => ({ ...prev, [qIndex]: next }));
// Single-select: an option and a custom answer replace each other, as in Claude Code.
// Multi-select keeps both. The editing surface owns its text and never replays state
// (docs/forms.md), so clearing state alone would leave stale text on screen that
// submit ignores; clear the surface explicitly.
if (!multiSelect && otherTexts[qIndex]) {
setOtherTexts((prev) => {
const nextTexts = { ...prev };
delete nextTexts[qIndex];
return nextTexts;
});
otherInputRef.current?.replaceText("");
}
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const nextTexts = { ...prev };
delete nextTexts[qIndex];
return nextTexts;
});
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
}
},
[activeQuestionIndex, otherTexts, questions, selections],
[activeQuestionIndex, questions, selections],
);
const setOtherText = useCallback(
(qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
const multiSelect = questions?.[qIndex]?.multiSelect ?? false;
if (!multiSelect && text.length > 0) {
setSelections((prev) => {
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
return { ...prev, [qIndex]: new Set<number>() };
});
}
},
[questions],
);
const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
if (text.length > 0) {
setSelections((prev) => {
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
return { ...prev, [qIndex]: new Set<number>() };
});
}
}, []);
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
const resolvedActiveQuestionIndex = questions
@@ -570,7 +554,6 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
{showTextInput ? (
<QuestionOtherInput
qIndex={resolvedActiveQuestionIndex}
inputRef={otherInputRef}
accessibilityLabel={activeQuestion.question}
value={otherText}
placeholder={getQuestionInputPlaceholder({
@@ -16,7 +16,6 @@ import {
resolveWorkingDiffComparison,
selectWorkingDiffComparison,
} from "@/git/working-diff-comparison";
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
import {
applyCheckoutStatusUpdateFromEvent,
ensureCheckoutStatus,
@@ -198,51 +197,6 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
).toBe(false);
});
it("drops the checkout's cached draft slash commands when its branch changes", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(
checkoutStatusQueryKey(serverId, cwd),
checkoutStatus({ currentBranch: "chore/build-paseo" }),
);
const thisCheckout = draftAgentCommandsQueryKey({
serverId,
draftConfig: { provider: "claude", cwd, model: "haiku" },
});
const otherCheckout = draftAgentCommandsQueryKey({
serverId,
draftConfig: { provider: "claude", cwd: "/repo2", model: "haiku" },
});
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
queryClient.setQueryData(otherCheckout, [{ name: "build-paseo" }]);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ currentBranch: "main" })),
});
expect(queryClient.getQueryData(thisCheckout)).toBeUndefined();
expect(queryClient.getQueryData(otherCheckout)).toEqual([{ name: "build-paseo" }]);
});
it("keeps the checkout's draft slash commands when a push leaves its branch unchanged", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), checkoutStatus());
const thisCheckout = draftAgentCommandsQueryKey({
serverId,
draftConfig: { provider: "claude", cwd, model: "haiku" },
});
queryClient.setQueryData(thisCheckout, [{ name: "build-paseo" }]);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
});
expect(queryClient.getQueryData(thisCheckout)).toEqual([{ name: "build-paseo" }]);
});
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
const queryClient = createQueryClient();
const pushedPr = prStatus({ requestId: "pr-1" });
@@ -8,7 +8,6 @@ import {
invalidatePrPaneTimelineForCheckout,
} from "@/git/query-keys";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
import { expireWorkingDiffComparisons } from "@/git/working-diff-comparison";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
@@ -68,13 +67,7 @@ export function applyCheckoutStatusUpdateFromEvent({
? normalizeCheckoutPrStatusPayload(payload.prStatus)
: undefined;
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
const previousStatus = queryClient.getQueryData<CheckoutStatusPayload>(
checkoutStatusQueryKey(serverId, payload.cwd),
);
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
if (previousStatus?.currentBranch !== payload.currentBranch) {
void resetDraftAgentCommandsForCheckout(queryClient, { serverId, cwd: payload.cwd });
}
void queryClient.invalidateQueries({
queryKey: checkoutCommitsQueryKey(serverId, payload.cwd),
});
@@ -38,10 +38,9 @@ describe("agent command query keys", () => {
"agentCommands",
"server-1",
"draft",
"codex",
"cwd",
"/repo",
"provider",
"codex",
"mode",
null,
"model",
+4 -22
View File
@@ -1,4 +1,3 @@
import type { QueryClient } from "@tanstack/react-query";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
@@ -25,24 +24,17 @@ export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId:
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
}
function draftAgentCommandsCheckoutScope(input: { serverId: string; cwd: string }) {
return [
...agentCommandsQueryRoot(input.serverId),
"draft",
"cwd",
normalizeAgentCommandsCwd(input.cwd),
] as const;
}
export function draftAgentCommandsQueryKey(input: {
serverId: string;
draftConfig: AgentCommandsDraftConfig;
}) {
const { draftConfig } = input;
return [
...draftAgentCommandsCheckoutScope({ serverId: input.serverId, cwd: draftConfig.cwd }),
"provider",
...agentCommandsQueryRoot(input.serverId),
"draft",
draftConfig.provider,
"cwd",
normalizeAgentCommandsCwd(draftConfig.cwd),
"mode",
draftConfig.modeId ?? null,
"model",
@@ -54,16 +46,6 @@ export function draftAgentCommandsQueryKey(input: {
] as const;
}
// Draft commands include project skills read from the checkout. When the checkout moves to
// another branch the cached list describes files that are gone, so it is dropped rather than
// shown while a refetch runs.
export function resetDraftAgentCommandsForCheckout(
queryClient: QueryClient,
input: { serverId: string; cwd: string },
): Promise<void> {
return queryClient.resetQueries({ queryKey: draftAgentCommandsCheckoutScope(input) });
}
export function agentCommandsQueryKey(input: {
serverId: string;
agentId: string;
@@ -44,7 +44,6 @@ class FakeDirectoryClient {
projectResult: ProjectListResult | null = null;
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
private readonly handlers = new Map<
SessionOutboundMessage["type"],
Set<(message: SessionOutboundMessage) => void>
@@ -83,14 +82,6 @@ class FakeDirectoryClient {
return complete;
}
holdProjectFetch(): (result: ProjectListResult) => void {
let complete!: (result: ProjectListResult) => void;
this.pendingProjectFetch = new Promise((resolve) => {
complete = resolve;
});
return complete;
}
async fetchAgents(options?: unknown): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
this.fetchAgentsCalls += 1;
this.lastAgentOptions = options;
@@ -125,11 +116,6 @@ class FakeDirectoryClient {
async listProjects(options?: unknown): Promise<ProjectListResult> {
this.listProjectsCalls += 1;
this.lastProjectOptions = options;
if (this.pendingProjectFetch) {
const pending = this.pendingProjectFetch;
this.pendingProjectFetch = null;
return pending;
}
if (this.projectResult) return this.projectResult;
return {
requestId: "projects",
@@ -241,25 +227,6 @@ function createAgent(serverId: string, id: string) {
};
}
function createWorkspaceEntry(id: string): WorkspaceFetchResult["entries"][number] {
return {
id,
projectId: "snapshot-project",
projectDisplayName: "Snapshot project",
projectRootPath: "/repo",
workspaceDirectory: `/repo/${id}`,
projectKind: "git",
workspaceKind: "local_checkout",
name: id,
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
afterEach(() => {
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
serverIds.clear();
@@ -1148,41 +1115,6 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it("keeps the accepted baseline when an old connection finishes its workspace request", async () => {
const serverId = "superseded-workspace-baseline";
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
directory.acceptWorkspaces([normalizeWorkspaceDescriptor(createWorkspaceEntry("retained"))]);
const release = client.holdWorkspaceFetch();
const refresh = directory.refreshWorkspaces();
const rejected = expect(refresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError);
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 2 },
});
release({
requestId: "old-empty-snapshot",
entries: [],
emptyProjects: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
});
await rejected;
expect([...useSessionStore.getState().sessions[serverId].workspaces.keys()]).toEqual([
"retained",
]);
directory.dispose();
});
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
const serverId = "session-wait-reconnect";
const { client, directory } = createDirectory(serverId);
@@ -1250,19 +1182,9 @@ describe("DirectorySync session readiness", () => {
},
},
});
client.emit({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: { ...createWorkspaceEntry("updated-workspace"), name: "Live name" },
},
});
completeFetch({
requestId: "workspaces",
entries: [
createWorkspaceEntry("removed-workspace"),
createWorkspaceEntry("updated-workspace"),
],
entries: [],
emptyProjects: [
{
projectId: "snapshot-project",
@@ -1285,11 +1207,6 @@ describe("DirectorySync session readiness", () => {
expect(projects?.get("workspace-project")).toMatchObject({
projectDisplayName: "Project from workspace update",
});
expect(
[...useSessionStore.getState().sessions[serverId].workspaces.values()].map(
({ id, name }) => ({ id, name }),
),
).toEqual([{ id: "updated-workspace", name: "Live name" }]);
directory.dispose();
});
@@ -1361,8 +1278,6 @@ function createSqliteCache() {
return {
cache,
database,
storage,
reopen: () => new ReplicaCache(storage, { clearLegacyCache: async () => {} }),
holdRead: (operation: () => Promise<void>) => {
beforeRead = operation;
},
@@ -1558,198 +1473,3 @@ describe("DirectorySync workspace labels", () => {
directory.dispose();
});
});
it.each(["before", "during", "before-metadata", "damaged"] as const)(
"retains unchanged workspaces across route/full demand and restart (cache: %s)",
async (timing) => {
const cacheFirst = timing === "before";
const damaged = timing === "damaged";
const serverId = `cache-route-overlap-${timing}`;
serverIds.add(serverId);
const { cache, database, reopen, storage } = createSqliteCache();
cache.setHosts([serverId]);
const projectEntry: ProjectListResult["projects"][number] = {
projectId: "P",
projectDisplayName: "Project P",
projectRootPath: "/repo",
projectKind: "git",
};
const project = normalizeProjectDescriptor(projectEntry);
const workspaceEntries: WorkspaceFetchResult["entries"] = ["A", "B"].map((id) => ({
id,
projectId: "P",
projectDisplayName: "Project P",
projectRootPath: "/repo",
workspaceDirectory: `/repo/${id}`,
projectKind: "git",
workspaceKind: "local_checkout",
name: id,
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
}));
const workspaces = workspaceEntries.map(normalizeWorkspaceDescriptor);
const cursor = { generation: "g", afterSeq: 7 };
cache.replaceDirectoryBaseline(serverId, {
agents: new Map(),
projects: new Map([[project.projectId, project]]),
workspaces: new Map(
(damaged ? [] : workspaces).map((workspace) => [workspace.id, workspace]),
),
checkpoint: { projects: cursor, workspaces: cursor, agents: cursor },
});
await cache.flush();
if (damaged) {
await storage.apply({
deletes: [],
upserts: [
{
serverId,
kind: "checkpoint",
id: "singleton",
payload: JSON.stringify({ projects: cursor, workspaces: cursor, agents: cursor }),
},
],
});
}
// The host keeps A/B unchanged. A current cursor legitimately receives no rows.
class UnchangedDirectoryClient extends FakeDirectoryClient {
readonly workspaceSyncRequests: unknown[] = [];
override async fetchWorkspaces(
options?: Parameters<DaemonClient["fetchWorkspaces"]>[0],
): Promise<WorkspaceFetchResult> {
this.fetchWorkspacesCalls += 1;
this.lastWorkspaceOptions = options;
this.workspaceSyncRequests.push(options?.sync);
const changes = options?.sync?.generation === "g" && options.sync.afterSeq === 7;
return {
requestId: "workspaces",
entries: changes ? [] : [workspaceEntries[options?.page?.cursor ? 1 : 0]],
emptyProjects: [],
pageInfo: {
hasMore: !changes && !options?.page?.cursor,
nextCursor: !changes && !options?.page?.cursor ? "page-2" : null,
prevCursor: null,
},
sync: {
generation: "g",
headSeq: 7,
mode: changes ? "changes" : "snapshot",
removals: [],
},
};
}
}
const client = new UnchangedDirectoryClient();
const releaseAgents = client.holdAgentFetch();
const releaseProjects = client.holdProjectFetch();
const projectResult: ProjectListResult = {
requestId: "projects",
projects: [projectEntry],
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
};
client.projectResult = projectResult;
const callbacks = {
onAgentStoppedRunning: () => {},
markAgentLoading: () => {},
markAgentReady: () => {},
markAgentError: () => {},
};
const directory = new DirectorySync(serverId, callbacks, cache);
const publishServerInfo = () => {
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true, projectList: true },
});
};
const initializeSession = (ready = true) => {
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
if (ready) publishServerInfo();
};
const connection = {
client: client as unknown as DaemonClient,
status: "online" as const,
source: { clientGeneration: 1, connectionEpoch: 1 },
};
initializeSession(timing !== "before-metadata");
directory.connectionChanged(connection);
const visibleIds = () =>
[...useSessionStore.getState().sessions[serverId].workspaces.keys()].sort();
let restarted: DirectorySync | undefined;
const restartedCache = reopen();
try {
if (cacheFirst) await directory.restoreCachedDirectory();
directory.setAgentRouteDemand(["agent"]);
if (timing === "before-metadata") {
await directory.restoreCachedDirectory();
publishServerInfo();
}
await expect.poll(() => client.listProjectsCalls).toBe(1);
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
directory.setDemand({}, true);
await directory.restoreCachedDirectory();
expect(visibleIds()).toEqual(damaged ? [] : ["A", "B"]);
releaseProjects(projectResult);
releaseAgents({
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: {
generation: "g",
headSeq: 7,
mode: cacheFirst ? "changes" : "snapshot",
removals: [],
},
});
await directory.refreshDemand();
expect(client.lastProjectOptions).toEqual({ sync: cacheFirst ? cursor : {} });
expect(client.workspaceSyncRequests).toEqual(cacheFirst ? [cursor] : [{}, {}]);
expect.soft(visibleIds(), "after overlapping refresh").toEqual(["A", "B"]);
await cache.flush();
expect([...(await cache.readDirectory(serverId)).workspaces.keys()].sort()).toEqual([
"A",
"B",
]);
await directory.refreshAll();
await cache.flush();
directory.dispose();
useSessionStore.getState().clearSession(serverId);
restartedCache.setHosts([serverId]);
const persisted = await restartedCache.readDirectory(serverId);
expect(persisted.checkpoint?.workspaces).toEqual(cursor);
expect
.soft([...persisted.workspaces.keys()].sort(), "persisted baseline")
.toEqual(["A", "B"]);
initializeSession();
restarted = new DirectorySync(serverId, callbacks, restartedCache);
restarted.connectionChanged(connection);
await restarted.refreshAll();
expect.soft(visibleIds(), "after restart and refresh").toEqual(["A", "B"]);
const beforeUpdate = visibleIds();
client.emit({
type: "workspace_update",
payload: { kind: "upsert", workspace: workspaceEntries[0], generation: "g", seq: 8 },
});
expect(visibleIds()).toEqual([...new Set([...beforeUpdate, "A"])].sort());
client.emit({
type: "workspace_update",
payload: { kind: "upsert", workspace: workspaceEntries[1], generation: "g", seq: 9 },
});
expect(visibleIds()).toEqual(["A", "B"]);
} finally {
directory.dispose();
restarted?.dispose();
await cache.flush();
await restartedCache.flush();
database.close();
}
},
);
@@ -61,10 +61,6 @@ interface AgentSnapshot {
syncRemovals: Array<{ id: string; seq: number }>;
}
interface WorkspaceRefreshSnapshot extends WorkspaceDirectorySnapshot {
readonly requestCursors: Readonly<Pick<DirectoryCheckpoint, "projects" | "workspaces">>;
}
interface AgentPageInfo {
hasMore?: boolean;
hasMoreAfter?: boolean;
@@ -137,7 +133,7 @@ export class DirectorySync {
AgentDirectoryDelta
>();
private readonly workspaceTransactions = new DirectoryTransactionOwner<
WorkspaceRefreshSnapshot,
WorkspaceDirectorySnapshot,
WorkspaceDirectoryDelta
>();
private readonly agents: AgentDirectoryReplica;
@@ -482,20 +478,14 @@ export class DirectorySync {
const onlineConnection = this.getOnlineConnection();
if (!onlineConnection) return;
const { client, source } = onlineConnection;
const transaction = this.workspaceTransactions.begin(source, () => {
const baseline = this.workspaces.snapshot();
return {
workspaces: new Map(baseline.workspaces),
projects: new Map(baseline.projects),
// Cache hydration can advance the live replica while these requests await responses.
// Every page must use the cursors belonging to the maps captured here.
requestCursors: { ...this.cursors },
syncCursors: {},
syncModes: {},
touchedWorkspaceIds: new Set(),
touchedProjectIds: new Set(),
};
});
const transaction = this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
syncCursors: {},
syncModes: {},
touchedWorkspaceIds: new Set(),
touchedProjectIds: new Set(),
}));
try {
await this.waitForSessionMetadata(client, source);
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
@@ -527,7 +517,7 @@ export class DirectorySync {
private async fetchWorkspaceSnapshot(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
initialSubscribe: boolean,
supportsDirectorySync: boolean,
): Promise<void> {
@@ -537,9 +527,7 @@ export class DirectorySync {
const query: Parameters<DaemonClient["observeWorkspaces"]>[0] = {
sort: [{ key: "activity_at", direction: "desc" }],
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
...(supportsDirectorySync
? { sync: transaction.snapshot.requestCursors.workspaces ?? {} }
: {}),
...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}),
};
let payload: FetchWorkspacesPayload;
if (subscribe) {
@@ -679,7 +667,7 @@ export class DirectorySync {
private assertWorkspaceTransactionCurrent(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
): void {
if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
throw new DirectoryRefreshSupersededError("workspace fetch no longer current");
@@ -736,13 +724,11 @@ export class DirectorySync {
private async fetchProjectSnapshot(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
supportsDirectorySync: boolean,
): Promise<void> {
const payload = await client.listProjects(
supportsDirectorySync
? { sync: transaction.snapshot.requestCursors.projects ?? {} }
: undefined,
supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined,
);
this.assertWorkspaceTransactionCurrent(client, source, transaction);
if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear();
@@ -767,7 +753,7 @@ export class DirectorySync {
private completeWorkspaceRefresh(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
): void {
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
@@ -256,41 +256,6 @@ function deleteDirectory(cache: ReplicaCache, serverId: string): void {
}
describe("ReplicaCache", () => {
it("rejects pre-baseline-fix checkpoints without discarding cached rows or timelines", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const baseline = directory();
writer.replaceDirectoryBaseline(SERVER_ID, baseline);
writer.commitTimeline(SERVER_ID, "agent-1", timeline());
await writer.flush();
await storage.apply({
deletes: [],
upserts: [
{
serverId: SERVER_ID,
kind: "checkpoint",
id: "singleton",
payload: JSON.stringify(baseline.checkpoint),
},
],
});
const reader = createCache(storage);
const restored = await reader.readDirectory(SERVER_ID);
expect(restored.checkpoint).toBeUndefined();
expect([...restored.workspaces.keys()]).toEqual([...baseline.workspaces.keys()]);
expect([...restored.projects.keys()]).toEqual([...baseline.projects.keys()]);
expect([...restored.agents.keys()]).toEqual([...baseline.agents.keys()]);
expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual(timeline());
expect(await storage.read(SERVER_ID, ["checkpoint"])).toEqual([]);
reader.replaceDirectoryBaseline(SERVER_ID, baseline);
await reader.flush();
expect((await createCache(storage).readDirectory(SERVER_ID)).checkpoint).toEqual(
baseline.checkpoint,
);
});
it("does nothing until an owner explicitly commits data", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
@@ -378,21 +378,6 @@ const DirectoryCheckpointSchema = z.strictObject({
agents: DirectoryCursorSchema.optional(),
});
// Old checkpoints could describe a baseline that an overlapping refresh had discarded.
// Revalidate directory contents once without evicting cached rows or timelines.
const StoredDirectoryCheckpointSchema = z.strictObject({
version: z.literal(1),
cursors: DirectoryCheckpointSchema,
});
function serializeDirectoryCheckpoint(cursors: DirectoryCheckpoint) {
return { version: 1 as const, cursors };
}
function deserializeDirectoryCheckpoint(payload: string): DirectoryCheckpoint {
return parseStoredPayload(StoredDirectoryCheckpointSchema, payload).cursors;
}
function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null {
if (!stored) {
return null;
@@ -864,7 +849,7 @@ function applyDirectoryRow(
if (row.id !== REPLICA_SINGLETON_ROW_ID) {
throw new Error("Replica checkpoint row id mismatch");
}
result.checkpoint = deserializeDirectoryCheckpoint(row.payload);
result.checkpoint = parseStoredPayload(DirectoryCheckpointSchema, row.payload);
return;
default:
return;
@@ -1030,7 +1015,7 @@ export class ReplicaCache {
let checkpoint: DirectoryCheckpoint | undefined;
if (checkpointRow) {
try {
checkpoint = deserializeDirectoryCheckpoint(checkpointRow.payload);
checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload);
checkpoint = { ...checkpoint };
delete checkpoint[invalidEntity];
} catch {
@@ -1046,7 +1031,7 @@ export class ReplicaCache {
serverId: row.serverId,
kind: "checkpoint",
id: REPLICA_SINGLETON_ROW_ID,
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
payload: JSON.stringify(checkpoint),
},
]
: [],
@@ -1072,7 +1057,7 @@ export class ReplicaCache {
serverId,
kind: "checkpoint",
id: REPLICA_SINGLETON_ROW_ID,
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
payload: JSON.stringify(checkpoint),
},
]
: [],
@@ -1338,7 +1323,7 @@ export class ReplicaCache {
if (!value) return null;
break;
case "checkpoint":
value = serializeDirectoryCheckpoint(upsert.value);
value = upsert.value;
break;
}
return {
@@ -195,7 +195,7 @@ function ThemeRow({
<Text style={styles.triggerText}>{selectedLabel}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200} scrollable>
<DropdownMenuContent side="bottom" align="end" width={200}>
{THEME_OPTIONS.map((option, index) => {
const previousOption = THEME_OPTIONS[index - 1];
return (
@@ -622,7 +622,7 @@ function RestartDaemonCard({ host }: { host: HostProfile }) {
{
restartServer: (reason) => daemonClient.restartServer(reason),
getStatus: async () => ({
...(await daemonClient.getDaemonStatus()),
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
}),
@@ -766,7 +766,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) {
void updateDaemonFromSettings(host.serverId, {
updateDaemon: () => daemonClient.updateDaemon(requestId),
getStatus: async () => ({
...(await daemonClient.getDaemonStatus()),
...(await daemonClient.getDaemonStatus({ timeout: 1500 })),
serverId: daemonClient.getLastServerInfoMessage()?.serverId ?? "",
version: daemonClient.getLastServerInfoMessage()?.version ?? null,
}),
@@ -377,7 +377,8 @@ export function KeyboardShortcutsSection() {
event.preventDefault();
event.stopPropagation();
if (event.code === "Backspace" && heldModifiersFromEvent(event) === null) {
const key = event.key ?? "";
if (key === "Backspace") {
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
return;
}
@@ -1,4 +1,4 @@
import { page, userEvent } from "@vitest/browser/context";
import { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
@@ -40,7 +40,6 @@ interface MountedTerminal {
sizes: TerminalSize[];
terminalKeys: TerminalKeyRecord[];
inputModeChanges: TerminalInputModeState[];
openedUrls: string[];
}
const mountedTerminals: MountedTerminal[] = [];
@@ -96,7 +95,6 @@ function createTerminalHost(input: {
const inputs: string[] = [];
const terminalKeys: TerminalKeyRecord[] = [];
const inputModeChanges: TerminalInputModeState[] = [];
const openedUrls: string[] = [];
const runtime = new TerminalEmulatorRuntime();
runtime.setCallbacks({
callbacks: {
@@ -112,9 +110,6 @@ function createTerminalHost(input: {
onInputModeChange: (state) => {
inputModeChanges.push(state);
},
onOpenExternalUrl: (url) => {
openedUrls.push(url);
},
},
});
runtime.mount({
@@ -129,16 +124,7 @@ function createTerminalHost(input: {
},
});
const mounted = {
host,
root,
runtime,
inputs,
sizes,
terminalKeys,
inputModeChanges,
openedUrls,
};
const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges };
mountedTerminals.push(mounted);
return mounted;
}
@@ -202,43 +188,7 @@ function dispatchTerminalKey(input: {
);
}
/** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */
function hyperlink(input: { url: string; text: string }): string {
return `\x1b]8;;${input.url}\x1b\\${input.text}\x1b]8;;\x1b\\`;
}
function writeLines(mounted: MountedTerminal, lines: string[]): Promise<void> {
return new Promise((resolve) => {
mounted.runtime.write({
data: terminalOutput(lines.map((line) => `${line}\r\n`).join("")),
onCommitted: resolve,
});
});
}
async function clickTerminalLink(input: {
host: HTMLElement;
row: number;
col: number;
}): Promise<void> {
const terminal = getBrowserTerminal();
const screen = input.host.querySelector<HTMLElement>(".xterm-screen");
if (!screen) {
throw new Error("Expected xterm screen to be mounted");
}
const position = {
x: (input.col + 0.5) * (screen.clientWidth / terminal.cols),
y: (input.row + 0.5) * (screen.clientHeight / terminal.rows),
};
await userEvent.hover(screen, { position });
await waitFor({
predicate: () => input.host.querySelector(".xterm-cursor-pointer") !== null,
});
await userEvent.click(screen, { position });
}
afterEach(() => {
vi.restoreAllMocks();
for (const mounted of mountedTerminals.splice(0)) {
mounted.runtime.unmount();
mounted.root.remove();
@@ -576,23 +526,4 @@ describe("terminal emulator runtime in a real browser", () => {
expect(reset).not.toHaveBeenCalled();
});
it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
await waitFor({ predicate: () => mounted.sizes.length > 0 });
await writeLines(mounted, [
hyperlink({ url: "https://example.com/osc8", text: "example" }),
"https://example.com/plain",
]);
await clickTerminalLink({ host: mounted.host, row: 1, col: 4 });
await clickTerminalLink({ host: mounted.host, row: 0, col: 2 });
expect(mounted.openedUrls).toEqual(["https://example.com/plain", "https://example.com/osc8"]);
expect(confirm).not.toHaveBeenCalled();
});
});
@@ -434,10 +434,6 @@ export class TerminalEmulatorRuntime {
this.inputModeTracker.reset();
this.emitInputModeChange();
const openExternalLink = (event: MouseEvent, uri: string) => {
event.preventDefault();
void this.callbacks.onOpenExternalUrl?.(uri);
};
const terminal = new Terminal({
allowProposedApi: true,
convertEol: false,
@@ -445,8 +441,6 @@ export class TerminalEmulatorRuntime {
cursorStyle: "bar",
fontFamily: resolveTerminalFontFamily(input.fontFamily),
fontSize: resolveTerminalFontSize(input.fontSize),
// OSC 8 hyperlinks; without a handler xterm prompts and calls window.open().
linkHandler: { activate: openExternalLink },
lineHeight: 1.0,
macOptionIsMeta: true,
minimumContrastRatio: 1,
@@ -463,7 +457,12 @@ export class TerminalEmulatorRuntime {
let imageAddon: ImageAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
terminal.loadAddon(new WebLinksAddon(openExternalLink));
terminal.loadAddon(
new WebLinksAddon((event, uri) => {
event.preventDefault();
void this.callbacks.onOpenExternalUrl?.(uri);
}),
);
const localFileLinkProvider = terminal.registerLinkProvider(
createTerminalLocalFileLinkProvider(terminal, {
resolveLink: async (source) => {
-11
View File
@@ -108,17 +108,6 @@ describe("formatMessageTimestamp", () => {
expect(formatted).toMatch(/10:12 PM|22:12/);
});
it("shows the full date for last week's same weekday, even under seven days ago", () => {
// 2026-09-18 is a Friday. The reply is 6 days 23h45m old and lands on today's weekday,
// so a weekday label would read as today.
const now = new Date(2026, 8, 25, 11, 47);
const date = new Date(2026, 8, 18, 12, 2);
const formatted = formatMessageTimestamp(date, now);
expect(formatted).toMatch(/Sep|September/);
expect(formatted).toMatch(/18/);
expect(formatted).not.toMatch(/Friday/);
});
it("includes full date for older timestamps", () => {
const now = new Date(2026, 4, 14, 17, 30);
const date = new Date(2026, 3, 1, 9, 5);
+12 -13
View File
@@ -91,14 +91,12 @@ export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string
return describeCompactTimeAgo(date, now).label;
}
/**
* How many local midnights lie between two instants: 0 for the same day, 1 for yesterday.
* Counted on the calendar rather than in elapsed time, so six days and 23 hours ago on
* today's weekday is 7, and rounded so a DST day of 23 or 25 hours still counts as one.
*/
function localCalendarDaysBetween(earlier: Date, later: Date): number {
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
return Math.round((startOfDay(later).getTime() - startOfDay(earlier).getTime()) / DAY_MS);
function isSameLocalDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
// Cached Intl formatter. Explicitly carrying `hourCycle` from the resolved
@@ -122,18 +120,19 @@ function getTimeFormatter(): Intl.DateTimeFormat {
/**
* Format a chat-message timestamp for hover-revealed UI.
* - Same day: "10:11 PM" or "22:11" depending on user preference
* - The previous 6 calendar days: "Wednesday 10:11 PM"
* - Older, including today's weekday last week: "14 May 2026, 10:11 PM"
* - Within ~6 days: "Wednesday 10:11 PM"
* - Older: "14 May 2026, 10:11 PM"
*/
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
const time = getTimeFormatter().format(date);
const daysAgo = localCalendarDaysBetween(date, now);
if (daysAgo === 0) {
if (isSameLocalDay(date, now)) {
return time;
}
if (daysAgo > 0 && daysAgo < 7) {
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays >= 0 && diffDays < 7) {
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
return `${weekday} ${time}`;
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.9.2",
"version": "0.9.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/server": "0.9.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/server": "0.9.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+4 -43
View File
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import {
resolveExistingRunWorkspace,
resolveRunCallerAgentId,
@@ -9,51 +8,13 @@ import {
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
// Answers fetchAgent the way a daemon does: an unknown id is an error.
function daemonWithAgents(...agentIds: string[]) {
return {
async fetchAgent({ agentId }: { agentId: string }) {
if (!agentIds.includes(agentId)) {
throw new Error(`Agent not found: ${agentId}`);
}
return { agent: { id: agentId } };
},
};
}
describe("managed agent caller context", () => {
it("uses a trimmed PASEO_AGENT_ID when the target daemon runs that agent", async () => {
await expect(
resolveRunCallerAgentId(daemonWithAgents("parent-agent"), {
PASEO_AGENT_ID: " parent-agent ",
}),
).resolves.toBe("parent-agent");
it("propagates a trimmed PASEO_AGENT_ID", () => {
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " parent-agent " })).toBe("parent-agent");
});
it("runs without a caller when PASEO_AGENT_ID belongs to another daemon", async () => {
await expect(
resolveRunCallerAgentId(daemonWithAgents("other-agent"), {
PASEO_AGENT_ID: "parent-agent",
}),
).resolves.toBeUndefined();
});
it("fails instead of dropping the caller when the lookup loses its connection", async () => {
const disconnectedDaemon = {
async fetchAgent(): Promise<never> {
throw new DaemonConnectionError("Connection lost before message could be sent");
},
};
await expect(
resolveRunCallerAgentId(disconnectedDaemon, { PASEO_AGENT_ID: "parent-agent" }),
).rejects.toBeInstanceOf(DaemonConnectionError);
});
it("omits blank caller ids", async () => {
await expect(
resolveRunCallerAgentId(daemonWithAgents(), { PASEO_AGENT_ID: " " }),
).resolves.toBeUndefined();
it("omits blank caller ids", () => {
expect(resolveRunCallerAgentId({ PASEO_AGENT_ID: " " })).toBeUndefined();
});
});
+7 -29
View File
@@ -4,7 +4,6 @@ import {
StructuredAgentResponseError,
} from "@getpaseo/server/agent-response";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { connectToDaemon } from "../../utils/client.js";
import type {
CommandOptions,
@@ -527,7 +526,7 @@ export async function resolveExistingRunWorkspace(
// Workspace policy for `paseo run`. Precedence:
// 1. --workspace <id> -> run in that existing workspace
// 2. caller agent -> daemon resolves the caller's workspace
// 2. $PASEO_AGENT_ID -> daemon resolves the caller's workspace
// 3. $PASEO_WORKSPACE_ID -> exported by workspace terminals
// 4. --new-workspace <kind> -> mint a new workspace explicitly
// 5. bare run -> mint a new local-backed workspace for cwd
@@ -535,7 +534,6 @@ async function resolveRunWorkspace(
client: ConnectedDaemonClient,
options: AgentRunOptions,
cwd: string,
callerAgentId: string | undefined,
): Promise<RunWorkspace> {
const newWorkspace = resolveNewWorkspaceKind(options);
const explicit = newWorkspace ? undefined : options.workspace?.trim();
@@ -544,7 +542,7 @@ async function resolveRunWorkspace(
return resolveExistingRunWorkspace(client, explicit);
}
if (!newWorkspace && callerAgentId) {
if (!newWorkspace && resolveRunCallerAgentId()) {
return { cwd };
}
@@ -611,9 +609,9 @@ export async function runRunCommand(
const env = parseRunEnv(options.env);
const requestEnv = Object.keys(env).length > 0 ? env : undefined;
const callerAgentId = await resolveRunCallerAgentId(client);
const workspace = await resolveRunWorkspace(client, options, cwd, callerAgentId);
const workspace = await resolveRunWorkspace(client, options, cwd);
const workspaceId = workspace.id;
const callerAgentId = resolveRunCallerAgentId();
const runCwd = workspace.cwd;
if (outputSchema) {
@@ -745,28 +743,8 @@ export async function runRunCommand(
}
}
export interface RunCallerLookupClient {
fetchAgent(options: { agentId: string }): Promise<{ agent: { id: string } } | null>;
}
// PASEO_AGENT_ID names an agent on the daemon that launched this shell. A run
// sent to another daemon (--host or --home) has no caller there, so it runs as
// a top-level agent instead of failing on an unknown caller.
export async function resolveRunCallerAgentId(
client: RunCallerLookupClient,
export function resolveRunCallerAgentId(
env: { PASEO_AGENT_ID?: string } = process.env,
): Promise<string | undefined> {
const agentId = env.PASEO_AGENT_ID?.trim();
if (!agentId) {
return undefined;
}
const caller = await client.fetchAgent({ agentId }).catch((error: unknown) => {
// A daemon without this agent answers with an error. A lost or timed-out
// connection is not an answer, so it must not drop the caller.
if (error instanceof DaemonConnectionError) {
throw error;
}
return null;
});
return caller?.agent.id === agentId ? agentId : undefined;
): string | undefined {
return env.PASEO_AGENT_ID?.trim() || undefined;
}
@@ -5,7 +5,6 @@ import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { hashDaemonPassword } from "@getpaseo/server/auth";
import { startDaemonInstance, readDaemonInstance } from "@getpaseo/server/daemon-control";
import { expect, test } from "vitest";
import { connectToDaemon } from "../../utils/client.js";
@@ -197,26 +196,6 @@ test("removed flags and ambiguous targets fail before side effects; observation
}
}, 30_000);
test("status reports the server id of a password-protected daemon to a caller without the password", async () => {
const f = await fixture();
const home = f.homes[0]!;
try {
await f.configure(home, `127.0.0.1:${await port()}`);
const configPath = path.join(home, "config.json");
const config = JSON.parse(await readFile(configPath, "utf8"));
config.daemon.auth = { password: await hashDaemonPassword("secret") };
await writeFile(configPath, JSON.stringify(config));
await f.ok(["start", "--home", home, "--timeout", "30"]);
const authenticated = await f.liveStatus(home, { PASEO_PASSWORD: "secret" });
expect(await f.ok(["daemon", "status", "--home", home])).toMatchObject({
connectedDaemon: "auth_required",
serverId: authenticated.serverId,
});
} finally {
await f.close();
}
}, 60_000);
test("an occupied initial or replacement address fails without false readiness or killing its owner", async () => {
const f = await fixture();
const [a, b] = f.homes as [string, string];
@@ -612,25 +591,6 @@ test("malformed configuration cannot turn a readiness timeout into launch cancel
}
}, 60_000);
test("a background start that fails before the supervisor logs names the cause in a log that exists", async () => {
const f = await fixture();
const home = f.homes[0]!;
const configPath = path.join(home, "config.json");
try {
await mkdir(home, { recursive: true });
await writeFile(configPath, '{"version":1,');
const failed = await f.run(["daemon", "start", "--home", home, "--timeout", "30"]);
expect(failed.code).toBe(1);
expect(failed.stderr).toContain(`Logs: ${path.join(home, "daemon.log")}`);
expect(failed.stderr).toContain(`Invalid JSON in ${configPath}`);
expect(await readFile(path.join(home, "daemon.log"), "utf8")).toContain(
`Invalid JSON in ${configPath}`,
);
} finally {
await f.close();
}
}, 60_000);
test("onboarding preserves an existing MCP choice unless explicitly supplied", async () => {
const f = await fixture();
const home = f.homes[0]!;
@@ -57,17 +57,6 @@ function createCommandError(code: string, message: string, details?: string): Co
return { code, message, ...(details ? { details } : {}) };
}
function terminalPasswordPrompt(): PromptPassword {
if (!process.stdin.isTTY) {
throw createCommandError(
"PASSWORD_TTY_REQUIRED",
"paseo daemon set-password needs a terminal to read the password",
"Run it in an interactive terminal, or set PASEO_PASSWORD in the daemon's environment instead.",
);
}
return (message) => passwordPrompt({ message });
}
async function promptForPassword(promptPassword: PromptPassword): Promise<string> {
const first = await promptPassword("New daemon password");
if (isCancel(first)) {
@@ -123,7 +112,7 @@ export async function runSetPasswordCommand(
const promptPassword =
typeof options.promptPassword === "function"
? (options.promptPassword as PromptPassword)
: terminalPasswordPrompt();
: (message: string) => passwordPrompt({ message });
const newPassword = await promptForPassword(promptPassword);
const result = await setDaemonPasswordInConfig(newPassword, {
home: options.daemonTarget.kind === "instance" ? options.daemonTarget.home : undefined,
@@ -124,7 +124,6 @@ function localStatus(home: string, instance: Awaited<ReturnType<typeof readDaemo
pid: instance?.pid ?? null,
startedAt: instance?.startedAt ?? null,
listen: instance?.listen ?? null,
serverId: instance?.serverId ?? undefined,
hostname: instance?.hostname ?? null,
configuredListen: config.listen,
localDaemon,
+8 -17
View File
@@ -16,7 +16,7 @@ export interface PermissionResponseItem {
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
idField: "requestId",
columns: [
{ header: "REQUEST ID", field: (item) => item.requestId.slice(0, 8), width: 12 },
{ header: "REQUEST ID", field: "requestId", width: 12 },
{ header: "AGENT", field: "agentShortId", width: 10 },
{ header: "TOOL", field: "name", width: 20 },
{
@@ -32,21 +32,6 @@ export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
],
};
/** Transform a permission the agent answered to a response item */
export function toPermissionResponseItem(
agentId: string,
permission: AgentPermissionRequest,
result: "allowed" | "denied",
): PermissionResponseItem {
return {
requestId: permission.id,
agentId,
agentShortId: agentId.slice(0, 7),
name: permission.name,
result,
};
}
export type PermitAllowResult = ListResult<PermissionResponseItem>;
export interface PermitAllowOptions extends CommandOptions {
@@ -133,7 +118,13 @@ export async function runAllowCommand(
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
});
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
return {
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: "allowed",
};
}),
);
+8 -6
View File
@@ -2,11 +2,7 @@ import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
import {
permitResponseSchema,
toPermissionResponseItem,
type PermissionResponseItem,
} from "./allow.js";
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
export type PermitDenyResult = ListResult<PermissionResponseItem>;
@@ -87,7 +83,13 @@ export async function runDenyCommand(
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
});
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
return {
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: "denied",
};
}),
);
+17 -19
View File
@@ -18,7 +18,7 @@ export const permitLsSchema: OutputSchema<PermissionListItem> = {
idField: "id",
columns: [
{ header: "AGENT", field: "agentShortId", width: 12 },
{ header: "REQ_ID", field: (item) => item.id.slice(0, 8), width: 12 },
{ header: "REQ_ID", field: "id", width: 12 },
{ header: "TOOL", field: "name", width: 20 },
{ header: "DESCRIPTION", field: "description", width: 50 },
],
@@ -30,7 +30,7 @@ function toListItem(
permission: AgentPermissionRequest,
): PermissionListItem {
return {
id: permission.id,
id: permission.id.slice(0, 8),
agentId: agent.id,
agentShortId: agent.id.slice(0, 7),
name: permission.name,
@@ -40,22 +40,6 @@ function toListItem(
export type PermitLsResult = ListResult<PermissionListItem>;
/** Collect the pending permissions of every agent */
export function listPendingPermissions(agents: AgentSnapshotPayload[]): PermitLsResult {
const items: PermissionListItem[] = [];
for (const agent of agents) {
for (const permission of agent.pendingPermissions ?? []) {
items.push(toListItem(agent, permission));
}
}
return {
type: "list",
data: items,
schema: permitLsSchema,
};
}
export interface PermitLsOptions extends CommandOptions {
host?: string;
}
@@ -71,7 +55,21 @@ export async function runLsCommand(
const agents = agentsPayload.entries.map((entry) => entry.agent);
await client.close();
return listPendingPermissions(agents);
// Collect all pending permissions from all agents
const items: PermissionListItem[] = [];
for (const agent of agents) {
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
for (const permission of agent.pendingPermissions) {
items.push(toListItem(agent, permission));
}
}
}
return {
type: "list",
data: items,
schema: permitLsSchema,
};
} catch (err) {
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
@@ -1,45 +0,0 @@
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import { render } from "../../output/index.js";
import { permitResponseSchema, toPermissionResponseItem } from "./allow.js";
import { listPendingPermissions } from "./ls.js";
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
function permissionRequest(id: string): AgentPermissionRequest {
return { id, provider: "claude", name: "Write", kind: "tool" };
}
const writeRequest = permissionRequest("permission-cc93510e-a45d-4c4e-a0bf-fc2821f82783");
const editRequest = permissionRequest("permission-654ff862-b312-4f6e-8aac-427421ff65eb");
const agent = {
id: agentId,
pendingPermissions: [writeRequest, editRequest],
} as AgentSnapshotPayload;
describe("permit ls output", () => {
it("emits full request ids that permit allow accepts", () => {
const result = listPendingPermissions([agent]);
const listed = JSON.parse(render(result, { format: "json" })) as Array<{ id: string }>;
expect(listed.map((item) => item.id)).toEqual([writeRequest.id, editRequest.id]);
expect(render(result, { quiet: true }).split("\n")).toEqual([writeRequest.id, editRequest.id]);
});
});
describe("permit allow and deny output", () => {
it("emits the full id of the request that was answered", () => {
const result = {
type: "list" as const,
data: [toPermissionResponseItem(agentId, writeRequest, "allowed")],
schema: permitResponseSchema,
};
const answered = JSON.parse(render(result, { format: "json" })) as Array<{
requestId: string;
}>;
expect(answered.map((item) => item.requestId)).toEqual([writeRequest.id]);
});
});
+4 -11
View File
@@ -52,20 +52,13 @@ export function buildDaemonConnectionCommandError(options: ConnectOptions & { er
return {
code,
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
details: describeConnectionRemedy(code, options.target),
details:
options.target.kind === "instance"
? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}`
: "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.",
};
}
function describeConnectionRemedy(code: string, target: DaemonTarget): string {
if (code === "AUTH_REQUIRED")
return "The daemon requires a password. Set PASEO_PASSWORD and retry.";
if (code === "AUTH_FAILED")
return "The daemon rejected the password. Check PASEO_PASSWORD and retry.";
if (target.kind === "instance")
return `Start with: paseo daemon start --home ${JSON.stringify(target.home)}`;
return "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.";
}
export function normalizeDaemonHost(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
@@ -6,7 +6,6 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Command } from "commander";
import { isBearerTokenValid } from "@getpaseo/server/auth";
import { runLocalPaseo } from "./helpers/local-cli.ts";
import {
runSetPasswordCommand,
setDaemonPasswordInConfig,
@@ -106,27 +105,6 @@ try {
);
console.log("✓ command refuses password mismatch\n");
}
{
console.log("Test 4: piped stdin fails with a message instead of exiting silently");
const pipedHome = join(root, "piped");
await mkdir(pipedHome, { recursive: true });
const run = runLocalPaseo(["daemon", "set-password", "--home", pipedHome, "--json"]);
run.stdin.end("x\nx\n");
const result = await run;
assert.strictEqual(result.exitCode, 1, result.stderr);
const error = JSON.parse(result.stderr).error as {
code: string;
message: string;
details: string;
};
assert.strictEqual(error.code, "PASSWORD_TTY_REQUIRED");
assert.match(error.message, /needs a terminal/);
assert.match(error.details, /PASEO_PASSWORD/);
await assert.rejects(readFile(join(pipedHome, "config.json"), "utf-8"));
console.log("✓ piped stdin reports that a terminal is required\n");
}
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -1,51 +0,0 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { runLocalPaseo } from "./helpers/local-cli.ts";
import { startTestDaemon } from "./helpers/test-daemon.ts";
console.log("=== Daemon Auth Command Errors ===\n");
const daemon = await startTestDaemon({
env: { PASEO_PASSWORD: "shared-secret" },
});
async function lsError(password: string) {
const result = await runLocalPaseo(["ls", "--json"], {
PASEO_HOME: daemon.paseoHome,
PASEO_HOST: "",
PASEO_PASSWORD: password,
});
assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password");
return JSON.parse(result.stderr).error as {
code: string;
message: string;
details: string;
};
}
try {
{
console.log("Test 1: missing password asks for PASEO_PASSWORD, not a daemon start");
const error = await lsError("");
assert.strictEqual(error.code, "AUTH_REQUIRED");
assert.match(error.message, /Password required/);
assert.match(error.details, /PASEO_PASSWORD/);
assert.doesNotMatch(error.details, /daemon start/);
console.log("✓ missing password points at PASEO_PASSWORD\n");
}
{
console.log("Test 2: wrong password asks for PASEO_PASSWORD, not a daemon start");
const error = await lsError("wrong-secret");
assert.strictEqual(error.code, "AUTH_FAILED");
assert.match(error.message, /Incorrect password/);
assert.match(error.details, /PASEO_PASSWORD/);
assert.doesNotMatch(error.details, /daemon start/);
console.log("✓ wrong password points at PASEO_PASSWORD\n");
}
} finally {
await daemon.stop();
}
console.log("=== Daemon Auth Command Errors Tests Passed ===");
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.9.2",
"version": "0.9.1",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -14,13 +14,10 @@ const SHORTCUTS_ROW = "show-shortcuts";
* daemon is left unmanaged so the app talks to the E2E daemon instead of trying
* to start one of its own.
*/
async function installDesktopBridge(
page: Page,
platform: "darwin" | "win32" = "darwin",
): Promise<void> {
await page.addInitScript((desktopPlatform) => {
async function installDesktopBridge(page: Page): Promise<void> {
await page.addInitScript(() => {
window.paseoDesktop = {
platform: desktopPlatform,
platform: "darwin",
events: { on: () => () => {} },
invoke: async (command: string) => {
if (command === "get_desktop_settings") {
@@ -32,11 +29,11 @@ async function installDesktopBridge(
return null;
},
};
}, platform);
});
}
async function openShortcutsSettings(page: Page, platform: "darwin" | "win32" = "darwin") {
await installDesktopBridge(page, platform);
async function openShortcutsSettings(page: Page) {
await installDesktopBridge(page);
await gotoAppShell(page);
await openSettings(page);
await openSettingsSection(page, "shortcuts");
@@ -70,38 +67,6 @@ async function closeRowMenu(page: Page) {
await expect(page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`)).toHaveCount(0);
}
async function clearShortcut(page: Page, label: string, action: string) {
await page.getByRole("button", { name: `Actions for ${label}` }).click();
await page.getByTestId(`shortcut-clear-${action}`).click();
}
async function captureShortcut(page: Page, label: string, action: string, keys: string) {
await page.getByRole("button", { name: `Actions for ${label}` }).click();
await page.getByTestId(`shortcut-bind-${action}`).click();
await page.keyboard.press(keys);
}
async function expectInterruptShortcut(page: Page, chord: string) {
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
await expect(row.getByText(chord, { exact: true })).toBeVisible();
}
async function finishInterruptCapture(page: Page, action: "Done" | "Cancel") {
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
await row.getByRole("button", { name: action }).click();
}
async function eraseLastCapturedCombo(page: Page, firstCombo: string, secondCombo: string) {
await captureShortcut(page, "Interrupt agent", "agent-interrupt", "Alt+K");
await page.keyboard.press("Alt+J");
await expectInterruptShortcut(page, firstCombo);
await expectInterruptShortcut(page, secondCombo);
await page.keyboard.press("Backspace");
await expectInterruptShortcut(page, firstCombo);
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
await expect(row.getByText(secondCombo, { exact: true })).toHaveCount(0);
}
test("unassigning a shortcut leaves it inert until it is reset", async ({ page }) => {
await openShortcutsSettings(page);
@@ -181,35 +146,3 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page }
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
});
for (const { platform, keys, label, firstCombo, secondCombo } of [
{
platform: "darwin",
keys: "Meta+Shift+Backspace",
label: "⇧⌘⌫",
firstCombo: "⌥K",
secondCombo: "⌥J",
},
{
platform: "win32",
keys: "Control+Shift+Backspace",
label: "Ctrl+Shift+⌫",
firstCombo: "Alt+K",
secondCombo: "Alt+J",
},
] as const) {
test(`binds modified Backspace and erases a combo with bare Backspace on ${platform}`, async ({
page,
}) => {
await openShortcutsSettings(page, platform);
await clearShortcut(page, "Archive workspace", "archive-workspace");
await captureShortcut(page, "Interrupt agent", "agent-interrupt", keys);
await expectInterruptShortcut(page, label);
await finishInterruptCapture(page, "Done");
await expectInterruptShortcut(page, label);
await eraseLastCapturedCombo(page, firstCombo, secondCombo);
await finishInterruptCapture(page, "Cancel");
await expectInterruptShortcut(page, label);
});
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.9.2",
"version": "0.9.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.2",
"version": "0.9.1",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.9.2",
"version": "0.9.1",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.9.2",
"version": "0.9.1",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -65,8 +65,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -76,8 +76,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.9.2",
"version": "0.9.1",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.9.2",
"version": "0.9.1",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+7 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.9.2",
"version": "0.9.1",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -107,15 +107,14 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.2",
"@getpaseo/highlight": "0.9.2",
"@getpaseo/plugin": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"@getpaseo/client": "0.9.1",
"@getpaseo/highlight": "0.9.1",
"@getpaseo/plugin": "0.9.1",
"@getpaseo/protocol": "0.9.1",
"@getpaseo/relay": "0.9.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -4,27 +4,22 @@ import { fileURLToPath } from "node:url";
import { build } from "esbuild";
for (const directory of ["", "v2/"]) {
const sourcePath = fileURLToPath(
new URL(
`../src/server/agent/providers/opencode/${directory}bridge-plugin.mjs`,
import.meta.url,
),
);
const outputPath = fileURLToPath(
new URL(
`../dist/server/server/agent/providers/opencode/${directory}bridge-plugin.bundle.mjs`,
import.meta.url,
),
);
await mkdir(path.dirname(outputPath), { recursive: true });
await build({
entryPoints: [sourcePath],
bundle: true,
format: "esm",
platform: "neutral",
mainFields: ["module", "main"],
target: "es2022",
outfile: outputPath,
});
}
const sourcePath = fileURLToPath(
new URL("../src/server/agent/providers/opencode/bridge-plugin.mjs", import.meta.url),
);
const outputPath = fileURLToPath(
new URL(
"../dist/server/server/agent/providers/opencode/bridge-plugin.bundle.mjs",
import.meta.url,
),
);
await mkdir(path.dirname(outputPath), { recursive: true });
await build({
entryPoints: [sourcePath],
bundle: true,
format: "esm",
platform: "neutral",
target: "es2022",
outfile: outputPath,
});
@@ -1,5 +1,5 @@
import { fileURLToPath } from "url";
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
import { existsSync } from "node:fs";
import path from "node:path";
import {
acquirePidLock,
@@ -9,8 +9,6 @@ import {
updatePidLock,
} from "../src/server/pid-lock.js";
import { resolvePaseoHome } from "../src/server/paseo-home.js";
import { daemonLogPath } from "../src/server/daemon-instance.js";
import { PRIVATE_FILE_MODE } from "../src/server/private-files.js";
import { loadPersistedConfig } from "../src/server/persisted-config.js";
import { runSupervisor } from "./supervisor.js";
import { resolveSupervisorLogFile } from "./supervisor-log-config.js";
@@ -120,7 +118,9 @@ async function main(): Promise<void> {
});
} catch (error) {
if (error instanceof PidLockError) {
failStartup(error.message, error.message);
process.stderr.write(`${error.message}\n`);
process.exit(1);
return;
}
throw error;
}
@@ -172,41 +172,17 @@ async function main(): Promise<void> {
: undefined,
restartOnCrash: true,
logFile: supervisorLogFile,
onWorkerReady: async ({ listen, serverId }) => {
await updatePidLock(paseoHome, { listen, serverId }, { ownerPid: process.pid });
onWorkerReady: async ({ listen }) => {
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
},
onWorkerExit: () =>
updatePidLock(paseoHome, { listen: null, serverId: null }, { ownerPid: process.pid }),
onWorkerExit: () => updatePidLock(paseoHome, { listen: null }, { ownerPid: process.pid }),
onSupervisorExit: releaseLock,
});
requestSupervisorShutdown = supervisor.requestShutdown;
}
// The supervisor opens its log only after config and the PID lock succeed. A background
// launch discards stderr, so earlier failures also go to the log the launcher points at.
function failStartup(detail: string, summary: string): never {
process.stderr.write(`${detail}\n`);
try {
const logPath = daemonLogPath(resolvePaseoHome(process.env));
mkdirSync(path.dirname(logPath), { recursive: true });
appendFileSync(
logPath,
`${JSON.stringify({
level: "fatal",
time: new Date().toISOString(),
pid: process.pid,
name: "DaemonRunner",
msg: summary,
})}\n`,
{ mode: PRIVATE_FILE_MODE },
);
} catch {
// stderr already carries the failure.
}
process.exit(1);
}
void main().catch((error) => {
if (error instanceof Error) failStartup(error.stack ?? error.message, error.message);
failStartup(String(error), String(error));
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
process.stderr.write(`${message}\n`);
process.exit(1);
});
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -23,10 +23,6 @@ async function runSupervisorFixture(options: {
workerSource: string;
restartOnCrash?: boolean;
timeoutMs?: number;
/** POSIX RLIMIT_FSIZE for the supervisor, in 512-byte blocks: writes past it fail like a full disk. */
fileSizeLimitBlocks?: number;
/** Occupy the log path with a directory so the supervisor cannot open daemon.log. */
blockLogPath?: boolean;
}): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
@@ -40,12 +36,9 @@ async function runSupervisorFixture(options: {
const workerPath = path.join(tempDir, "worker.mjs");
const runnerPath = path.join(tempDir, "runner.mjs");
if (options.blockLogPath) {
await mkdir(logPath);
}
await writeFile(
workerPath,
`process.send?.({ type: "paseo:ready", listen: "fixture", serverId: "srv_fixture" });\n${options.workerSource}`,
`process.send?.({ type: "paseo:ready", listen: "fixture" });\n${options.workerSource}`,
);
await writeFile(
runnerPath,
@@ -69,18 +62,7 @@ async function runSupervisorFixture(options: {
);
const startedAt = Date.now();
const runnerArgs = [process.execPath, "--import", "tsx", runnerPath];
const [command, ...args] =
options.fileSizeLimitBlocks === undefined
? runnerArgs
: [
"/bin/sh",
"-c",
`ulimit -f ${options.fileSizeLimitBlocks} && exec "$@"`,
"sh",
...runnerArgs,
];
const child = spawn(command, args, {
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: repoRoot,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
@@ -366,55 +348,4 @@ describe("supervisor durable logging", () => {
expect(result.log).toContain("Supervisor exiting");
},
);
// POSIX-only: RLIMIT_FSIZE is how the test makes daemon.log writes fail.
test.skipIf(isPlatform("win32"))(
"keeps supervising the worker when daemon.log can no longer be written",
async () => {
const result = await runSupervisorFixture({
fileSizeLimitBlocks: 128,
workerSource: `
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
const line = '{"level":30,"msg":"' + "x".repeat(1000) + '"}\\n';
for (let i = 0; i < 200; i += 1) process.stdout.write(line);
setTimeout(() => {
process.send?.({ type: "paseo:shutdown", reason: "log_write_failure_probe" });
}, 1000);
setInterval(() => {}, 1000);
`,
});
expect(result.stderr).not.toContain("Unhandled 'error' event");
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
},
);
test("resumes writing daemon.log once the log path is writable again", async () => {
const result = await runSupervisorFixture({
blockLogPath: true,
workerSource: `
import { rmdirSync } from "node:fs";
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
process.stdout.write("line while daemon.log is blocked\\n");
setTimeout(() => {
rmdirSync(process.argv[1].replace(/worker\\.mjs$/, "daemon.log"));
process.stdout.write("line after daemon.log is writable\\n");
setTimeout(() => {
process.send?.({ type: "paseo:shutdown", reason: "log_recovery_probe" });
}, 200);
}, 500);
setInterval(() => {}, 1000);
`,
});
expect(result.code).toBe(0);
expect(result.log).toContain("line after daemon.log is writable\n");
expect(result.log).toContain('"reason":"log_recovery_probe"');
});
});
@@ -92,7 +92,7 @@ test("a replacement worker must independently reach ready", async () => {
process.on("message", message => {
if (message.type === "paseo:graceful-shutdown") process.exit(0);
});
process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" });
process.send({ type: "paseo:ready", listen: "test-endpoint" });
process.send({ type: "paseo:restart" });
`);
expect(result.code).toBe(1);
@@ -102,7 +102,7 @@ test("a replacement worker must independently reach ready", async () => {
test("exit clears publication after an in-flight ready write", async () => {
const result = await runWorker(
`
process.send({ type: "paseo:ready", listen: "test-endpoint", serverId: "srv_test" }, () => process.exit(0));
process.send({ type: "paseo:ready", listen: "test-endpoint" }, () => process.exit(0));
`,
100,
);
+26 -73
View File
@@ -1,9 +1,7 @@
import { fork, spawn, type ChildProcess } from "child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import {
createStream as createRotatingFileStream,
type RotatingFileStream,
} from "rotating-file-stream";
import { createStream as createRotatingFileStream } from "rotating-file-stream";
import { signalProcessTree } from "../src/utils/tree-kill.js";
const WORKER_HEARTBEAT_INTERVAL_MS = 1_000;
@@ -25,7 +23,6 @@ type WorkerLifecycleMessage =
| {
type: "paseo:ready";
listen: string;
serverId: string;
}
| {
type: "paseo:restart";
@@ -53,7 +50,7 @@ interface SupervisorOptions {
args: string[];
env?: NodeJS.ProcessEnv;
} | null;
onWorkerReady?: (message: { listen: string; serverId: string }) => Promise<void> | void;
onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
onWorkerExit?: () => Promise<void> | void;
restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void;
@@ -81,14 +78,11 @@ function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
};
}
if (type === "paseo:ready") {
const { listen, serverId } = msg as { listen?: unknown; serverId?: unknown };
const listen = (msg as { listen?: unknown }).listen;
if (typeof listen !== "string" || listen.trim().length === 0) {
return null;
}
if (typeof serverId !== "string" || serverId.trim().length === 0) {
return null;
}
return { type: "paseo:ready", listen, serverId };
return { type: "paseo:ready", listen };
}
if (type === "paseo:restart") {
const reason = (msg as { reason?: unknown }).reason;
@@ -112,58 +106,17 @@ function toRotatingFileStreamSize(size: string): string {
return `${value}${unit}`;
}
// daemon.log is best effort: a full disk or a replaced log path must not take the
// supervisor, and with it the worker, down. A failed stream is dropped and a new one
// is opened on the next write, so logging resumes once the file is writable again.
function createDurableLog(
options: SupervisorLogFileOptions | undefined,
reportFailure: (message: string) => void,
) {
let stream: RotatingFileStream | null = null;
let failing = false;
function createSupervisorLogStream(options: SupervisorLogFileOptions | undefined) {
if (!options) {
return null;
}
const open = (logFile: SupervisorLogFileOptions): RotatingFileStream => {
const next = createRotatingFileStream(path.basename(logFile.path), {
path: path.dirname(logFile.path),
size: toRotatingFileStreamSize(logFile.rotate.maxSize),
maxFiles: logFile.rotate.maxFiles,
});
next.on("error", (error) => {
if (stream === next) {
stream = null;
}
if (!failing) {
failing = true;
reportFailure(
`Cannot write ${logFile.path}, will retry on the next log line: ${error.message}`,
);
}
});
return next;
};
return {
write(chunk: string | Buffer): void {
if (!options) {
return;
}
stream ??= open(options);
stream.write(chunk, (error) => {
if (!error) {
failing = false;
}
});
},
close(): Promise<void> {
return new Promise((resolve) => {
if (!stream) {
resolve();
return;
}
stream.end(() => resolve());
});
},
};
mkdirSync(path.dirname(options.path), { recursive: true });
return createRotatingFileStream(path.basename(options.path), {
path: path.dirname(options.path),
size: toRotatingFileStreamSize(options.rotate.maxSize),
maxFiles: options.rotate.maxFiles,
});
}
export function runSupervisor(options: SupervisorOptions): SupervisorController {
@@ -180,12 +133,10 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
let lifecycleFailed = false;
let publication = Promise.resolve();
let forceKillTimer: NodeJS.Timeout | null = null;
const durableLog = createDurableLog(options.logFile, (message) => {
process.stderr.write(`[${options.name}] ${message}\n`);
});
const logStream = createSupervisorLogStream(options.logFile);
const writeDurableChunk = (chunk: string | Buffer): void => {
durableLog.write(chunk);
logStream?.write(chunk);
};
const writeLifecycleLog = (message: string, fields: Record<string, unknown> = {}): void => {
@@ -206,7 +157,14 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
writeLifecycleLog(message);
};
const closeLogStream = (): Promise<void> => durableLog.close();
const closeLogStream = (): Promise<void> =>
new Promise((resolve) => {
if (!logStream) {
resolve();
return;
}
logStream.end(resolve);
});
const exitSupervisor = (code: number): void => {
if (exiting) {
@@ -331,12 +289,7 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
reachedReady = true;
writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen });
publication = publication
.then(() =>
options.onWorkerReady?.({
listen: lifecycleMessage.listen,
serverId: lifecycleMessage.serverId,
}),
)
.then(() => options.onWorkerReady?.({ listen: lifecycleMessage.listen }))
.catch((error) => {
lifecycleFailed = true;
const message = error instanceof Error ? error.message : String(error);
@@ -11244,129 +11244,3 @@ test("concurrent native restores run once before resuming the same agent", async
rmSync(workdir, { recursive: true, force: true });
}
});
test("commits startup notices once on create and after restored history", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-startup-notice-"));
const notice: AgentTimelineItem = { type: "notification", level: "info", message: "Runtime v2" };
const history: AgentTimelineItem = { type: "assistant_message", text: "Existing conversation" };
const toolOutput: AgentTimelineItem = {
type: "tool_call",
callId: "startup-output",
name: "shell",
status: "completed",
error: null,
detail: {
type: "shell",
command: "print output",
output: "x".repeat(1024 * 1024),
exitCode: 0,
},
};
const limitedToolOutput: AgentTimelineItem = {
...toolOutput,
detail: { type: "shell", command: "print output", output: "x".repeat(64 * 1024), exitCode: 0 },
};
class NotifyingSession extends TestAgentSession {
readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }];
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: "codex",
item: history,
timestamp: "2026-09-21T00:00:00.000Z",
};
yield {
type: "timeline",
provider: "codex",
item: toolOutput,
timestamp: "2026-09-21T01:00:00.000Z",
};
yield { type: "timeline", provider: "codex", ...this.initialTimeline[0] };
}
}
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig) {
return new NotifyingSession(config);
}
override async resumeSession() {
return new NotifyingSession({ provider: "codex", cwd: workdir });
}
})();
const store = new RecordingTimelineStore();
const manager = new AgentManager({
clients: { codex: client },
durableTimelineStore: store,
logger,
});
const ids: string[] = [];
try {
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {});
ids.push(created.id);
expect((await manager.getTimelineRows(created.id)).map((row) => row.item)).toEqual([notice]);
const resumed = await manager.resumeAgentFromPersistence({
provider: "codex",
sessionId: "existing",
metadata: { cwd: workdir },
});
ids.push(resumed.id);
await manager.hydrateTimelineFromProvider(resumed.id);
expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([
history,
limitedToolOutput,
notice,
]);
await manager.hydrateTimelineFromProvider(resumed.id, { force: true });
expect((await manager.getTimelineRows(resumed.id)).map((row) => row.item)).toEqual([
history,
limitedToolOutput,
notice,
]);
await manager.flush();
expect(store.writes.flat()).toContainEqual(expect.objectContaining({ item: notice }));
} finally {
for (const id of ids) await manager.closeAgent(id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("failed startup history closes the session without registering an agent", async () => {
let closed = false;
class FailingHistorySession extends TestAgentSession {
readonly initialTimeline = [
{
item: { type: "notification", level: "info", message: "Runtime v2" } as const,
timestamp: "2026-09-22T00:00:00.000Z",
},
];
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: "Partial history" },
};
throw new Error("History unavailable");
}
override async close() {
closed = true;
await super.close();
}
}
const client = new (class extends TestAgentClient {
override async resumeSession() {
return new FailingHistorySession({ provider: "codex", cwd: tmpdir() });
}
})();
const manager = new AgentManager({ clients: { codex: client }, logger });
try {
await expect(
manager.resumeAgentFromPersistence({
provider: "codex",
sessionId: "failed-history",
metadata: { cwd: tmpdir() },
}),
).rejects.toThrow("History unavailable");
expect({ agents: manager.listAgents(), closed }).toEqual({ agents: [], closed: true });
} finally {
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
}
});
@@ -3488,31 +3488,11 @@ export class AgentManager {
options,
});
// Read history before publishing the agent: a provider failure must leave the
// session unregistered so the registration catch closes it.
const startupHistory: AgentStreamEvent[] = [];
if (session.initialTimeline?.length && !managed.historyPrimed) {
for await (const event of session.streamHistory()) {
startupHistory.push(limitAgentStreamEventContent(event));
}
}
this.assertAcceptingAgentRegistrations();
this.agents.set(resolvedAgentId, managed);
registered = true;
// Initialize previousStatus to track transitions
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
if (session.initialTimeline?.length) {
if (!managed.historyPrimed) {
// Legacy/imported chats need their existing history before startup rows.
await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory);
} else {
for (const entry of session.initialTimeline) {
this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp });
}
}
this.refreshSessionPersistence(managed);
}
await this.refreshRuntimeInfo(managed, { emit: false });
this.assertAgentRegistrationActive(managed);
await this.persistSnapshot(managed, {
@@ -4043,9 +4023,6 @@ export class AgentManager {
private async primeTimelineFromLegacyProviderHistory(
agent: ActiveManagedAgent,
broadcast: boolean | (() => boolean),
history:
| AsyncIterable<AgentStreamEvent>
| Iterable<AgentStreamEvent> = agent.session.streamHistory(),
): Promise<void> {
const deferredBroadcast = typeof broadcast === "function";
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
@@ -4055,7 +4032,7 @@ export class AgentManager {
// Collect the whole replay before touching either store. A stream that fails
// halfway then leaves the committed timeline as it was, instead of a partial
// copy the next attempt would append to.
for await (const rawEvent of history) {
for await (const rawEvent of agent.session.streamHistory()) {
const event = limitAgentStreamEventContent(rawEvent);
if (event.type === "provider_subagent") {
historySubagentEvents.push(event);
@@ -89,75 +89,6 @@ async function createMcpClient(url: string, authToken?: string): Promise<McpClie
return { callTool: boundCallTool, close: () => rawClient.close() };
}
interface OfflineMcpDaemon {
client: McpClient;
stop: () => Promise<void>;
}
async function startOfflineMcpDaemon(): Promise<OfflineMcpDaemon> {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const removeDirectories = async () => {
await rm(paseoHome, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
};
const port = await getAvailablePort();
const daemon = await createPaseoDaemon(
{
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
},
pino({ level: "silent" }),
).catch(async (error: unknown) => {
await removeDirectories();
throw error;
});
try {
await daemon.start();
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
return {
client,
stop: async () => {
await client.close();
await daemon.stop();
await removeDirectories();
},
};
} catch (error) {
await daemon.stop();
await removeDirectories();
throw error;
}
}
type WorkspaceCreation = { workspaceId: unknown } | { error: unknown };
async function createLocalWorkspace(client: McpClient, cwd: string): Promise<WorkspaceCreation> {
const result = await client.callTool({
name: "create_workspace",
args: { isolation: "local", path: cwd },
});
if (result.isError) {
const content = result.content?.[0];
return { error: content && "text" in content ? content.text : undefined };
}
return { workspaceId: getStructuredContent(result)?.workspaceId };
}
async function listWorkspaces(client: McpClient): Promise<WorkspaceCreation[]> {
const result = await client.callTool({ name: "list_workspaces", args: {} });
const workspaces = getStructuredContent(result)?.workspaces;
if (!Array.isArray(workspaces)) return [];
return workspaces.map((workspace: StructuredContent) => ({ workspaceId: workspace.workspaceId }));
}
interface LaunchRecorder {
recordedLaunches: AgentSessionConfig[];
}
@@ -304,27 +235,6 @@ describe("agent MCP end-to-end (offline)", () => {
}
}, 30_000);
test("create_workspace with local isolation adopts only an existing directory", async () => {
const daemon = await startOfflineMcpDaemon();
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-workspace-"));
const missingPath = path.join(root, "does-not-exist");
const filePath = path.join(root, "regular-file");
await writeFile(filePath, "not a directory\n", "utf8");
try {
expect(await createLocalWorkspace(daemon.client, missingPath)).toEqual({
error: expect.stringContaining(`Directory not found: ${missingPath}`),
});
expect(await createLocalWorkspace(daemon.client, filePath)).toEqual({
error: expect.stringContaining(`Directory not found: ${filePath}`),
});
const created = await createLocalWorkspace(daemon.client, root);
expect(await listWorkspaces(daemon.client)).toEqual([created]);
} finally {
await daemon.stop();
await rm(root, { recursive: true, force: true });
}
}, 30_000);
test("password-protected daemon authorizes the agent MCP via the capability token", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
@@ -425,11 +425,6 @@ interface NotifySafelyOptions {
permissionRequest?: AgentPermissionRequest;
}
// A caller waits on a child through one armed notification. Arming again, such as a
// follow-up prompt while the child still runs, replaces the earlier one so the child's
// next finish reaches the caller once.
const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>();
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
const {
agentManager,
@@ -445,19 +440,10 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
let unsubscribe: (() => void) | null = null;
let notificationQueue = Promise.resolve();
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
armedFinishNotifications.set(agentManager, armedByManager);
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
armedByManager.get(armedKey)?.();
armedByManager.set(armedKey, stop);
function stop(): void {
if (stopped) return;
stopped = true;
unsubscribe?.();
if (armedByManager.get(armedKey) === stop) {
armedByManager.delete(armedKey);
}
}
async function notify(
@@ -665,9 +665,6 @@ export interface AgentSession {
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[];
/** New provider-owned rows to commit on registration. streamHistory must also
* replay them at their original timestamps; restored sessions omit old rows. */
readonly initialTimeline?: ImportedTimelineEntry[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -1,9 +1,8 @@
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it, vi } from "vitest";
import { realpathSync } from "node:fs";
import { realpathSync, rmSync } from "node:fs";
import { access, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve as resolvePath } from "node:path";
import { tmpdir } from "node:os";
@@ -14,16 +13,7 @@ import { createAgentMcpServer } from "./mcp-server.js";
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import type {
AgentClient,
AgentMode,
AgentPromptInput,
AgentProvider,
AgentRunResult,
AgentSession,
AgentStreamEvent,
ProviderSnapshotEntry,
} from "./agent-sdk-types.js";
import type { AgentMode, AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
import { createProviderSnapshotManagerStub } from "../test-utils/session-stubs.js";
import {
@@ -192,16 +182,6 @@ async function removeTempDir(path: string): Promise<void> {
await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
async function removeAgentStateDir(
agentManager: AgentManager,
storage: AgentStorage,
path: string,
): Promise<void> {
await agentManager.flush();
await storage.flush();
await removeTempDir(path);
}
type AgentManagerSpies = ReturnType<typeof buildAgentManagerSpies>;
type AgentStorageSpies = ReturnType<typeof buildAgentStorageSpies>;
@@ -3430,7 +3410,7 @@ describe("create_agent MCP tool", () => {
expect(storedChild?.workspaceId).toBe("wks_parent");
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBe(parent.id);
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
rmSync(workdir, { recursive: true, force: true });
}
});
@@ -3684,142 +3664,6 @@ describe("create_agent MCP tool", () => {
});
});
const HELD_TURN_CAPABILITIES = {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsSessionListing: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
} as const;
/**
* Provider session that records every prompt it receives. With `holdTurns`, a started
* turn stays running until `finishTurn()` so a caller's bounded wait can run out first.
*/
class HeldTurnAgentSession implements AgentSession {
readonly capabilities = HELD_TURN_CAPABILITIES;
readonly id = randomUUID();
readonly prompts: string[] = [];
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private activeTurnId: string | null = null;
constructor(
readonly provider: AgentProvider,
private readonly holdTurns: boolean,
) {}
async run(): Promise<AgentRunResult> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
const turnId = randomUUID();
this.activeTurnId = turnId;
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
if (!this.holdTurns && this.activeTurnId === turnId) {
this.finishTurn();
}
}, 0);
return { turnId };
}
finishTurn(): void {
const turnId = this.activeTurnId;
if (!turnId) {
throw new Error("No held turn to finish");
}
this.activeTurnId = null;
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
private pushEvent(event: AgentStreamEvent): void {
for (const callback of this.subscribers) {
callback(event);
}
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return { provider: this.provider, sessionId: this.id, model: null, modeId: null };
}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return null;
}
async setMode(): Promise<void> {}
getPendingPermissions() {
return [];
}
async respondToPermission(): Promise<void> {}
describePersistence() {
return { provider: this.provider, sessionId: this.id };
}
async interrupt(): Promise<void> {
const turnId = this.activeTurnId;
if (!turnId) {
return;
}
this.activeTurnId = null;
this.pushEvent({
type: "turn_canceled",
provider: this.provider,
reason: "interrupted",
turnId,
});
}
async close(): Promise<void> {}
}
class HeldTurnAgentClient implements AgentClient {
readonly capabilities = HELD_TURN_CAPABILITIES;
readonly sessions: HeldTurnAgentSession[] = [];
constructor(
readonly provider: AgentProvider,
private readonly holdTurns: boolean,
) {}
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(): Promise<AgentSession> {
const session = new HeldTurnAgentSession(this.provider, this.holdTurns);
this.sessions.push(session);
return session;
}
async fetchCatalog() {
return { models: [], modes: [] };
}
async resumeSession(): Promise<AgentSession> {
return await this.createSession();
}
}
describe("send_agent_prompt MCP tool", () => {
const logger = createTestLogger();
const existingCwd = process.cwd();
@@ -3963,128 +3807,6 @@ describe("send_agent_prompt MCP tool", () => {
expect.objectContaining({ waitForActive: true }),
);
});
it("notifies the caller when a blocking agent-scoped prompt outlasts the wait", async () => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-blocking-send-timeout-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const parentClient = new HeldTurnAgentClient("claude", false);
const childClient = new HeldTurnAgentClient("codex", true);
const agentManager = new AgentManager({
clients: { claude: parentClient, codex: childClient },
registry: storage,
logger,
});
try {
const parent = await agentManager.createAgent(
{ provider: "claude", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const child = await agentManager.createAgent(
{ provider: "codex", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "send_agent_prompt");
vi.useFakeTimers();
const pending = invokeToolWithParsedInput(tool, {
agentId: child.id,
prompt: "Follow up",
background: false,
});
await vi.advanceTimersByTimeAsync(31_000);
const response = await pending;
expect(response.structuredContent).toMatchObject({
status: "running",
guidance:
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
});
expect(response.structuredContent.lastMessage).toContain("timed out after 30s");
childClient.sessions[0]!.finishTurn();
await vi.advanceTimersByTimeAsync(1_000);
vi.useRealTimers();
await vi.waitFor(() => {
const parentPrompts = parentClient.sessions[0]!.prompts;
expect(parentPrompts).toHaveLength(1);
expect(parentPrompts[0]).toContain(child.id);
expect(parentPrompts[0]).toContain("finished");
});
} finally {
vi.useRealTimers();
await removeAgentStateDir(agentManager, storage, workdir);
}
});
it("notifies the caller once when it prompts a created child that is still running", async () => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-running-child-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const parentClient = new HeldTurnAgentClient("claude", false);
const childClient = new HeldTurnAgentClient("codex", true);
const agentManager = new AgentManager({
clients: { claude: parentClient, codex: childClient },
registry: storage,
logger,
});
try {
const parent = await agentManager.createAgent(
{ provider: "claude", cwd: existingCwd },
undefined,
{ workspaceId: "wks_parent" },
);
const server = await createAgentMcpServer({
agentManager,
agentStorage: storage,
callerAgentId: parent.id,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const created = await invokeToolWithParsedInput(registeredTool(server, "create_agent"), {
relationship: { kind: "subagent" },
workspace: { kind: "current" },
title: "Busy Child",
provider: "codex/gpt-5.4",
initialPrompt: "Run a long command",
});
const childId = z.object({ agentId: z.string() }).parse(created.structuredContent).agentId;
await vi.waitFor(() => expect(agentManager.getAgent(childId)?.lifecycle).toBe("running"));
const sent = await invokeToolWithParsedInput(registeredTool(server, "send_agent_prompt"), {
agentId: childId,
prompt: "Stop and reply instead",
});
expect(sent.structuredContent).toMatchObject({
success: true,
status: "running",
});
const childSession = childClient.sessions[0]!;
await vi.waitFor(() => expect(childSession.prompts).toHaveLength(2));
childSession.finishTurn();
function finishNotifications() {
return (parentClient.sessions[0]?.prompts ?? []).filter((prompt) =>
prompt.includes(`Agent ${childId} (Busy Child) finished.`),
);
}
await vi.waitFor(() => expect(finishNotifications()).not.toHaveLength(0));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(finishNotifications()).toHaveLength(1);
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
});
});
describe("update_agent MCP tool", () => {
@@ -86,11 +86,6 @@ export function resolveRequiredProviderModel(
};
}
export interface AgentWaitWithTimeoutResult extends WaitForAgentResult {
/** The wait gave up while the agent was still working. */
timedOut: boolean;
}
/**
* Wraps agentManager.waitForAgentEvent with a self-imposed timeout.
* Returns a friendly message when timeout occurs, rather than letting
@@ -103,7 +98,7 @@ export async function waitForAgentWithTimeout(
signal?: AbortSignal;
waitForActive?: boolean;
},
): Promise<AgentWaitWithTimeoutResult> {
): Promise<WaitForAgentResult> {
const timeoutController = new AbortController();
const combinedController = new AbortController();
@@ -138,7 +133,7 @@ export async function waitForAgentWithTimeout(
signal: combinedController.signal,
waitForActive: options?.waitForActive,
});
return { ...result, timedOut: false };
return result;
} catch (error) {
if (error instanceof Error && error.message === "wait timeout") {
const snapshot = agentManager.getAgent(agentId);
@@ -155,7 +150,6 @@ export async function waitForAgentWithTimeout(
status: snapshot?.lifecycle ?? "idle",
permission: null,
lastMessage: message,
timedOut: true,
};
}
throw error;
@@ -4,10 +4,9 @@ import type {
ProviderInput,
ProviderRegistration,
} from "@getpaseo/plugin/server/provider";
import { setImmediate as nextTurn } from "node:timers/promises";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
import { PluginAgentClientRegistry } from "./plugin-provider.js";
import {
isStaleProviderSessionError,
@@ -26,7 +25,6 @@ interface ProviderHarnessOptions {
capabilities?: ProviderConnection["capabilities"];
completeTurn?: boolean;
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
function createProviderHarness(options: ProviderHarnessOptions = {}) {
@@ -45,7 +43,6 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
capabilities,
async send(input) {
inputs.push(input);
if (await options.handleInput?.(input, emit)) return;
if (input.type === "catalog") {
emit({
type: "catalog",
@@ -281,59 +278,6 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
}
describe("PluginAgentClientRegistry", () => {
test.each([false, true])(
"contains a failed session open while send is pending: %s",
async (pendingSend) => {
let listener: ((event: ProviderEvent) => void) | undefined;
const unhandled: unknown[] = [];
const observeUnhandled = (reason: unknown) => unhandled.push(reason);
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([
{
id: "failing-provider",
label: "Failing provider",
async connect() {
return {
version: 1,
capabilities: ["session.persistence"],
async send(input) {
if (input.type !== "session.open") return;
listener?.({
type: "request.failed",
requestId: input.requestId,
error: { message: "OMP persistent session registration is in progress" },
});
// Keep acceptance pending across a Node event-loop turn, as IPC can do.
if (pendingSend) await nextTurn();
},
onEvent(nextListener) {
listener = nextListener;
return () => {
listener = undefined;
};
},
async close() {},
};
},
},
]);
process.on("unhandledRejection", observeUnhandled);
try {
await expect(
registry.clients()["failing-provider"]!.createSession({
provider: "failing-provider",
cwd: "/workspace",
}),
).rejects.toThrow("OMP persistent session registration is in progress");
await nextTurn();
expect(unhandled).toEqual([]);
} finally {
await registry.shutdown();
process.off("unhandledRejection", observeUnhandled);
}
},
);
test("preserves nested provider child ownership during opening", async () => {
const harness = createProviderHarness({ openChildren: openNestedChildren });
const registry = new PluginAgentClientRegistry(createTestLogger());
@@ -596,167 +540,3 @@ describe("PluginAgentClientRegistry", () => {
expect(harness.inputs.map((input) => input.type)).toContain("session.close");
});
});
type RequestKind = "session.open" | "catalog" | "session.configure" | "session.prompt";
async function requestFromClient(client: AgentClient, kind: RequestKind): Promise<unknown> {
if (kind === "catalog") return client.fetchCatalog({ scope: "global" });
const session = await client.createSession({ provider: client.provider, cwd: "/workspace" });
if (kind === "session.open") return session;
if (kind === "session.configure") return session.setMode("build");
return session.startTurn("hello", { clientMessageId: "pending-message" });
}
async function withObservedProvider(
options: ProviderHarnessOptions,
run: (client: AgentClient, registry: PluginAgentClientRegistry) => Promise<void>,
): Promise<void> {
const harness = createProviderHarness(options);
const registry = new PluginAgentClientRegistry(createTestLogger());
const unhandled: unknown[] = [];
const observe = (reason: unknown) => unhandled.push(reason);
process.on("unhandledRejection", observe);
registry.replace([harness.registration]);
try {
await run(registry.clients()[harness.registration.id]!, registry);
await registry.shutdown();
await nextTurn();
expect(unhandled).toEqual([]);
} finally {
await registry.shutdown();
process.off("unhandledRejection", observe);
}
}
describe("pending provider responses", () => {
test.each<RequestKind>(["session.open", "catalog", "session.configure", "session.prompt"])(
"preserves the send error for %s",
async (kind) => {
const failure = new Error("Provider send failed");
await withObservedProvider(
{
async handleInput(input) {
if (input.type === kind) throw failure;
return false;
},
},
async (client) => {
await expect(requestFromClient(client, kind)).rejects.toBe(failure);
},
);
},
);
test.each([
[
"catalog",
expect.objectContaining({
models: [expect.objectContaining({ id: "plugin-model", isDefault: true })],
defaultModeId: "build",
}),
],
["session.configure", undefined],
] as const)(
"contains an early request.failed for %s and permits a successful retry",
async (kind, result) => {
let failed = false;
await withObservedProvider(
{
async handleInput(input, emit) {
if (input.type !== kind || !("requestId" in input) || failed) return false;
failed = true;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "Provider request failed", code: "busy", diagnostic: "retry" },
});
await nextTurn();
return true;
},
},
async (client) => {
await expect(requestFromClient(client, kind)).rejects.toMatchObject({
message: "Provider request failed",
code: "busy",
diagnostic: "retry",
});
await expect(requestFromClient(client, kind)).resolves.toEqual(result);
},
);
},
);
test.each([
["session.open", "Provider session closed"],
["session.configure", "Provider session closed"],
["session.prompt", StaleProviderSessionError],
] as const)("contains session closure while accepting %s", async (kind, error) => {
await withObservedProvider(
{
async handleInput(input, emit) {
if (input.type !== kind || !("sessionId" in input)) return false;
emit({
type: "session.closed",
sessionId: input.sessionId,
error: { message: "Provider session closed" },
});
await nextTurn();
return true;
},
},
async (client) => {
await expect(requestFromClient(client, kind)).rejects.toThrow(error);
},
);
});
test.each([
["session.open", "Provider connection closed"],
["catalog", "Provider closed"],
["session.configure", "Provider closed"],
["session.prompt", StaleProviderSessionError],
] as const)("contains connection shutdown while accepting %s", async (kind, error) => {
let disconnect!: () => Promise<void>;
await withObservedProvider(
{
async handleInput(input) {
if (input.type !== kind) return false;
await disconnect();
await nextTurn();
return true;
},
},
async (client, registry) => {
disconnect = () => registry.shutdown();
await expect(requestFromClient(client, kind)).rejects.toThrow(error);
},
);
});
test.each([true, false])(
"preserves cancellation with acceptance pending: %s",
async (pending) => {
const controller = new AbortController();
const reason = new Error("Catalog refresh cancelled");
await withObservedProvider(
{
async handleInput(input) {
if (input.type !== "catalog") return false;
if (pending) {
controller.abort(reason);
await nextTurn();
} else {
setImmediate(() => controller.abort(reason));
}
return true;
},
},
async (client) => {
await expect(
client.fetchCatalog({ scope: "global" }, { signal: controller.signal }),
).rejects.toBe(reason);
},
);
},
);
});
@@ -92,9 +92,6 @@ function deferred<Value>(): Deferred<Value> {
resolve = onResolve;
reject = onReject;
});
// Provider events can reject before send() settles and the caller awaits this promise.
// Observe that interval without replacing the rejecting promise returned to the caller.
void promise.catch(() => undefined);
return { promise, resolve, reject };
}
@@ -51,7 +51,6 @@ describe("provider-owned option schemas", () => {
ClaudeProviderOptionsSchema.parse({
allowedTools: ["Read"],
disallowedTools: ["Bash(rm *)"],
extraArgs: { chrome: null, model: "x" },
sandbox: {
enabled: true,
failIfUnavailable: true,
@@ -64,26 +63,7 @@ describe("provider-owned option schemas", () => {
},
settings: { permissions: { ask: ["Bash(*)"], deny: ["Edit(.env)"] } },
}),
).toMatchObject({
extraArgs: { chrome: null, model: "x" },
sandbox: { enabled: true, failIfUnavailable: true },
});
});
test.each([true, 42, ["x"]])("rejects invalid Claude extra argument values: %j", (value) => {
expect(() =>
validateProviderOptions("claude", ClaudeProviderOptionsSchema, {
extraArgs: { chrome: value },
}),
).toThrow("providerOptions.extraArgs.chrome");
});
test("rejects a raw argument list for the SDK argument map", () => {
expect(() =>
validateProviderOptions("claude", ClaudeProviderOptionsSchema, {
extraArgs: ["--chrome"],
}),
).toThrow("providerOptions.extraArgs");
).toMatchObject({ sandbox: { enabled: true, failIfUnavailable: true } });
});
test("reports the exact invalid Claude option path", () => {
@@ -41,7 +41,7 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { KimiACPAgentClient } from "./providers/kimi-acp-agent.js";
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
import { OpenCodeRuntimeClient } from "./providers/opencode/runtime-client.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import type { OpenCodeBridge } from "./providers/opencode/bridge.js";
import { OmpAgentClient } from "./providers/omp/agent.js";
import type { OmpRuntime } from "./providers/omp/runtime.js";
@@ -216,7 +216,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
env: runtimeSettings?.env,
}),
opencode: (logger, runtimeSettings, options) =>
new OpenCodeRuntimeClient(logger, runtimeSettings, {
new OpenCodeAgentClient(logger, runtimeSettings, {
managedProcesses: options?.managedProcesses,
bridge: options?.openCodeBridge,
}),
@@ -1,8 +1,5 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
AgentSideConnection,
@@ -3524,34 +3521,6 @@ describe("ACPAgentSession close() tree-kill", () => {
});
describe("ACPAgentSession initialization cleanup", () => {
test("rejects a resume whose working directory was deleted instead of crashing", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-acp-deleted-cwd-"));
const deletedCwd = path.join(root, "worktree");
const terminator = new FakeTerminator();
const session = new ACPAgentSession(
{ provider: "test-acp", cwd: deletedCwd },
{
provider: "test-acp",
logger: createTestLogger(),
defaultCommand: [process.execPath, "-e", "process.stdin.resume()"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
},
handle: { provider: "test-acp", sessionId: "archived-session" },
terminateProcess: terminator.terminate,
},
);
try {
await expect(session.initializeResumedSession()).rejects.toThrow("ENOENT");
expect(terminator.terminated).toHaveLength(1);
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("terminates the ACP process when session/new fails", async () => {
const terminator = new FakeTerminator();
const child = createProbeChildStub();
@@ -136,17 +136,6 @@ function assertChildWithPipes(
}
}
// Node reports a failed spawn (missing binary or missing cwd) as a child
// `error` event; without a listener it becomes an uncaught exception.
function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promise<never> {
return new Promise<never>((_, reject) => {
child.once("error", (error) => {
const stderr = stderrChunks.join("").trim();
reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error)));
});
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -1365,7 +1354,12 @@ export class ACPAgentClient implements AgentClient {
stderrChunks.push(chunk.toString());
});
const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks);
const spawnErrorPromise = new Promise<never>((_, reject) => {
child.once("error", (error) => {
const stderr = stderrChunks.join("").trim();
reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error)));
});
});
const spawnReadyPromise = new Promise<void>((resolve) => {
child.once("spawn", () => {
resolve();
@@ -2738,7 +2732,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
child.stderr.on("data", (chunk: Buffer | string) => {
stderrChunks.push(chunk.toString());
});
const spawnError = rejectOnSpawnError(child, stderrChunks);
child.once("exit", (code, signal) => {
if (this.closed) {
return;
@@ -2766,17 +2759,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.child = child;
this.connection = connection;
const initialize = await this.runACPRequest(() =>
Promise.race([
connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: buildACPClientCapabilities(
this.clientCapabilityMeta,
this.clientCapabilities,
),
clientInfo: { name: "Paseo", version: "dev" },
}),
spawnError,
]),
connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: buildACPClientCapabilities(
this.clientCapabilityMeta,
this.clientCapabilities,
),
clientInfo: { name: "Paseo", version: "dev" },
}),
);
return { child, connection, initialize };
@@ -428,7 +428,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
logger,
resolveBinary: async () => "/test/claude/bin",
resolveVersion: async () => "2.1.219",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
configDir: emptyConfigDir,
});
const { models } = await client.fetchCatalog({
scope: "workspace",
@@ -475,7 +475,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
resolveVersion: async () => {
throw new Error("unrecognized version output");
},
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
configDir: emptyConfigDir,
});
const { models } = await client.fetchCatalog({
scope: "workspace",
@@ -497,7 +497,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
logger,
resolveBinary: async () => "/test/claude/bin",
resolveVersion: async () => "2.1.219",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
configDir: emptyConfigDir,
});
const { models } = await client.fetchCatalog({
scope: "workspace",
@@ -785,29 +785,6 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("passes extra Claude Code CLI arguments to the SDK", async () => {
const { queryFactory, launches } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
providerOptions: {
extraArgs: { chrome: null, model: "x" },
},
});
await expect(session.startTurn("hello")).resolves.toEqual({
turnId: expect.stringMatching(/^foreground-turn-/),
});
expect(launches[0]?.options.extraArgs).toEqual({ chrome: null, model: "x" });
await session.close();
});
test("lists fast mode only for supported Opus models", async () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
@@ -2,6 +2,7 @@ import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { promises } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
type AgentDefinition,
@@ -82,7 +83,7 @@ import {
type ClaudeRewindSdk,
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
@@ -408,6 +409,7 @@ interface ClaudeAgentClientOptions {
queryFactory?: ClaudeQueryFactory;
resolveBinary?: () => Promise<string>;
resolveVersion?: (signal?: AbortSignal) => Promise<string>;
configDir?: string;
rewindSdk?: ClaudeRewindSdk;
}
@@ -1504,6 +1506,7 @@ export class ClaudeAgentClient implements AgentClient {
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
private readonly configDir?: string;
private readonly rewindSdk: ClaudeRewindSdk;
constructor(options: ClaudeAgentClientOptions) {
@@ -1515,6 +1518,7 @@ export class ClaudeAgentClient implements AgentClient {
this.resolveVersion =
options.resolveVersion ??
((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal));
this.configDir = options.configDir;
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
}
@@ -1588,11 +1592,12 @@ export class ClaudeAgentClient implements AgentClient {
} catch (error) {
this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog");
}
const env = this.buildProviderEnv();
const models = await runProviderRefreshActivity(context, "settings", () =>
getClaudeModelsWithSettings(this.logger, claudeConfigDir(env), claudeCodeVersion),
getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion),
);
const modeCatalog = claudeModeCatalog(
createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings }),
);
const modeCatalog = claudeModeCatalog(env);
return {
models,
...modeCatalog,
@@ -1600,15 +1605,12 @@ export class ClaudeAgentClient implements AgentClient {
}
async resolveDefaultModeId({ env: launchEnv }: ResolveAgentDefaultModeInput): Promise<string> {
return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId;
}
private buildProviderEnv(launchEnv?: Record<string, string>): NodeJS.ProcessEnv {
return createProviderEnv({
const env = createProviderEnv({
baseEnv: process.env,
runtimeSettings: this.runtimeSettings,
overlays: [launchEnv],
});
return claudeModeCatalog(env).defaultModeId;
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
@@ -1622,7 +1624,7 @@ export class ClaudeAgentClient implements AgentClient {
async listImportableSessions(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]> {
const configDir = claudeConfigDir(this.buildProviderEnv());
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const sessionsRoot = options?.cwd
? claudeProjectDirSync(options.cwd, { configDir })
: path.join(configDir, "projects");
@@ -5049,7 +5051,7 @@ class ClaudeAgentSession implements AgentSession {
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
const configDir = claudeConfigDir(this.buildSdkEnv());
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const candidates = [cwd];
try {
const realCwd = fs.realpathSync(cwd);
@@ -210,7 +210,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
env: {
ANTHROPIC_MODEL: "openrouter/anthropic/claude-sonnet-4.5",
ANTHROPIC_SMALL_FAST_MODEL: "ollama/qwen3-coder",
ANTHROPIC_DEFAULT_FABLE_MODEL: "claude-fable-5-1[1M]",
ANTHROPIC_DEFAULT_OPUS_MODEL: "bedrock-opus-from-env",
ANTHROPIC_DEFAULT_SONNET_MODEL: "glm-5.1",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5",
@@ -245,12 +244,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
label: "ollama/qwen3-coder",
description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL",
},
{
provider: "claude",
id: "claude-fable-5-1[1M]",
label: "claude-fable-5-1[1M]",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_FABLE_MODEL",
},
{
provider: "claude",
id: "bedrock-opus-from-env",
@@ -270,27 +263,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL",
},
]);
expect(models.filter((model) => model.id === "claude-fable-5-1[1M]")).toHaveLength(1);
expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(true);
});
it("reads settings.json from the provider's own CLAUDE_CONFIG_DIR", async () => {
const daemonConfigDir = await createClaudeConfigDir({});
const providerConfigDir = await createClaudeConfigDir({ model: "glm-5.1" });
vi.stubEnv("CLAUDE_CONFIG_DIR", daemonConfigDir);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.280",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } },
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: os.tmpdir(),
force: true,
});
expect(models.map((model) => model.id)).toContain("glm-5.1");
});
it("falls back to hardcoded models when settings.json is missing", async () => {
@@ -1,4 +1,5 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { Logger } from "pino";
@@ -13,7 +14,6 @@ import {
const CLAUDE_SETTINGS_MODEL_ENV_KEYS = [
"ANTHROPIC_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"ANTHROPIC_DEFAULT_FABLE_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
@@ -50,7 +50,7 @@ export function findClaudeModel(
export async function getClaudeModelsWithSettings(
logger: Logger,
configDir: string,
configDir?: string,
claudeCodeVersion?: string,
): Promise<AgentModelDefinition[]> {
const hardcodedModels = getClaudeModels(claudeCodeVersion);
@@ -78,9 +78,9 @@ export async function getClaudeModelsWithSettings(
async function readClaudeSettingsModels(
logger: Logger,
configDir: string,
configDir?: string,
): Promise<AgentModelDefinition[]> {
const settingsPath = path.join(configDir, "settings.json");
const settingsPath = path.join(resolveClaudeConfigDir(configDir), "settings.json");
let parsed: unknown;
try {
@@ -115,6 +115,10 @@ async function readClaudeSettingsModels(
return models;
}
function resolveClaudeConfigDir(configDir?: string): string {
return configDir ?? process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
}
function addSettingsModel(
models: AgentModelDefinition[],
value: unknown,
@@ -48,7 +48,6 @@ export const ClaudeProviderOptionsSchema = z
allowedTools: z.array(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(),
additionalDirectories: z.array(z.string()).optional(),
extraArgs: z.record(z.string(), z.string().nullable()).optional(),
sandbox: z
.object({
enabled: z.boolean().optional(),
@@ -66,11 +66,6 @@ function hashSuffix(input: string): string {
return Math.abs(hash).toString(36);
}
// Claude Code keeps its settings and transcripts here for a process launched with `env`.
export function claudeConfigDir(env: NodeJS.ProcessEnv): string {
return env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
}
function resolveConfigDir(options?: ClaudeProjectDirOptions): string {
return options?.configDir ?? claudeConfigDir(process.env);
return options?.configDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
}
@@ -171,16 +171,8 @@ function createSession(
return session;
}
function createProviderWithFakeAppServer(
appServer: FakeCodexAppServer,
options?: {
runtimeSettings?: ConstructorParameters<typeof CodexAppServerAgentClient>[1];
customProvider?: { id: string; label: string; extends: string };
},
): CodexAppServerAgentClient {
const provider = new CodexAppServerAgentClient(createTestLogger(), options?.runtimeSettings, {
customProvider: options?.customProvider,
});
function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAppServerAgentClient {
const provider = new CodexAppServerAgentClient(createTestLogger());
const internals = castInternals<{
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
@@ -242,54 +234,6 @@ describe("Codex active-turn steering admission", () => {
appServer.assertNoErrors();
});
test("a steer resolves a Codex skill mentioned after prose into app-server skill input", async () => {
const appServer = createFakeCodexAppServer({
"skills/list": () => ({
data: [
{
cwd: "/workspace/project",
skills: [
{
name: "paseo-implement",
description: "Execute an existing Paseo plan.",
path: "/workspace/skills/paseo-implement/SKILL.md",
},
],
errors: [],
},
],
}),
"turn/steer": () => ({ turn: { id: "native-A" } }),
});
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
await expect(
session.steerActiveTurn!("Then use /paseo-implement to complete it.", {
expectedTurnId: paseoTurnId,
}),
).resolves.toEqual({ status: "accepted" });
const steer = appServer.requests().find((request) => request.method === "turn/steer");
expect(steer?.params).toEqual(
expect.objectContaining({
input: [
{
type: "skill",
name: "paseo-implement",
path: "/workspace/skills/paseo-implement/SKILL.md",
},
{
type: "text",
text: "Then use $paseo-implement to complete it.",
text_elements: [],
},
],
}),
);
await session.close();
appServer.assertNoErrors();
});
test("a clearing steer denies every pending permission through its provider handler", async () => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
@@ -2281,59 +2225,6 @@ describe("Codex app-server provider", () => {
);
});
test("resolves a Codex skill mentioned after prose into app-server skill input", async () => {
const session = createSession();
const request = vi.fn(async (method: string) => {
if (method === "skills/list") {
return {
data: [
{
cwd: "/tmp/codex-question-test",
skills: [
{
name: "paseo-implement",
description: "Execute an existing Paseo plan.",
path: "/tmp/skills/paseo-implement/SKILL.md",
},
],
errors: [],
},
],
};
}
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "turn/start") {
return {};
}
throw new Error(`Unexpected request: ${method}`);
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({ request });
await session.startTurn("Review the accepted plan, then use /paseo-implement to complete it.");
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
expect(turnStartCall?.[1]).toEqual(
expect.objectContaining({
input: [
{
type: "skill",
name: "paseo-implement",
path: "/tmp/skills/paseo-implement/SKILL.md",
},
{
type: "text",
text: "Review the accepted plan, then use $paseo-implement to complete it.",
text_elements: [],
},
],
}),
);
});
test("lists project skill commands when app-server receives the project cwd in cwds", async () => {
const commands = await listCommandsFromFakeCodex([
{
@@ -6321,55 +6212,6 @@ describe("Codex importable sessions", () => {
appServer.assertNoErrors();
});
// Codex filters thread/list by model provider: with no `modelProviders` it
// returns only threads of the provider its own config selects, an empty
// list returns every provider, and a list returns only those providers.
function providerFilteringThreadListHandler(threads: Array<Record<string, unknown>>) {
return (input: unknown) => {
const { modelProviders } = (input ?? {}) as { modelProviders?: string[] };
const included = (thread: Record<string, unknown>) => {
if (modelProviders === undefined) return thread.modelProvider === "openai";
if (modelProviders.length === 0) return true;
return modelProviders.includes(String(thread.modelProvider));
};
return { data: threads.filter(included), nextCursor: null };
};
}
const threadsByModelProvider = [
{ id: "stock-thread", cwd: "/workspace/project-a", modelProvider: "openai", updatedAt: 2 },
{ id: "custom-thread", cwd: "/workspace/project-a", modelProvider: "my-codex", updatedAt: 1 },
];
test("a custom Codex provider lists the threads it created", async () => {
const appServer = createFakeCodexAppServer({
"thread/list": providerFilteringThreadListHandler(threadsByModelProvider),
});
const provider = createProviderWithFakeAppServer(appServer, {
runtimeSettings: {
env: { OPENAI_BASE_URL: "https://llm.example.test/v1", OPENAI_API_KEY: "test-key" },
},
customProvider: { id: "my-codex", label: "My Codex", extends: "codex" },
});
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]);
appServer.assertNoErrors();
});
test("stock Codex keeps listing only its own threads", async () => {
const appServer = createFakeCodexAppServer({
"thread/list": providerFilteringThreadListHandler(threadsByModelProvider),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" });
expect(sessions.map((session) => session.providerHandleId)).toEqual(["stock-thread"]);
appServer.assertNoErrors();
});
test("listImportableSessions uses thread list metadata without hydrating thread history", async () => {
const allThreads = [
{
@@ -259,7 +259,7 @@ interface CodexAppServerAgentDeps {
label: string;
extends: string;
};
customCodexConfig?: CodexCustomProviderConfig | null;
customCodexConfig?: Record<string, unknown> | null;
_createCodexClient?: (
child: ChildProcessWithoutNullStreams,
logger: Logger,
@@ -919,7 +919,7 @@ const CodexModelListResponseSchema = z.object({
async function readCodexThreadWindow(
client: Pick<CodexAppServerClientLike, "request">,
logger: Logger,
window: { limit: number; cwd?: string; modelProviders?: string[] },
window: { limit: number; cwd?: string },
): Promise<Array<Record<string, unknown>>> {
// A thread updated while the scan is paging moves under `updated_at` order
// and can come back on a later page, so identify each one and keep it once.
@@ -935,7 +935,6 @@ async function readCodexThreadWindow(
// Older Codex builds ignore the unknown key and keep that order.
sortKey: "updated_at",
...(window.cwd ? { cwd: window.cwd } : {}),
...(window.modelProviders ? { modelProviders: window.modelProviders } : {}),
...(cursor ? { cursor } : {}),
}),
);
@@ -3169,26 +3168,6 @@ function enabledCodexSkills(
return Array.from(skillsByName.values());
}
// Composer autocomplete inserts skills as `/name` anywhere in the prompt, while
// Codex only injects a skill it receives as a skill input item.
const SKILL_MENTION_PATTERN = /(^|\s)\/([^\s/"']+)/g;
function resolveCodexSkillMentions(
text: string,
skills: ReadonlyArray<{ name: string; path: string }>,
): { skills: CodexSkillPromptBlock[]; text: string } {
const mentioned = new Map<string, CodexSkillPromptBlock>();
const rewritten = text.replace(SKILL_MENTION_PATTERN, (mention, prefix: string, name: string) => {
const skill = skills.find((entry) => entry.name === name);
if (!skill) {
return mention;
}
mentioned.set(skill.name, { type: "skill", name: skill.name, path: skill.path });
return `${prefix}$${skill.name}`;
});
return { skills: Array.from(mentioned.values()), text: rewritten };
}
type CodexPromptContentBlock = AgentPromptContentBlock | CodexSkillPromptBlock;
type CodexPromptInput = string | CodexPromptContentBlock[];
interface CodexTextElement {
@@ -3299,15 +3278,10 @@ function normalizeOpenAICompatibleBaseUrl(value: string): string | null {
return `${withoutTrailingSlashes}/v1`;
}
interface CodexCustomProviderConfig {
model_provider: string;
model_providers: Record<string, Record<string, unknown>>;
}
function buildCodexCustomProviderConfig(
runtimeSettings: ProviderRuntimeSettings | undefined,
customProvider: CodexAppServerAgentDeps["customProvider"],
): CodexCustomProviderConfig | null {
): Record<string, unknown> | null {
if (customProvider?.extends !== CODEX_PROVIDER) {
return null;
}
@@ -4083,18 +4057,6 @@ export class CodexAppServerAgentSession implements AgentSession {
return args ? `$${commandName} ${args}` : `$${commandName}`;
}
private async attachMentionedSkills(prompt: AgentPromptInput): Promise<CodexPromptInput> {
if (typeof prompt !== "string" || !prompt.includes("/")) {
return prompt;
}
await this.loadSkills();
const resolved = resolveCodexSkillMentions(prompt, this.cachedSkills ?? []);
if (resolved.skills.length === 0) {
return prompt;
}
return [...resolved.skills, { type: "text", text: resolved.text }];
}
private async buildTurnStartParams(
prompt: CodexPromptInput,
options?: AgentRunOptions,
@@ -4298,7 +4260,7 @@ export class CodexAppServerAgentSession implements AgentSession {
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
const effectivePrompt = slashCommand
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
: await this.attachMentionedSkills(prompt);
: prompt;
if (this.currentThreadId) {
await this.ensureThreadLoaded();
@@ -4365,7 +4327,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
return { status: "unavailable" };
}
const input = await this.buildUserInput(await this.attachMentionedSkills(prompt));
const input = await this.buildUserInput(prompt);
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
return { status: "unavailable" };
}
@@ -7059,14 +7021,13 @@ export class CodexAppServerAgentClient implements AgentClient {
private sessionDeps(): CodexAppServerAgentDeps {
return {
...this.deps,
customCodexConfig: this.customProviderConfig(),
customCodexConfig: buildCodexCustomProviderConfig(
this.runtimeSettings,
this.deps.customProvider,
),
};
}
private customProviderConfig(): CodexCustomProviderConfig | null {
return buildCodexCustomProviderConfig(this.runtimeSettings, this.deps.customProvider);
}
private resolveGoalsEnabled(): Promise<boolean> {
if (!this.goalsEnabledPromise) {
this.goalsEnabledPromise = (async () => {
@@ -7230,14 +7191,9 @@ export class CodexAppServerAgentClient implements AgentClient {
// filtering since most threads will be from other cwds, then keep the
// local realpath-aware filter for symlink-equivalent workspace paths.
const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit;
// Codex records each thread under the model provider that ran it and,
// unless told otherwise, lists only the provider its own config selects.
// A custom provider runs under its own id, so ask for exactly that one.
const customProviderConfig = this.customProviderConfig();
const allThreads = await readCodexThreadWindow(client, this.logger, {
limit: listLimit,
cwd: options?.cwd,
...(customProviderConfig ? { modelProviders: [customProviderConfig.model_provider] } : {}),
});
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
return threads.slice(0, limit).map((thread) => {
@@ -1,139 +0,0 @@
import { mkdtempSync, rmSync } from "node:fs";
import { once } from "node:events";
import { createServer, type Server } from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
// Runs the real `codex` binary against a local Responses endpoint standing in
// for a custom provider's OPENAI_BASE_URL, so no login is needed.
function sse(events: Array<Record<string, unknown>>): string {
return events
.map((event) => `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`)
.join("");
}
function finalAnswer(): string {
return sse([
{ type: "response.created", response: { id: "resp-1" } },
{
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
id: "msg-1",
content: [{ type: "output_text", text: "done" }],
},
},
{
type: "response.completed",
response: {
id: "resp-1",
usage: {
input_tokens: 0,
input_tokens_details: null,
output_tokens: 0,
output_tokens_details: null,
total_tokens: 0,
},
},
},
]);
}
async function startResponsesEndpoint(): Promise<{
baseUrl: string;
server: Server;
requestCount: () => number;
}> {
let requests = 0;
const server = createServer((req, res) => {
req.resume();
req.on("end", () => {
if (req.method !== "POST" || req.url !== "/v1/responses") {
res.statusCode = 404;
res.end();
return;
}
requests += 1;
res.setHeader("content-type", "text/event-stream");
res.end(finalAnswer());
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("no server address");
return {
baseUrl: `http://127.0.0.1:${address.port}/v1`,
server,
requestCount: () => requests,
};
}
async function closeServer(server: Server): Promise<void> {
server.close();
await once(server, "close");
}
describe("Codex custom provider import list", () => {
const cleanups: Array<() => void | Promise<void>> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
});
test("lists a custom provider's session under that provider and imports it there", async () => {
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-"));
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-"));
cleanups.push(() => rmSync(codexHome, { recursive: true, force: true }));
cleanups.push(() => rmSync(cwd, { recursive: true, force: true }));
const { baseUrl, server, requestCount } = await startResponsesEndpoint();
cleanups.push(() => closeServer(server));
const logger = createTestLogger();
const customCodex = new CodexAppServerAgentClient(
logger,
{ env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } },
{ customProvider: { id: "my-codex", label: "My Codex", extends: "codex" } },
);
const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } });
const session = await customCodex.createSession({
provider: "codex",
cwd,
model: "mock-model",
});
try {
await session.run("hello from the custom provider");
} finally {
await session.close();
}
const customSessions = await customCodex.listImportableSessions({ cwd });
const stockSessions = await stockCodex.listImportableSessions({ cwd });
expect(customSessions.map((entry) => entry.firstPromptPreview)).toEqual([
"hello from the custom provider",
]);
expect(stockSessions).toEqual([]);
const config = { provider: "codex", cwd, model: "mock-model" };
const imported = await customCodex.importSession(
{ providerHandleId: customSessions[0].providerHandleId, cwd },
{ config, storedConfig: config },
);
const requestsBeforeImportedTurn = requestCount();
try {
await imported.session.run("hello again after import");
} finally {
await imported.session.close();
}
expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn);
}, 120_000);
});
@@ -1,108 +0,0 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
describe("GenericACPAgentClient slash commands", () => {
test("lists commands an agent advertises right after session/new", async () => {
await withFakeACPAgent("commands-after-session-new", async (command, cwd) => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command,
// Long enough that the update always lands inside the wait, however slowly
// the fake agent is scheduled.
initialCommandsWaitTimeoutMs: 60_000,
});
const session = await client.createSession({ provider: "acp", cwd });
try {
await expect(session.listCommands?.()).resolves.toEqual([
{ name: "review", description: "Review the diff", argumentHint: "", kind: "command" },
]);
} finally {
await session.close();
}
});
});
test("answers with no commands for an agent that never advertises any", async () => {
await withFakeACPAgent("silent", async (command, cwd) => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command,
initialCommandsWaitTimeoutMs: 50,
});
const session = await client.createSession({ provider: "acp", cwd });
try {
await expect(session.listCommands?.()).resolves.toEqual([]);
} finally {
await session.close();
}
});
});
});
async function withFakeACPAgent(
mode: "commands-after-session-new" | "silent",
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-commands-"));
try {
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
await run([process.execPath, scriptPath, mode], testDir);
} finally {
await rm(testDir, { recursive: true, force: true });
}
}
const fakeACPAgentScript = `
const readline = require("node:readline");
const mode = process.argv[2];
const rl = readline.createInterface({ input: process.stdin });
function write(message) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
}
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") {
write({
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion ?? 1,
agentCapabilities: { sessionCapabilities: { close: {} } },
},
});
return;
}
if (message.method === "session/new") {
write({ id: message.id, result: { sessionId: "session-1" } });
if (mode === "commands-after-session-new") {
setTimeout(() => {
write({
method: "session/update",
params: {
sessionId: "session-1",
update: {
sessionUpdate: "available_commands_update",
availableCommands: [{ name: "review", description: "Review the diff" }],
},
},
});
}, 0);
}
return;
}
if (message.id !== undefined) {
write({ id: message.id, result: {} });
}
});
`;
@@ -62,7 +62,6 @@ describe("GenericACPAgentClient", () => {
supportsRewindFiles: false,
supportsRewindBoth: false,
},
waitForInitialCommands: true,
},
]);
});
@@ -70,9 +70,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
},
defaultCommand: options.command,
capabilities: buildGenericACPCapabilities(providerParams),
// ACP agents advertise slash commands with available_commands_update after
// session/new, so the first listCommands() waits for that batch.
waitForInitialCommands: options.waitForInitialCommands ?? true,
waitForInitialCommands: options.waitForInitialCommands,
initialCommandsWaitTimeoutMs: options.initialCommandsWaitTimeoutMs,
clientCapabilities: providerParams.clientCapabilities,
clientCapabilityMeta: options.clientCapabilityMeta,
@@ -4,7 +4,11 @@ import { PassThrough } from "node:stream";
import pino from "pino";
import { describe, expect, test } from "vitest";
import { JsonlRpcProcess, type JsonlRpcExit } from "./jsonl-rpc-process.js";
import {
JsonlRpcProcess,
JsonlRpcRequestRejectedError,
type JsonlRpcExit,
} from "./jsonl-rpc-process.js";
const CHILD_SOURCE = String.raw`
const readline = require("node:readline");
@@ -154,13 +158,13 @@ describe("JsonlRpcProcess", () => {
}
});
test("rejects unsuccessful responses", async () => {
test("rejects unsuccessful responses as rejected requests", async () => {
const transport = startProcess();
try {
await expect(transport.request({ type: "fail" })).rejects.toThrow(
"child rejected the request",
);
const request = transport.request({ type: "fail" });
await expect(request).rejects.toThrow("child rejected the request");
await expect(request).rejects.toBeInstanceOf(JsonlRpcRequestRejectedError);
} finally {
await transport.close();
}
@@ -173,9 +177,12 @@ describe("JsonlRpcProcess", () => {
try {
child.stderr.write("still waiting");
await expect(transport.request({ type: "hang" })).rejects.toThrow(
const request = transport.request({ type: "hang" });
await expect(request).rejects.toThrow(
/JSONL RPC request timed out phase=hang elapsedMs=\d+ timeoutMs=50\nstill waiting/,
);
// A timed-out request may still be carried out, so it is not a rejection.
await expect(request).rejects.not.toBeInstanceOf(JsonlRpcRequestRejectedError);
} finally {
await transport.close();
}
@@ -40,6 +40,9 @@ interface PendingRequest {
timer: NodeJS.Timeout | null;
}
/** The process answered the request with a failure, so it did not carry it out. */
export class JsonlRpcRequestRejectedError extends Error {}
export interface JsonlRpcExit {
code: number | null;
signal: NodeJS.Signals | null;
@@ -283,7 +286,7 @@ export class JsonlRpcProcess {
this.pending.delete(response.id);
if (!response.success) {
pending.reject(
new Error(
new JsonlRpcRequestRejectedError(
response.error ?? `${this.diagnosticName} ${response.command ?? "request"} failed`,
),
);
@@ -7,8 +7,8 @@ import type { OmpAgentMessage } from "./rpc-types.js";
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
import type { OmpUsagePollScheduler } from "./usage-poller.js";
import { resolveOmpProviderParams } from "./provider-config.js";
import { OmpRuntimeEventSchema } from "./rpc-types.js";
import { OmpHarness } from "./test-utils/omp-harness.js";
import { JsonlRpcRequestRejectedError } from "../jsonl-rpc-process.js";
const TURN_LIFECYCLE_EVENTS = new Set<AgentStreamEvent["type"]>([
"turn_started",
@@ -43,6 +43,7 @@ test("OMP ready timeout defaults to 20 seconds and RPC timeout overrides both",
rpcTimeoutMs: 90_000,
});
});
class ManualIdleScheduler implements OmpProviderIdleScheduler {
private readonly retries: Array<() => void> = [];
private readonly waiters: Array<{ count: number; resolve: () => void }> = [];
@@ -376,25 +377,6 @@ describe("OMP agent client and session", () => {
expect(omp.completedTurnCount()).toBe(1);
});
test("does not complete a turn when a custom message arrives before its user message", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurn("hello OMP");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptCustomMessage("startup notice");
expect(omp.completedTurnCount()).toBe(0);
runtime.acceptPrompt("hello OMP", "user-1");
runtime.streamAssistantText("model turn completed");
runtime.finishTurn();
await waitForImmediate();
expect(omp.completedTurnCount()).toBe(1);
});
test("omits live custom messages when display is false", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -582,106 +564,6 @@ describe("OMP agent client and session", () => {
]);
});
test("maps legacy select options without descriptions and preserves ordinary responses", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "extension_ui_request",
id: "select-legacy",
method: "select",
title: "Choose",
options: ["Approve", "Deny"],
});
expect(omp.pendingPermissions()[0]?.input).toMatchObject({
questions: [{ options: [{ label: "Approve" }, { label: "Deny" }] }],
});
await omp.respondToPermission("select-legacy", {
behavior: "allow",
updatedInput: { answers: { Response: "Deny" } },
});
expect(omp.extensionUiResponses()).toContainEqual({
id: "select-legacy",
response: { value: "Deny" },
});
});
test("maps described and mixed select metadata by option index", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "extension_ui_request",
id: "select-described",
method: "select",
title: "Choose",
options: ["First", "Second", "Third"],
optionDetails: [{ description: "First detail" }, {}, { description: " \t" }],
});
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toStrictEqual([
{ label: "First", description: "First detail" },
{ label: "Second" },
{ label: "Third" },
]);
});
test("accepts malformed or misaligned optional metadata and falls back to labels", async () => {
const omp = new OmpHarness();
await omp.start();
const parsed = OmpRuntimeEventSchema.safeParse({
type: "extension_ui_request",
id: "select-malformed",
method: "select",
options: ["First", "Second"],
optionDetails: [{ description: 42 }, { description: "\n\t" }, { description: "extra" }],
});
expect(parsed.success).toBe(true);
if (!parsed.success) throw new Error("Expected malformed metadata event to parse");
omp.emit(parsed.data);
expect(omp.pendingPermissions()[0]?.input?.questions?.[0]?.options).toStrictEqual([
{ label: "First" },
{ label: "Second" },
]);
});
test("preserves combined selection descriptions and freeform sentinel behavior", async () => {
const omp = new OmpHarness();
await omp.start();
omp.emit({
type: "tool_execution_start",
toolCallId: "ask-user-1",
toolName: "ask_user",
args: { allowComment: true, allowFreeform: true, allowMultiple: false },
});
omp.emit({
type: "extension_ui_request",
id: "select-combined",
method: "select",
title: "Choose",
options: ["First", "✏️ Type custom response..."],
optionDetails: [{ description: "First detail" }, { description: "ignored" }],
});
const combinedInput = omp.pendingPermissions()[0]?.input;
expect(combinedInput?.questions?.[0]?.options).toStrictEqual([
{ label: "First", description: "First detail" },
]);
expect(combinedInput).toMatchObject({
questions: [{ allowOther: true }, { header: "Comment" }],
});
await omp.respondToPermission("select-combined", {
behavior: "allow",
updatedInput: { answers: { Response: "custom", Comment: "note" } },
});
expect(omp.extensionUiResponses()).toContainEqual({
id: "select-combined",
response: { value: "✏️ Type custom response..." },
});
});
test("exposes OMP modes and commands through the domain session", async () => {
const omp = new OmpHarness();
omp.queueCommands([{ name: "review", description: "Review changes", source: "skill" }]);
@@ -859,6 +741,221 @@ describe("OMP agent client and session", () => {
]);
});
test("a replacement prompt keeps its client correlation when OMP ends the aborted turn late", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurnFromClient("first prompt", "client-a");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptPrompt("first prompt", "omp-user-a");
runtime.streamAssistantText("working on it", "omp-assistant-a");
await omp.waitForUserMessages(1);
// OMP takes no steer, so Paseo interrupts and replaces the foreground turn.
await omp.interrupt();
await omp.requireStartTurnFromClient("second prompt", "client-b");
runtime.beginTurn();
// OMP flushes the aborted turn's terminal event after the replacement started.
runtime.emit({
type: "agent_end",
messages: [{ role: "assistant", content: [], responseId: "omp-assistant-a" }],
});
await omp.waitForEvent("turn_completed");
runtime.acceptPrompt("second prompt", "omp-user-b");
runtime.streamAssistantText("second answer", "omp-assistant-b");
runtime.finishTurn();
await omp.waitForUserMessages(2);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{
type: "user_message",
text: "first prompt",
messageId: "omp-user-a",
clientMessageId: "client-a",
},
{
type: "user_message",
text: "second prompt",
messageId: "omp-user-b",
clientMessageId: "client-b",
},
]);
});
test("a prompt echoed after a hidden custom message completes the turn keeps its client correlation", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurnFromClient("do the thing", "client-a");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.emit({
type: "message_end",
message: { role: "custom", content: "xd://inventory", display: false },
});
await omp.waitForEvent("turn_completed");
runtime.acceptPrompt("do the thing", "omp-user-a");
runtime.streamAssistantText("done", "omp-assistant-a");
runtime.finishTurn();
await omp.waitForUserMessages(1);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{
type: "user_message",
text: "do the thing",
messageId: "omp-user-a",
clientMessageId: "client-a",
},
]);
});
test("a re-emitted echo does not consume a later submission's client correlation", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurnFromClient("again", "client-a");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptPrompt("again", "omp-user-a");
runtime.streamAssistantText("first answer", "omp-assistant-a");
runtime.finishTurn();
await omp.waitForUserMessages(1);
await omp.requireStartTurnFromClient("again", "client-b");
runtime.beginTurn();
// OMP re-sends the first turn's entry before echoing the new submission.
runtime.acceptPrompt("again", "omp-user-a");
runtime.acceptPrompt("again", "omp-user-b");
runtime.streamAssistantText("second answer", "omp-assistant-b");
runtime.finishTurn();
await omp.waitForUserMessages(2);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{ type: "user_message", text: "again", messageId: "omp-user-a", clientMessageId: "client-a" },
{ type: "user_message", text: "again", messageId: "omp-user-b", clientMessageId: "client-b" },
]);
});
test("a prompt OMP echoes after its dispatch failed keeps its client correlation", async () => {
const omp = new OmpHarness();
await omp.start();
const runtime = omp.runtime();
// The prompt reached OMP, but Paseo's request failed before it was answered.
runtime.promptError = new Error("OMP prompt request timed out");
await omp.requireStartTurnFromClient("slow prompt", "client-a");
await omp.waitForEvent("turn_failed");
runtime.promptError = null;
// OMP accepted it anyway and echoes it once it gets to the turn.
runtime.beginTurn();
runtime.acceptPrompt("slow prompt", "omp-user-a");
runtime.streamAssistantText("late answer", "omp-assistant-a");
runtime.finishTurn();
await omp.waitForUserMessages(1);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{
type: "user_message",
text: "slow prompt",
messageId: "omp-user-a",
clientMessageId: "client-a",
},
]);
});
test("a timed-out prompt OMP echoes late keeps its own correlation when the same text is resubmitted", async () => {
const omp = new OmpHarness();
await omp.start();
const runtime = omp.runtime();
// OMP recorded the prompt, but Paseo's request timed out before it was answered.
runtime.promptError = new Error("OMP prompt request timed out");
await omp.requireStartTurnFromClient("retry me", "client-a");
await omp.waitForEvent("turn_failed");
runtime.promptError = null;
await omp.requireStartTurnFromClient("retry me", "client-b");
// OMP echoes both prompts in the order it received them.
runtime.beginTurn();
runtime.acceptPrompt("retry me", "omp-user-a");
runtime.acceptPrompt("retry me", "omp-user-b");
runtime.streamAssistantText("done", "omp-assistant-b");
runtime.finishTurn();
await omp.waitForUserMessages(2);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{
type: "user_message",
text: "retry me",
messageId: "omp-user-a",
clientMessageId: "client-a",
},
{
type: "user_message",
text: "retry me",
messageId: "omp-user-b",
clientMessageId: "client-b",
},
]);
});
test("a prompt OMP rejects leaves no correlation for a later submission", async () => {
const omp = new OmpHarness();
await omp.start();
const runtime = omp.runtime();
runtime.promptError = new JsonlRpcRequestRejectedError("OMP refused the prompt");
await omp.requireStartTurnFromClient("retry me", "client-a");
await omp.waitForEvent("turn_failed");
runtime.promptError = null;
await omp.requireStartTurnFromClient("retry me", "client-b");
runtime.beginTurn();
runtime.acceptPrompt("retry me", "omp-user-b");
runtime.streamAssistantText("done", "omp-assistant-b");
runtime.finishTurn();
await omp.waitForUserMessages(1);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{
type: "user_message",
text: "retry me",
messageId: "omp-user-b",
clientMessageId: "client-b",
},
]);
});
test("the same prompt text on two turns correlates to its own submission", async () => {
const omp = new OmpHarness();
await omp.start();
await omp.requireStartTurnFromClient("again", "client-a");
const runtime = omp.runtime();
runtime.beginTurn();
runtime.acceptPrompt("again", "omp-user-a");
runtime.streamAssistantText("first answer", "omp-assistant-a");
runtime.finishTurn();
await omp.waitForUserMessages(1);
await omp.requireStartTurnFromClient("again", "client-b");
runtime.beginTurn();
runtime.acceptPrompt("again", "omp-user-b");
runtime.streamAssistantText("second answer", "omp-assistant-b");
runtime.finishTurn();
await omp.waitForUserMessages(2);
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
{ type: "user_message", text: "again", messageId: "omp-user-a", clientMessageId: "client-a" },
{ type: "user_message", text: "again", messageId: "omp-user-b", clientMessageId: "client-b" },
]);
});
test("re-emitted user message_end frames dedupe by native entry id", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -72,6 +72,7 @@ import { getUserMessageText } from "./message-history.js";
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
import { materializeProviderImage } from "../provider-image-output.js";
import { OmpCliRuntime } from "./cli-runtime.js";
import { JsonlRpcRequestRejectedError } from "../jsonl-rpc-process.js";
import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
import type { OmpRuntime, OmpRuntimeSession, OmpStartSessionInput } from "./runtime.js";
import type {
@@ -322,6 +323,9 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str
}
}
// Bounds the dispatched prompts whose client correlation is still unclaimed.
const MAX_PENDING_CLIENT_CORRELATIONS = 16;
function convertPromptInput(
prompt: AgentPromptInput,
options: { model: OmpModel | null | undefined },
@@ -568,24 +572,6 @@ function getInputQuestionTitle(title: string | undefined, placeholder: string |
return "Optional response";
}
interface OmpSelectOption {
label: string;
description?: string;
}
function readSelectOptions(options: unknown, optionDetails: unknown): OmpSelectOption[] {
const labels = readStringArray(options);
const details = Array.isArray(optionDetails) ? optionDetails : [];
return labels.map((label, index) => {
const detail = details[index];
const description =
isRecord(detail) && typeof detail.description === "string" && detail.description.trim() !== ""
? detail.description
: undefined;
return description === undefined ? { label } : { label, description };
});
}
function readStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
@@ -604,7 +590,7 @@ function mapExtensionUiRequestToPermission(
const label = options.label ?? "OMP";
switch (event.method) {
case "select": {
const selectOptions = readSelectOptions(event.options, event.optionDetails);
const selectOptions = readStringArray(event.options);
if (options.combineOptionalComment) {
return buildCombinedAskUserQuestionPermission(event, {
provider,
@@ -651,7 +637,7 @@ function mapExtensionUiRequestToPermission(
question: [optionalString(event.title), optionalString(event.message)]
.filter(Boolean)
.join("\n\n"),
options: [{ label: "Yes" }, { label: "No" }],
options: ["Yes", "No"],
multiSelect: false,
});
default:
@@ -696,7 +682,7 @@ function buildExtensionUiQuestionPermission(
provider: AgentProvider;
label: string;
question: string;
options: OmpSelectOption[];
options: string[];
multiSelect: boolean;
placeholder?: string;
allowEmpty?: boolean;
@@ -714,10 +700,7 @@ function buildExtensionUiQuestionPermission(
{
question: input.question,
header: QUESTION_RESPONSE_HEADER,
options: input.options.map((option) => ({
label: option.label,
...(option.description === undefined ? {} : { description: option.description }),
})),
options: input.options.map((label) => ({ label })),
multiSelect: input.multiSelect,
...(input.placeholder ? { placeholder: input.placeholder } : {}),
...(input.allowEmpty ? { allowEmpty: true } : {}),
@@ -738,13 +721,11 @@ function buildCombinedAskUserQuestionPermission(
provider: AgentProvider;
label: string;
question: string;
options: OmpSelectOption[];
options: string[];
allowFreeform: boolean;
},
): AgentPermissionRequest {
const visibleOptions = input.options.filter(
(option) => !isOmpAskUserFreeformOption(option.label),
);
const visibleOptions = input.options.filter((option) => !isOmpAskUserFreeformOption(option));
const allowOther = input.allowFreeform || visibleOptions.length !== input.options.length;
return {
id: event.id,
@@ -757,10 +738,7 @@ function buildCombinedAskUserQuestionPermission(
{
question: input.question,
header: QUESTION_RESPONSE_HEADER,
options: visibleOptions.map((option) => ({
label: option.label,
...(option.description === undefined ? {} : { description: option.description }),
})),
options: visibleOptions.map((label) => ({ label })),
multiSelect: false,
...(allowOther ? { allowOther: true } : {}),
},
@@ -779,7 +757,7 @@ function buildCombinedAskUserQuestionPermission(
answerHeader: QUESTION_RESPONSE_HEADER,
commentHeader: QUESTION_COMMENT_HEADER,
combinedAskUser: COMBINED_ASK_USER_METADATA,
selectOptions: visibleOptions.map((option) => option.label),
selectOptions: visibleOptions,
...(allowOther ? { freeformSentinel: OMP_ASK_USER_FREEFORM_SENTINEL } : {}),
},
};
@@ -881,7 +859,12 @@ export class OmpAgentSession implements AgentSession {
private activeAskUserDialog: ActiveAskUserDialog | null = null;
private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null;
private activeTurnId: string | null = null;
private activeClientMessageId: string | null = null;
// A dispatched prompt owns its client correlation, not the turn that carried
// it: OMP echoes the prompt as a user message that can land after Paseo has
// already completed the turn (a replacement turn, or a turn completed by a
// custom message). Correlations are consumed by the echo they belong to, so
// the same text submitted on different turns stays separate.
private readonly pendingClientCorrelations: Array<{ text: string; clientMessageId: string }> = [];
private activeAssistantMessageId: string | null = null;
private activeTurnTerminalAssistantMessage: OmpAgentMessage | null = null;
private activeTurnStarted = false;
@@ -988,9 +971,11 @@ export class OmpAgentSession implements AgentSession {
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID();
if (options?.clientMessageId) {
this.rememberClientCorrelation(payload.text, options.clientMessageId);
}
this.live = true;
this.activeTurnId = turnId;
this.activeClientMessageId = options?.clientMessageId ?? null;
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
@@ -1020,12 +1005,16 @@ export class OmpAgentSession implements AgentSession {
return;
}
} catch (error) {
if (error instanceof JsonlRpcRequestRejectedError) {
// OMP refused the prompt, so no echo will ever claim it. Any other
// failure (a timeout) leaves the prompt with OMP, which still echoes it.
this.forgetClientCorrelation(options?.clientMessageId);
}
if (this.activeTurnId !== turnId) {
return;
}
this.usagePoller.stopTurn();
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activeAssistantMessageId = null;
@@ -1158,7 +1147,6 @@ export class OmpAgentSession implements AgentSession {
this.terminalizeActiveWork();
this.usagePoller.stopTurn();
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activeAssistantMessageId = null;
@@ -1319,6 +1307,36 @@ export class OmpAgentSession implements AgentSession {
return this.activeTurnId ?? undefined;
}
private rememberClientCorrelation(text: string, clientMessageId: string): void {
this.pendingClientCorrelations.push({ text, clientMessageId });
// OMP never echoes some prompts (a local-only command, a session that dies
// mid-turn). Keep the queue bounded so those cannot accumulate.
if (this.pendingClientCorrelations.length > MAX_PENDING_CLIENT_CORRELATIONS) {
this.pendingClientCorrelations.shift();
}
}
private forgetClientCorrelation(clientMessageId: string | undefined): void {
const index = this.pendingClientCorrelations.findIndex(
(entry) => entry.clientMessageId === clientMessageId,
);
if (index !== -1) {
this.pendingClientCorrelations.splice(index, 1);
}
}
// OMP echoes prompts in the order Paseo sent them, and a prompt OMP refused
// is forgotten on dispatch, so the oldest unclaimed correlation with the
// echoed text belongs to this echo.
private takeClientCorrelation(text: string): string | null {
const index = this.pendingClientCorrelations.findIndex((entry) => entry.text === text);
if (index === -1) {
return null;
}
const [entry] = this.pendingClientCorrelations.splice(index, 1);
return entry?.clientMessageId ?? null;
}
private scheduleNoTurnPromptCompletion(turnId: string): void {
this.cancelNoTurnPromptCompletion();
const abort = new AbortController();
@@ -1372,6 +1390,7 @@ export class OmpAgentSession implements AgentSession {
const outputs = this.pendingNoTurnOutputs.filter((output) => output.turnId === turnId);
this.clearNoTurnBuffers();
if (promptText) {
const clientMessageId = this.takeClientCorrelation(promptText);
this.emit({
type: "timeline",
provider: this.provider,
@@ -1379,7 +1398,7 @@ export class OmpAgentSession implements AgentSession {
item: {
type: "user_message",
text: promptText,
...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}),
...(clientMessageId ? { clientMessageId } : {}),
},
});
}
@@ -1817,7 +1836,6 @@ export class OmpAgentSession implements AgentSession {
}
const turnId = this.activeTurnId;
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeTurnStarted = false;
this.activeTurnHasUserMessage = false;
this.activeTurnTerminalAssistantMessage = null;
@@ -2059,6 +2077,9 @@ export class OmpAgentSession implements AgentSession {
});
}
}
if (!this.activeTurnHasUserMessage) {
this.completeTurn(turnId, []);
}
return;
}
@@ -2071,7 +2092,6 @@ export class OmpAgentSession implements AgentSession {
}
const nativeMessage = event.message as OmpAgentMessage & { id?: unknown; entryId?: unknown };
const messageId = readNativeMessageId(nativeMessage);
const clientMessageId = this.activeClientMessageId;
const emitUserMessage = (resolvedMessageId?: string): void => {
if (resolvedMessageId) {
// OMP re-emits user message_end frames for entries it has already
@@ -2082,6 +2102,9 @@ export class OmpAgentSession implements AgentSession {
}
this.emittedUserMessageIds.add(resolvedMessageId);
}
// Claimed only once a row is really emitted, so a re-emitted frame cannot
// consume the correlation of a later submission with the same text.
const clientMessageId = this.takeClientCorrelation(text);
this.emit({
type: "timeline",
provider: this.provider,
@@ -2155,7 +2178,6 @@ export class OmpAgentSession implements AgentSession {
private completeTurn(turnId: string | undefined, messages: OmpAgentMessage[]): void {
this.activeTurnId = null;
this.activeClientMessageId = null;
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
@@ -444,7 +444,6 @@ const OmpExtensionUiRequestSchema = z
title: z.string().optional(),
message: z.string().optional(),
options: z.array(z.string()).optional(),
optionDetails: z.unknown().optional(),
placeholder: z.string().optional(),
url: z.string().optional(),
launchUrl: z.string().optional(),
@@ -44,6 +44,7 @@ export interface OmpStartSessionInput {
export interface OmpRuntimeSession {
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void;
// Rejects with JsonlRpcRequestRejectedError when OMP refused the prompt.
prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
@@ -133,6 +133,7 @@ export class FakeOmpSession implements OmpRuntimeSession {
compactError: Error | null = null;
emitCompactEnd = true;
getStateError: Error | null = null;
promptError: Error | null = null;
promptAck: OmpPromptAck = {};
branchResponse: { text?: string; cancelled?: boolean } = { text: "" };
branchMessages: Array<{ entryId: string; text: string }> = [];
@@ -182,6 +183,9 @@ export class FakeOmpSession implements OmpRuntimeSession {
}
this.prompts.push({ message, imageCount: images?.length ?? 0 });
this.promptWaiters.shift()?.();
if (this.promptError) {
throw this.promptError;
}
const heldPrompt = this.nextHeldPrompt;
if (heldPrompt) {
this.nextHeldPrompt = null;
@@ -19,7 +19,7 @@ import {
type OmpProviderIdleScheduler,
} from "../agent.js";
import type { OmpUsagePollScheduler } from "../usage-poller.js";
import type { OmpAgentMessage, OmpRpcSlashCommand, OmpRuntimeEvent } from "../rpc-types.js";
import type { OmpAgentMessage, OmpRpcSlashCommand } from "../rpc-types.js";
import { FakeOmp } from "./fake-omp.js";
const CWD = "/tmp/paseo-omp-agent-test";
@@ -422,10 +422,6 @@ export class OmpHarness {
this.omp.latestSession().requestToolApproval(input);
}
emit(event: OmpRuntimeEvent): void {
this.omp.latestSession().emit(event);
}
pendingPermissions() {
return this.requireSession().getPendingPermissions();
}
@@ -470,6 +466,33 @@ export class OmpHarness {
await promptStarted;
}
// Drains the microtask queue until the session has reached the state the test
// is waiting on, so no test depends on how many async steps a path takes.
async waitUntil(condition: () => boolean, description: string): Promise<void> {
for (let index = 0; index < 100; index += 1) {
if (condition()) return;
await waitForImmediate();
}
throw new Error(`OMP harness timed out waiting for ${description}`);
}
async waitForUserMessages(count: number): Promise<void> {
await this.waitUntil(
() => this.timeline().filter((item) => item.type === "user_message").length >= count,
`${count} user message(s)`,
);
}
async waitForEvent(type: AgentStreamEvent["type"]): Promise<void> {
await this.waitUntil(() => this.eventTypes().includes(type), `a ${type} event`);
}
async requireStartTurnFromClient(message: string, clientMessageId: string): Promise<void> {
const promptStarted = this.omp.latestSession().nextPrompt();
await this.requireSession().startTurn(message, { clientMessageId });
await promptStarted;
}
async interrupt(): Promise<void> {
await this.requireSession().interrupt();
}

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