Compare commits

..
23 Commits
Author SHA1 Message Date
Mohamed Boudra d3876ffe61 Fix rebase type errors and add TODO date tag
- Await listAvailableEditorTargets() which became async on main
- Wrap sendAgentMessage callback to match Promise<void> return type
- Add TODO(2026-07) date to legacy editor target ids comment
2026-04-09 09:23:31 +07:00
Illia Panasenko 8f136a1f92 Add WebStorm editor target 2026-04-09 09:17:43 +07:00
Mohamed Boudra 6bf8da8087 Commit current desktop and CLI diff 2026-04-09 00:27:20 +07:00
Mohamed Boudra d39414064e Fix opencode terminal states and model refresh 2026-04-09 00:25:30 +07:00
Mohamed Boudra ffcc35485d Fix agent follow-up turns and wait lifecycle 2026-04-09 00:24:58 +07:00
Mohamed Boudra 8ff94dc03e fix(app): improve pair device screen with read-only input for link
Replace truncating text with a read-only TextInput + copy button pattern
so the pairing URL doesn't get cut off. Increase QR code size from
200x200 to 320x320.
2026-04-08 23:36:07 +07:00
Mohamed Boudra dace4f862f refactor: make executable resolution async and centralize spawn
- Rename findExecutable → findExecutableSync, add async findExecutable
  that uses promisified execFile instead of execFileSync
- Simplify Windows PATH resolution: rely on inherited env from Explorer
  instead of shelling out to PowerShell for registry PATH entries
- Add spawnProcess() helper that centralizes Windows shell/quoting concerns
- Update all agent providers and consumers to use async executable resolution
- Add windowsHide: true across all Windows child process calls
2026-04-08 23:24:43 +07:00
Mohamed Boudra fc667d6312 fix(claude): filter <local-command-stdout> messages from timeline
Claude Code writes local CLI command outputs (model switches, /context,
/compact, goodbye messages, etc.) as user entries in the JSONL history.
These were leaking through as user_message timeline items in Paseo.
2026-04-08 22:03:30 +07:00
Mohamed Boudra aae4d9f8dd docs(website): update mobile app availability status
Apps are now available on App Store and Play Store.
2026-04-08 21:34:37 +07:00
Mohamed Boudra 5eb8b300a3 fix(ci): prevent duplicate GitHub releases in desktop workflow
Add a create-release job that runs first and creates the GitHub release
before any platform build jobs start. This prevents the race condition
where parallel electron-builder jobs each create their own release.

Platform jobs now depend on create-release and just upload assets to
the existing release. Single-platform retry tags (desktop-macos-v*,
desktop-linux-v*, desktop-windows-v*) skip the create-release job
since the release already exists from the original build.
2026-04-08 19:37:30 +07:00
github-actions[bot] 6c9a832906 fix: update lockfile signatures and Nix hash 2026-04-08 12:20:15 +00:00
Mohamed Boudra 35430dab52 chore(release): cut 0.1.51-rc.1 2026-04-08 19:19:07 +07:00
Mohamed Boudra 0eac4bc4b3 fix(desktop): enable electron-log console transport for stdout output
Ensures renderer console.log output (including layout-debug) is
printed to stdout in packaged builds, not just the log file.
Also removes unnecessary webContents event listeners.
2026-04-08 19:11:06 +07:00
Mohamed Boudra 635de3d76a debug(desktop): add renderer console-message and lifecycle event logging
Pipes all renderer console.log output to the main process stdout via
electron-log, and logs did-finish-load/did-fail-load/render-process-gone
events to help diagnose layout and loading issues on Linux.
2026-04-08 19:03:45 +07:00
Mohamed Boudra 931d3ba81f fix: log bootstrap errors to daemon.log and add layout debug logging
- Log fatal errors during daemon bootstrap and startup to pino (daemon.log)
  instead of only writing to stderr, which is invisible on Windows desktop
- Delay process.exit to let pino async streams flush
- Make voice MCP bridge script resolution non-fatal (warn + disable instead
  of crashing the daemon)
- Add PASEO_ELECTRON_FLAGS env var for passing Chromium flags on Linux
- Add layout debug logging to AppContainer for diagnosing sidebar issues
2026-04-08 18:55:56 +07:00
Illia Panasenko 6a4f439541 refactor(app): improve route types, reduce amount of as any (#217) 2026-04-08 12:52:37 +08:00
Mohamed Boudra ac5e6df6c9 feat(website): add Plausible analytics to all pages 2026-04-08 11:18:03 +07:00
Mohamed Boudra bf7d3c2775 copy: update hero headline to focus on orchestration value prop 2026-04-08 11:05:42 +07:00
Mohamed Boudra 5c93fbc955 feat: emit usage_updated events for real-time token usage updates 2026-04-08 08:59:02 +07:00
Mohamed Boudra 5ac7b3f7c7 docs(release): add changelog voice guidelines 2026-04-07 20:26:01 +07:00
Mohamed Boudra c742f17080 docs(changelog): remove internal-only fixes from 0.1.50 notes 2026-04-07 20:24:02 +07:00
Mohamed Boudra b4d6a5d6b8 docs(changelog): rewrite release notes for end users 2026-04-07 20:22:04 +07:00
github-actions[bot] 43f01600ce fix: update lockfile signatures and Nix hash 2026-04-07 13:05:48 +00:00
91 changed files with 2376 additions and 1077 deletions
+40 -3
View File
@@ -35,8 +35,43 @@ env:
DESKTOP_PACKAGE_PATH: 'packages/desktop' DESKTOP_PACKAGE_PATH: 'packages/desktop'
jobs: jobs:
create-release:
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Create GitHub release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists, skipping creation"
else
prerelease_flag=""
if [[ "$IS_PRERELEASE" == "true" ]]; then
prerelease_flag="--prerelease"
fi
gh release create "$RELEASE_TAG" \
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--generate-notes \
$prerelease_flag
fi
publish-macos: publish-macos:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }} needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -219,7 +254,8 @@ jobs:
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}" run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
publish-linux: publish-linux:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }} needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
permissions: permissions:
contents: write contents: write
packages: read packages: read
@@ -285,7 +321,8 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}" npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
publish-windows: publish-windows:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }} needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }}
permissions: permissions:
contents: write contents: write
packages: read packages: read
+22 -26
View File
@@ -3,40 +3,36 @@
## 0.1.50 - 2026-04-07 ## 0.1.50 - 2026-04-07
### Added ### Added
- Context window meter — live token usage indicator for Claude Code, Codex, and OpenCode shows how much of the context window has been consumed, with color thresholds at 70% and 90%. - Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
- Open in editor — open the current workspace directory in Cursor, VS Code, Zed, or the system file manager directly from the toolbar. Remembers your preferred editor. - Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
- Side-by-side diff layout — toggle between unified and split-column diff views in the Changes pane, with a whitespace visibility toggle. - Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
- Spoken messages — voice-mode speak tool calls now render inline in the conversation as labeled spoken messages instead of raw tool call blocks. - Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
- Plan approval actions — plan permission cards now show provider-defined action buttons (e.g. "Implement", "Deny") instead of hardcoded accept/reject. - Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
- Background git fetch — the daemon periodically fetches from origin so the Changes pane shows accurate ahead/behind counts without manual refreshes. - Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
### Improved ### Improved
- File explorer and diff pane expanded/collapsed state persists across tab switches and rehydration. - Workspaces load instantly on connect instead of waiting for a full sync.
- Workspace list and updates are served instantly on connect; reconciliation happens in the background, eliminating the initial loading delay. - File explorer and diff pane remember which folders are expanded when you switch tabs.
- Provider list in Settings now includes a Refresh button and shows inline error details. - Closing a workspace tab is now instant.
- Workspace tabs close optimistically — the tab disappears immediately while the daemon archives the agent in the background. - Settings shows a Refresh button for providers and displays error details inline.
- Reload agent action moved away from the close button to prevent accidental taps. - Reload agent moved away from the close button to prevent accidental taps.
### Fixed ### Fixed
- WorkingIndicator no longer remounts on every stream update on native. - Voice mode no longer drifts into false speech detection during long sessions.
- Silero VAD state is now reset between voice turns, preventing LSTM drift that could cause false speech detections in long sessions. - Garbled overlapping text on plan cards.
- OpenCode context window meter updates correctly after the first turn. - Changes pane could show stale diffs when working with git worktrees.
- Garbled overlapping text in plan card markdown. - Restarting an agent quickly could crash the session.
- Worktree branch tracking now prefers `origin/{branch}` over the local branch ref, fixing stale diff baselines. - Copilot no longer pauses for permission prompts in autopilot mode.
- Session ID reset on query restart prevents an overwrite crash when restarting an agent quickly. - Connection and pairing dialogs now display correctly on tablets.
- Copilot ACP permission prompts are now bypassed in autopilot mode. - Orchestration errors from agents are now surfaced instead of silently lost.
- Direct connection and pairing modal content now displays correctly on tablets. - Diff stats no longer reset to zero when reconnecting.
- `wait_for_finish` errors from agents are now surfaced to the caller instead of silently swallowed.
- Workspace diff stats preserved across rehydration instead of resetting to zero.
- Diff toolbar toggle buttons polished for consistent sizing and alignment.
## 0.1.49 - 2026-04-07 ## 0.1.49 - 2026-04-07
### Fixed ### Fixed
- Models and providers now load reliably on first app connect instead of requiring a second status refresh. - Models and providers now load reliably on first connect instead of requiring a manual refresh.
- Model picker on running agents now only shows models from the agent's own provider, not every provider on the server. - Model picker only shows models from the agent's own provider, not every provider on the server.
- Model data is now prefetched consistently regardless of which screen you open first. - Model lists stay consistent regardless of which screen you open first.
- Draft and running-agent flows now share the same provider data path, eliminating stale model lists from legacy fallbacks.
## 0.1.48 - 2026-04-05 ## 0.1.48 - 2026-04-05
+10
View File
@@ -129,6 +129,16 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
- **Only Claude should write changelog entries.** - **Only Claude should write changelog entries.**
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result. - If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
## Changelog voice
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
## Pre-release sanity check ## Pre-release sanity check
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs. Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/). # CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-TEsFzJRgVubRnjAy7OO6Xkn6HY7CRO3LVxsVdbw3IH4="; npmDepsHash = "sha256-eslgD6PqQaRAWCnDE2A41bTmXqoU/ZEY0oDTh+oAvh0=";
# Prevent onnxruntime-node's install script from running during automatic # Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+19 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.50", "version": "0.1.51-rc.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "paseo", "name": "paseo",
"version": "0.1.50", "version": "0.1.51-rc.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
@@ -34906,16 +34906,16 @@
}, },
"packages/app": { "packages/app": {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"version": "0.1.50", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.50", "@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
"@getpaseo/highlight": "0.1.50", "@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/server": "0.1.50", "@getpaseo/server": "0.1.51-rc.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
@@ -35032,11 +35032,11 @@
}, },
"packages/cli": { "packages/cli": {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.50", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.50", "@getpaseo/relay": "0.1.51-rc.1",
"@getpaseo/server": "0.1.50", "@getpaseo/server": "0.1.51-rc.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
@@ -35077,11 +35077,11 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.50", "version": "0.1.51-rc.1",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.50", "@getpaseo/cli": "0.1.51-rc.1",
"@getpaseo/server": "0.1.50", "@getpaseo/server": "0.1.51-rc.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
@@ -35115,7 +35115,7 @@
}, },
"packages/expo-two-way-audio": { "packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.50", "version": "0.1.51-rc.1",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.9.4", "@biomejs/biome": "1.9.4",
@@ -35316,7 +35316,7 @@
}, },
"packages/highlight": { "packages/highlight": {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.50", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"@lezer/common": "^1.5.0", "@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5", "@lezer/cpp": "^1.1.5",
@@ -35342,7 +35342,7 @@
}, },
"packages/relay": { "packages/relay": {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.50", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"tweetnacl": "^1.0.3", "tweetnacl": "^1.0.3",
@@ -35358,14 +35358,14 @@
}, },
"packages/server": { "packages/server": {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.50", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"@agentclientprotocol/sdk": "^0.17.1", "@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.50", "@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/relay": "0.1.50", "@getpaseo/relay": "0.1.51-rc.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
@@ -35764,7 +35764,7 @@
}, },
"packages/website": { "packages/website": {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.50", "version": "0.1.51-rc.1",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0", "@cloudflare/workers-types": "^4.20260114.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "paseo", "name": "paseo",
"version": "0.1.50", "version": "0.1.51-rc.1",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/expo-two-way-audio", "packages/expo-two-way-audio",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "@getpaseo/app", "name": "@getpaseo/app",
"main": "index.ts", "main": "index.ts",
"version": "0.1.50", "version": "0.1.51-rc.1",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7", "@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.50", "@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
"@getpaseo/highlight": "0.1.50", "@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/server": "0.1.50", "@getpaseo/server": "0.1.51-rc.1",
"@gorhom/bottom-sheet": "^5.2.6", "@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14", "@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
+33 -2
View File
@@ -369,10 +369,41 @@ function AppContainer({
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars); const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode); const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const isCompactLayout = isCompactFormFactor(); const isCompactLayout = isCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
useEffect(() => {
const bp = UnistylesRuntime.breakpoint;
const screenW = UnistylesRuntime.screen.width;
const screenH = UnistylesRuntime.screen.height;
const isElectron = getIsElectronRuntime();
const windowW = Platform.OS === "web" ? window.innerWidth : undefined;
const windowH = Platform.OS === "web" ? window.innerHeight : undefined;
const dpr = Platform.OS === "web" ? window.devicePixelRatio : undefined;
const ua = Platform.OS === "web" ? navigator.userAgent : undefined;
console.log(
"[layout-debug]",
JSON.stringify({
breakpoint: bp,
isCompactLayout,
isElectron,
chromeEnabled,
isFocusModeEnabled,
agentListOpen,
sidebarWidth,
sidebarRenderedInRow: !isCompactLayout && chromeEnabled && !isFocusModeEnabled,
unistylesScreen: { w: screenW, h: screenH },
window: { w: windowW, h: windowH },
devicePixelRatio: dpr,
userAgent: ua,
}),
);
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
useKeyboardShortcuts({ useKeyboardShortcuts({
enabled: chromeEnabled, enabled: chromeEnabled,
isMobile: isCompactLayout, isMobile: isCompactLayout,
@@ -574,7 +605,7 @@ function OfferLinkListener({
if (cancelled) return; if (cancelled) return;
const serverId = (profile as any)?.serverId; const serverId = (profile as any)?.serverId;
if (typeof serverId !== "string" || !serverId) return; if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostRootRoute(serverId) as any); router.replace(buildHostRootRoute(serverId));
}) })
.catch((error) => { .catch((error) => {
if (cancelled) return; if (cancelled) return;
@@ -692,7 +723,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
if (hosts.some((host) => host.serverId === activeServerId)) { if (hosts.some((host) => host.serverId === activeServerId)) {
return; return;
} }
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any); router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
}, [activeServerId, hosts, pathname, router]); }, [activeServerId, hosts, pathname, router]);
// Parse selectedAgentKey directly from pathname // Parse selectedAgentKey directly from pathname
@@ -58,7 +58,7 @@ export default function HostAgentReadyRoute() {
} }
if (!client || !isConnected) { if (!client || !isConnected) {
redirectedRef.current = true; redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId) as any); router.replace(buildHostRootRoute(serverId));
} }
}, [agentCwd, agentId, client, isConnected, router, serverId]); }, [agentCwd, agentId, client, isConnected, router, serverId]);
@@ -89,14 +89,14 @@ export default function HostAgentReadyRoute() {
); );
return; return;
} }
router.replace(buildHostRootRoute(serverId) as any); router.replace(buildHostRootRoute(serverId));
}) })
.catch(() => { .catch(() => {
if (cancelled || redirectedRef.current) { if (cancelled || redirectedRef.current) {
return; return;
} }
redirectedRef.current = true; redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId) as any); router.replace(buildHostRootRoute(serverId));
}); });
return () => { return () => {
+2 -2
View File
@@ -68,11 +68,11 @@ export default function HostIndexRoute() {
const primaryWorkspace = visibleWorkspaces[0]; const primaryWorkspace = visibleWorkspaces[0];
if (primaryWorkspace?.id?.trim()) { if (primaryWorkspace?.id?.trim()) {
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any); router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
return; return;
} }
router.replace(buildHostOpenProjectRoute(serverId) as any); router.replace(buildHostOpenProjectRoute(serverId));
}, HOST_ROOT_REDIRECT_DELAY_MS); }, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer); return () => clearTimeout(timer);
+1 -1
View File
@@ -58,7 +58,7 @@ export default function Index() {
const targetRoute = anyOnlineServerId const targetRoute = anyOnlineServerId
? buildHostRootRoute(anyOnlineServerId) ? buildHostRootRoute(anyOnlineServerId)
: WELCOME_ROUTE; : WELCOME_ROUTE;
router.replace(targetRoute as any); router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]); }, [anyOnlineServerId, pathname, router, storeReady]);
return <StartupSplashScreen bootstrapState={bootstrapState} />; return <StartupSplashScreen bootstrapState={bootstrapState} />;
+3 -3
View File
@@ -174,7 +174,7 @@ export default function PairScanScreen() {
const returnToSource = useCallback( const returnToSource = useCallback(
(serverId: string) => { (serverId: string) => {
if (source === "onboarding") { if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId) as any); router.replace(buildHostRootRoute(serverId));
return; return;
} }
if (source === "editHost" && targetServerId) { if (source === "editHost" && targetServerId) {
@@ -190,7 +190,7 @@ export default function PairScanScreen() {
router.back(); router.back();
} catch { } catch {
const settingsServerId = sourceServerId ?? serverId; const settingsServerId = sourceServerId ?? serverId;
router.replace(buildHostSettingsRoute(settingsServerId) as any); router.replace(buildHostSettingsRoute(settingsServerId));
} }
}, },
[router, source, sourceServerId, targetServerId], [router, source, sourceServerId, targetServerId],
@@ -209,7 +209,7 @@ export default function PairScanScreen() {
router.back(); router.back();
} catch { } catch {
if (sourceServerId) { if (sourceServerId) {
router.replace(buildHostSettingsRoute(sourceServerId) as any); router.replace(buildHostSettingsRoute(sourceServerId));
return; return;
} }
router.replace("/" as any); router.replace("/" as any);
+1 -1
View File
@@ -16,7 +16,7 @@ export default function LegacySettingsRoute() {
if (!targetServerId) { if (!targetServerId) {
return; return;
} }
router.replace(buildHostSettingsRoute(targetServerId) as any); router.replace(buildHostSettingsRoute(targetServerId));
}, [router, targetServerId]); }, [router, targetServerId]);
if (!targetServerId) { if (!targetServerId) {
+1 -1
View File
@@ -240,7 +240,7 @@ export function AgentList({
target: { kind: "agent", agentId }, target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt), pin: Boolean(agent.archivedAt),
}); });
router.navigate(route as any); router.navigate(route);
}, },
[isActionSheetVisible, onAgentSelect], [isActionSheetVisible, onAgentSelect],
); );
@@ -182,7 +182,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceId, workspaceId,
target: { kind: "file", path: normalized.file }, target: { kind: "file", path: normalized.file },
}); });
router.navigate(route as any); router.navigate(route);
return; return;
} }
@@ -983,7 +983,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath }) void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => { .then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any); router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
}) })
.catch((err) => { .catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree"; const message = err instanceof Error ? err.message : "Failed to archive worktree";
@@ -1,5 +1,10 @@
import { SquareTerminal } from "lucide-react-native";
import { Image, type ImageSourcePropType } from "react-native"; import { Image, type ImageSourcePropType } from "react-native";
import type { EditorTargetId } from "@server/shared/messages"; import {
isKnownEditorTargetId,
type EditorTargetId,
type KnownEditorTargetId,
} from "@server/shared/messages";
interface EditorAppIconProps { interface EditorAppIconProps {
editorId: EditorTargetId; editorId: EditorTargetId;
@@ -8,9 +13,10 @@ interface EditorAppIconProps {
} }
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const EDITOR_APP_IMAGES: Record<EditorTargetId, ImageSourcePropType> = { const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
cursor: require("../../../assets/images/editor-apps/cursor.png"), cursor: require("../../../assets/images/editor-apps/cursor.png"),
vscode: require("../../../assets/images/editor-apps/vscode.png"), vscode: require("../../../assets/images/editor-apps/vscode.png"),
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
zed: require("../../../assets/images/editor-apps/zed.png"), zed: require("../../../assets/images/editor-apps/zed.png"),
finder: require("../../../assets/images/editor-apps/finder.png"), finder: require("../../../assets/images/editor-apps/finder.png"),
explorer: require("../../../assets/images/editor-apps/file-explorer.png"), explorer: require("../../../assets/images/editor-apps/file-explorer.png"),
@@ -18,10 +24,21 @@ const EDITOR_APP_IMAGES: Record<EditorTargetId, ImageSourcePropType> = {
}; };
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
export function hasBundledEditorAppIcon(
editorId: EditorTargetId,
): editorId is KnownEditorTargetId {
return isKnownEditorTargetId(editorId);
}
export function EditorAppIcon({ export function EditorAppIcon({
editorId, editorId,
size = 16, size = 16,
color,
}: EditorAppIconProps) { }: EditorAppIconProps) {
if (!hasBundledEditorAppIcon(editorId)) {
return <SquareTerminal size={size} color={color} />;
}
return ( return (
<Image <Image
source={EDITOR_APP_IMAGES[editorId]} source={EDITOR_APP_IMAGES[editorId]}
+4 -4
View File
@@ -229,21 +229,21 @@ export const LeftSidebar = memo(function LeftSidebar({
return; return;
} }
closeToAgent(); closeToAgent();
router.push(buildHostSettingsRoute(activeServerId) as any); router.push(buildHostSettingsRoute(activeServerId));
}, [activeServerId, closeToAgent]); }, [activeServerId, closeToAgent]);
const handleSettingsDesktop = useCallback(() => { const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) { if (!activeServerId) {
return; return;
} }
router.push(buildHostSettingsRoute(activeServerId) as any); router.push(buildHostSettingsRoute(activeServerId));
}, [activeServerId]); }, [activeServerId]);
const handleViewMoreNavigate = useCallback(() => { const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) { if (!activeServerId) {
return; return;
} }
router.push(buildHostSessionsRoute(activeServerId) as any); router.push(buildHostSessionsRoute(activeServerId));
}, [activeServerId]); }, [activeServerId]);
const handleHostSelect = useCallback( const handleHostSelect = useCallback(
@@ -253,7 +253,7 @@ export const LeftSidebar = memo(function LeftSidebar({
} }
const nextPath = mapPathnameToServer(pathname, nextServerId); const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false); setIsHostPickerOpen(false);
router.push(nextPath as any); router.push(nextPath);
}, },
[pathname], [pathname],
); );
@@ -1113,7 +1113,7 @@ function WorkspaceRowWithMenu({
serverId: workspace.serverId, serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId, archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(), workspaces: sessionWorkspaces.values(),
}) as any, }),
); );
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]); }, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
@@ -270,12 +270,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
if (pendingNameHost) { if (pendingNameHost) {
return; return;
} }
router.replace(buildHostRootRoute(anyOnlineServerId) as any); router.replace(buildHostRootRoute(anyOnlineServerId));
}, [anyOnlineServerId, pendingNameHost, router]); }, [anyOnlineServerId, pendingNameHost, router]);
const finishOnboarding = useCallback( const finishOnboarding = useCallback(
(serverId: string) => { (serverId: string) => {
router.replace(buildHostRootRoute(serverId) as any); router.replace(buildHostRootRoute(serverId));
}, },
[router], [router],
); );
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Image, Text, View } from "react-native"; import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
@@ -12,7 +11,6 @@ import {
RotateCw, RotateCw,
Copy, Copy,
FileText, FileText,
Smartphone,
Activity, Activity,
} from "lucide-react-native"; } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
@@ -24,7 +22,6 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import { import {
getCliDaemonStatus, getCliDaemonStatus,
getDesktopDaemonLogs, getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus, getDesktopDaemonStatus,
restartDesktopDaemon, restartDesktopDaemon,
shouldUseDesktopDaemon, shouldUseDesktopDaemon,
@@ -32,7 +29,6 @@ import {
stopDesktopDaemon, stopDesktopDaemon,
type DesktopDaemonLogs, type DesktopDaemonLogs,
type DesktopDaemonStatus, type DesktopDaemonStatus,
type DesktopPairingOffer,
} from "@/desktop/daemon/desktop-daemon"; } from "@/desktop/daemon/desktop-daemon";
export interface LocalDaemonSectionProps { export interface LocalDaemonSectionProps {
@@ -52,10 +48,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const [statusMessage, setStatusMessage] = useState<string | null>(null); const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null); const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null); const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false); const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false); const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
@@ -238,46 +230,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
setIsLogsModalOpen(true); setIsLogsModalOpen(true);
}, [daemonLogs]); }, [daemonLogs]);
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
return;
}
setIsPairingModalOpen(true);
setIsLoadingPairing(true);
setPairingStatusMessage(null);
void getDesktopDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing);
if (!pairing.relayEnabled || !pairing.url) {
setPairingStatusMessage("Relay pairing is not available.");
}
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setPairingOffer(null);
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
})
.finally(() => {
setIsLoadingPairing(false);
});
}, [isLoadingPairing]);
const handleCopyPairingLink = useCallback(() => {
if (!pairingOffer?.url) {
return;
}
void Clipboard.setStringAsync(pairingOffer.url)
.then(() => {
Alert.alert("Copied", "Pairing link copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy pairing link", error);
Alert.alert("Error", "Unable to copy pairing link.");
});
}, [pairingOffer?.url]);
const handleOpenCliStatus = useCallback(async () => { const handleOpenCliStatus = useCallback(async () => {
setIsLoadingCliStatus(true); setIsLoadingCliStatus(true);
try { try {
@@ -418,20 +370,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button> </Button>
</View> </View>
</View> </View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Pair device</Text>
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenPairingModal}
>
Pair device
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}> <View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}> <View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text> <Text style={settingsStyles.rowTitle}>Full status</Text>
@@ -460,20 +398,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View> </View>
) : null} ) : null}
<AdaptiveModalSheet
visible={isPairingModalOpen}
onClose={() => setIsPairingModalOpen(false)}
title="Pair device"
testID="managed-daemon-pairing-dialog"
>
<PairingOfferDialogContent
isLoading={isLoadingPairing}
pairingOffer={pairingOffer}
statusMessage={pairingStatusMessage}
onCopyLink={handleCopyPairingLink}
/>
</AdaptiveModalSheet>
<AdaptiveModalSheet <AdaptiveModalSheet
visible={isLogsModalOpen} visible={isLogsModalOpen}
onClose={() => setIsLogsModalOpen(false)} onClose={() => setIsLogsModalOpen(false)}
@@ -516,107 +440,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration"; const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
function PairingOfferDialogContent(input: {
isLoading: boolean;
pairingOffer: DesktopPairingOffer | null;
statusMessage: string | null;
onCopyLink: () => void;
}) {
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrError, setQrError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!pairingOffer?.url) {
setQrDataUrl(null);
setQrError(null);
return () => {
cancelled = true;
};
}
setQrError(null);
setQrDataUrl(null);
void QRCode.toDataURL(pairingOffer.url, {
errorCorrectionLevel: "M",
margin: 1,
width: 480,
})
.then((dataUrl) => {
if (cancelled) {
return;
}
setQrDataUrl(dataUrl);
})
.catch((error) => {
if (cancelled) {
return;
}
setQrError(error instanceof Error ? error.message : String(error));
});
return () => {
cancelled = true;
};
}, [pairingOffer?.url]);
if (isLoading) {
return (
<View style={styles.pairingState}>
<ActivityIndicator size="small" />
<Text style={settingsStyles.rowHint}>Loading pairing offer…</Text>
</View>
);
}
if (statusMessage) {
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
</View>
);
}
if (!pairingOffer?.url) {
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
</View>
);
}
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>
Scan this QR code in Paseo, or copy the pairing link below.
</Text>
<View style={styles.qrCard}>
{qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
) : qrError ? (
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
</View>
<View style={styles.linkSection}>
<Text style={styles.linkLabel}>Pairing link</Text>
<Text style={styles.linkText} selectable>
{pairingOffer.url}
</Text>
</View>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onCopyLink}>
Copy link
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
actionGroup: { actionGroup: {
flexDirection: "row", flexDirection: "row",
@@ -659,40 +482,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3], gap: theme.spacing[3],
paddingBottom: theme.spacing[2], paddingBottom: theme.spacing[2],
}, },
pairingState: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[6],
},
qrCard: {
alignItems: "center",
justifyContent: "center",
width: "100%",
aspectRatio: 1,
alignSelf: "stretch",
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
qrImage: {
width: "100%",
height: "100%",
},
linkSection: {
gap: theme.spacing[2],
},
linkLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
linkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 18,
},
logOutput: { logOutput: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -0,0 +1,187 @@
import { useCallback } from "react";
import { ActivityIndicator, Image, Text, TextInput, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useQuery } from "@tanstack/react-query";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { RotateCw, Copy, Check } from "lucide-react-native";
import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button";
import { getDesktopDaemonPairing, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { useState } from "react";
export function PairDeviceSection() {
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const [copied, setCopied] = useState(false);
const pairingQuery = useQuery({
queryKey: ["desktop-daemon-pairing"],
queryFn: getDesktopDaemonPairing,
enabled: showSection,
staleTime: 5 * 60 * 1000,
retry: 1,
});
const qrQuery = useQuery({
queryKey: ["desktop-daemon-pairing-qr", pairingQuery.data?.url],
queryFn: () =>
QRCode.toDataURL(pairingQuery.data!.url!, {
errorCorrectionLevel: "M",
margin: 1,
width: 480,
}),
enabled: !!pairingQuery.data?.url,
staleTime: Infinity,
});
const handleCopyLink = useCallback(async () => {
if (!pairingQuery.data?.url) return;
await Clipboard.setStringAsync(pairingQuery.data.url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [pairingQuery.data?.url]);
if (!showSection) return null;
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>Pair device</Text>
<View style={settingsStyles.card}>
{pairingQuery.isPending ? (
<View style={styles.centered}>
<ActivityIndicator size="small" />
<Text style={styles.hint}>Loading pairing offer…</Text>
</View>
) : pairingQuery.isError ? (
<View style={styles.centered}>
<Text style={styles.hint}>
{pairingQuery.error instanceof Error
? pairingQuery.error.message
: "Failed to load pairing offer."}
</Text>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void pairingQuery.refetch()}
>
Retry
</Button>
</View>
) : !pairingQuery.data?.url ? (
<View style={styles.centered}>
<Text style={styles.hint}>
{pairingQuery.data?.relayEnabled === false
? "Relay is not enabled. Enable relay to pair a device."
: "Pairing offer unavailable."}
</Text>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void pairingQuery.refetch()}
>
Retry
</Button>
</View>
) : (
<View style={styles.content}>
<Text style={styles.hint}>
Scan this QR code with Paseo on your phone, or copy the link below.
</Text>
<View style={styles.qrContainer}>
{qrQuery.data ? (
<Image source={{ uri: qrQuery.data }} style={styles.qrImage} resizeMode="contain" />
) : qrQuery.isError ? (
<Text style={styles.hint}>QR code unavailable.</Text>
) : (
<ActivityIndicator size="small" />
)}
</View>
<View style={styles.linkRow}>
<View style={styles.inputWrapper}>
<TextInput
style={styles.linkInput}
value={pairingQuery.data.url}
readOnly
selectTextOnFocus
selectionColor={theme.colors.accent}
/>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
copied ? (
<Check size={theme.iconSize.sm} color={theme.colors.accent} />
) : (
<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={() => void handleCopyLink()}
>
{copied ? "Copied" : "Copy"}
</Button>
</View>
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
centered: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[6],
paddingHorizontal: theme.spacing[4],
},
content: {
gap: theme.spacing[3],
padding: theme.spacing[4],
},
hint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
textAlign: "center",
},
qrContainer: {
alignItems: "center",
justifyContent: "center",
alignSelf: "center",
width: 320,
height: 320,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
padding: theme.spacing[2],
},
qrImage: {
width: "100%",
height: "100%",
},
linkRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
inputWrapper: {
flex: 1,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
overflow: "hidden",
},
linkInput: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
outlineStyle: "none",
} as any,
}));
+2 -2
View File
@@ -165,7 +165,7 @@ export function useCommandCenter() {
const settingsRoute = useMemo<Href>(() => { const settingsRoute = useMemo<Href>(() => {
const serverIdFromPath = activeServerId; const serverIdFromPath = activeServerId;
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/"; return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/";
}, [activeServerId]); }, [activeServerId]);
const actionItems = useMemo(() => { const actionItems = useMemo(() => {
@@ -220,7 +220,7 @@ export function useCommandCenter() {
workspaceId: agent.cwd, workspaceId: agent.cwd,
target: { kind: "agent", agentId: agent.id }, target: { kind: "agent", agentId: agent.id },
}); });
router.navigate(route as any); router.navigate(route);
}, },
[setOpen], [setOpen],
); );
+1 -1
View File
@@ -190,7 +190,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath }) void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => { .then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any); router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
}) })
.catch((err) => { .catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree"; const message = err instanceof Error ? err.message : "Failed to archive worktree";
@@ -14,6 +14,12 @@ describe("resolvePreferredEditorId", () => {
expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer"); expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer");
}); });
it("keeps unknown editor ids when they are still available", () => {
expect(resolvePreferredEditorId(["unknown-editor", "cursor"], "unknown-editor")).toBe(
"unknown-editor",
);
});
it("returns null when no editors are available", () => { it("returns null when no editors are available", () => {
expect(resolvePreferredEditorId([], "cursor")).toBeNull(); expect(resolvePreferredEditorId([], "cursor")).toBeNull();
}); });
@@ -11,7 +11,7 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
*/ */
export function navigateToWorkspace(serverId: string, workspaceId: string) { export function navigateToWorkspace(serverId: string, workspaceId: string) {
const href = buildHostWorkspaceRoute(serverId, workspaceId); const href = buildHostWorkspaceRoute(serverId, workspaceId);
router.navigate(href as any); router.navigate(href);
} }
export function useWorkspaceNavigation() { export function useWorkspaceNavigation() {
+1 -1
View File
@@ -54,7 +54,7 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
<Button <Button
variant="ghost" variant="ghost"
leftIcon={ChevronLeft} leftIcon={ChevronLeft}
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId) as any)} onPress={() => router.navigate(buildHostOpenProjectRoute(serverId))}
> >
Back Back
</Button> </Button>
+7 -1
View File
@@ -22,6 +22,7 @@ import {
Shield, Shield,
Puzzle, Puzzle,
Blocks, Blocks,
Smartphone,
} from "lucide-react-native"; } from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import type { HostProfile, HostConnection } from "@/types/host-connection"; import type { HostProfile, HostConnection } from "@/types/host-connection";
@@ -54,6 +55,7 @@ import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-mod
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section"; import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section"; import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
import { isElectronRuntime } from "@/desktop/host"; import { isElectronRuntime } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater"; import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -82,7 +84,8 @@ type SettingsSectionId =
| "diagnostics" | "diagnostics"
| "about" | "about"
| "permissions" | "permissions"
| "daemon"; | "daemon"
| "pair-device";
interface SettingsSectionDef { interface SettingsSectionDef {
id: SettingsSectionId; id: SettingsSectionId;
@@ -101,6 +104,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
if (context.isDesktopApp) { if (context.isDesktopApp) {
sections.push( sections.push(
{ id: "integrations", label: "Integrations", icon: Puzzle }, { id: "integrations", label: "Integrations", icon: Puzzle },
{ id: "pair-device", label: "Pair device", icon: Smartphone },
{ id: "daemon", label: "Daemon", icon: Settings }, { id: "daemon", label: "Daemon", icon: Settings },
); );
} }
@@ -647,6 +651,8 @@ function SettingsSectionContent({
return isDesktopApp ? <IntegrationsSection /> : null; return isDesktopApp ? <IntegrationsSection /> : null;
case "permissions": case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null; return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "pair-device":
return isDesktopApp ? <PairDeviceSection /> : null;
case "daemon": case "daemon":
return isDesktopApp ? ( return isDesktopApp ? (
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} /> <LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
+28 -28
View File
@@ -268,99 +268,99 @@ export function parseHostWorkspaceRouteFromPathname(
return { serverId, workspaceId }; return { serverId, workspaceId };
} }
export function buildHostWorkspaceRoute(serverId: string, workspaceId: string): string { export function buildHostWorkspaceRoute(serverId: string, workspaceId: string) {
const normalizedServerId = trimNonEmpty(serverId); const normalizedServerId = trimNonEmpty(serverId);
const normalizedWorkspaceId = trimNonEmpty(workspaceId); const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (!normalizedServerId || !normalizedWorkspaceId) { if (!normalizedServerId || !normalizedWorkspaceId) {
return "/"; return "/" as const;
} }
const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(normalizedWorkspaceId); const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(normalizedWorkspaceId);
if (!encodedWorkspaceId) { if (!encodedWorkspaceId) {
return "/"; return "/" as const;
} }
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`; return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}` as const;
} }
export function buildHostAgentDetailRoute( export function buildHostAgentDetailRoute(
serverId: string, serverId: string,
agentId: string, agentId: string,
workspaceId?: string, workspaceId?: string,
): string { ) {
const normalizedWorkspaceId = trimNonEmpty(workspaceId); const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (normalizedWorkspaceId) { if (normalizedWorkspaceId) {
const normalizedAgentId = trimNonEmpty(agentId); const normalizedAgentId = trimNonEmpty(agentId);
if (!normalizedAgentId) { if (!normalizedAgentId) {
return "/"; return "/" as const;
} }
const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId); const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId);
if (base === "/") { if (base === "/") {
return "/"; return "/" as const;
} }
return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}`; return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}` as const;
} }
const normalizedServerId = trimNonEmpty(serverId); const normalizedServerId = trimNonEmpty(serverId);
const normalizedAgentId = trimNonEmpty(agentId); const normalizedAgentId = trimNonEmpty(agentId);
if (!normalizedServerId || !normalizedAgentId) { if (!normalizedServerId || !normalizedAgentId) {
return "/"; return "/" as const;
} }
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}`; return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}` as const;
} }
export function buildHostRootRoute(serverId: string): string { export function buildHostRootRoute(serverId: string) {
const normalized = trimNonEmpty(serverId); const normalized = trimNonEmpty(serverId);
if (!normalized) { if (!normalized) {
return "/"; return "/" as const;
} }
return `/h/${encodeSegment(normalized)}`; return `/h/${encodeSegment(normalized)}` as const;
} }
export function buildHostSessionsRoute(serverId: string): string { export function buildHostSessionsRoute(serverId: string) {
const base = buildHostRootRoute(serverId); const base = buildHostRootRoute(serverId);
if (base === "/") { if (base === "/") {
return "/"; return "/" as const;
} }
return `${base}/sessions`; return `${base}/sessions` as const;
} }
export function buildHostOpenProjectRoute(serverId: string): string { export function buildHostOpenProjectRoute(serverId: string) {
const base = buildHostRootRoute(serverId); const base = buildHostRootRoute(serverId);
if (base === "/") { if (base === "/") {
return "/"; return "/" as const;
} }
return `${base}/open-project`; return `${base}/open-project` as const;
} }
export function buildHostSettingsRoute(serverId: string): string { export function buildHostSettingsRoute(serverId: string) {
const base = buildHostRootRoute(serverId); const base = buildHostRootRoute(serverId);
if (base === "/") { if (base === "/") {
return "/"; return "/" as const;
} }
return `${base}/settings`; return `${base}/settings` as const;
} }
export function mapPathnameToServer(pathname: string, nextServerId: string): string { export function mapPathnameToServer(pathname: string, nextServerId: string) {
const normalized = trimNonEmpty(nextServerId); const normalized = trimNonEmpty(nextServerId);
if (!normalized) { if (!normalized) {
return "/"; return "/" as const;
} }
const suffix = pathname.replace(/^\/h\/[^/]+\/?/, ""); const suffix = pathname.replace(/^\/h\/[^/]+\/?/, "");
const base = buildHostRootRoute(normalized); const base = buildHostRootRoute(normalized);
if (suffix.startsWith("settings")) { if (suffix.startsWith("settings")) {
return `${base}/settings`; return `${base}/settings` as const;
} }
if (suffix.startsWith("sessions")) { if (suffix.startsWith("sessions")) {
return `${base}/sessions`; return `${base}/sessions` as const;
} }
if (suffix.startsWith("open-project")) { if (suffix.startsWith("open-project")) {
return `${base}/open-project`; return `${base}/open-project` as const;
} }
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname); const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
if (workspaceRoute) { if (workspaceRoute) {
return buildHostWorkspaceRoute(normalized, workspaceRoute.workspaceId); return buildHostWorkspaceRoute(normalized, workspaceRoute.workspaceId);
} }
if (suffix.startsWith("agent/")) { if (suffix.startsWith("agent/")) {
return `${base}/${suffix}`; return `${base}/${suffix}` as const;
} }
return base; return base;
} }
+1 -1
View File
@@ -69,7 +69,7 @@ export function resolveNewAgentWorkingDir(
return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd; return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd;
} }
export function buildNewAgentRoute(serverId: string, workingDir?: string | null): string { export function buildNewAgentRoute(serverId: string, workingDir?: string | null) {
const trimmedWorkingDir = workingDir?.trim(); const trimmedWorkingDir = workingDir?.trim();
return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || "."); return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || ".");
} }
@@ -27,17 +27,17 @@ export function resolveNotificationTarget(data: NotificationData): {
}; };
} }
export function buildNotificationRoute(data: NotificationData): string { export function buildNotificationRoute(data: NotificationData) {
const { serverId, agentId, workspaceId } = resolveNotificationTarget(data); const { serverId, agentId, workspaceId } = resolveNotificationTarget(data);
if (serverId && agentId) { if (serverId && agentId) {
if (workspaceId) { if (workspaceId) {
const base = buildHostWorkspaceRoute(serverId, workspaceId); const base = buildHostWorkspaceRoute(serverId, workspaceId);
return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}`; return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}` as const;
} }
return buildHostAgentDetailRoute(serverId, agentId); return buildHostAgentDetailRoute(serverId, agentId);
} }
if (serverId) { if (serverId) {
return buildHostRootRoute(serverId); return buildHostRootRoute(serverId);
} }
return "/"; return "/" as const;
} }
@@ -52,7 +52,7 @@ export function buildWorkspaceArchiveRedirectRoute(input: {
serverId: string; serverId: string;
archivedWorkspaceId: string; archivedWorkspaceId: string;
workspaces: Iterable<WorkspaceDescriptor>; workspaces: Iterable<WorkspaceDescriptor>;
}): string { }) {
const redirectWorkspaceId = resolveWorkspaceArchiveRedirectWorkspaceId({ const redirectWorkspaceId = resolveWorkspaceArchiveRedirectWorkspaceId({
archivedWorkspaceId: input.archivedWorkspaceId, archivedWorkspaceId: input.archivedWorkspaceId,
workspaces: input.workspaces, workspaces: input.workspaces,
@@ -20,7 +20,7 @@ function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
return { kind: "draft", draftId: generateDraftId() }; return { kind: "draft", draftId: generateDraftId() };
} }
export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string { export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) {
const target = getPreparedTarget(input.target); const target = getPreparedTarget(input.target);
const key = const key =
buildWorkspaceTabPersistenceKey({ buildWorkspaceTabPersistenceKey({
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/cli", "name": "@getpaseo/cli",
"version": "0.1.50", "version": "0.1.51-rc.1",
"description": "Paseo CLI - control your AI coding agents from the command line", "description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module", "type": "module",
"files": [ "files": [
@@ -24,8 +24,8 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.0.0", "@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.50", "@getpaseo/relay": "0.1.51-rc.1",
"@getpaseo/server": "0.1.50", "@getpaseo/server": "0.1.51-rc.1",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"commander": "^12.0.0", "commander": "^12.0.0",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
+19 -15
View File
@@ -1,12 +1,14 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { execFileSync } from "node:child_process"; import { execFile } from "node:child_process";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { promisify } from "node:util";
import { import {
getOrCreateServerId, getOrCreateServerId,
findExecutable, findExecutable,
quoteWindowsCommand,
applyProviderEnv, applyProviderEnv,
} from "@getpaseo/server"; } from "@getpaseo/server";
const execFileAsync = promisify(execFile);
import { tryConnectToDaemon } from "../../utils/client.js"; import { tryConnectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
@@ -171,31 +173,33 @@ const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: "OpenCode", binary: "opencode" }, { label: "OpenCode", binary: "opencode" },
]; ];
function checkProviderBinary(binary: string): { path: string | null; version: string | null } { async function checkProviderBinary(binary: string): Promise<{ path: string | null; version: string | null }> {
const binaryPath = findExecutable(binary); const binaryPath = await findExecutable(binary);
if (!binaryPath) { if (!binaryPath) {
return { path: null, version: null }; return { path: null, version: null };
} }
const env = applyProviderEnv(process.env); const env = applyProviderEnv(process.env);
try { try {
const output = execFileSync(quoteWindowsCommand(binaryPath), ["--version"], { const { stdout } = await execFileAsync(binaryPath, ["--version"], {
encoding: "utf8", encoding: "utf8",
timeout: 5000, timeout: 5000,
stdio: ["ignore", "pipe", "pipe"],
env, env,
shell: process.platform === "win32", windowsHide: true,
}).trim(); });
return { path: binaryPath, version: output || null }; return { path: binaryPath, version: stdout.trim() || null };
} catch { } catch {
return { path: binaryPath, version: null }; return { path: binaryPath, version: null };
} }
} }
function checkProviderBinaries(): ProviderBinaryStatus[] { async function checkProviderBinaries(): Promise<ProviderBinaryStatus[]> {
return PROVIDER_BINARIES.map(({ label, binary }) => { const results = await Promise.all(
const result = checkProviderBinary(binary); PROVIDER_BINARIES.map(async ({ label, binary }) => {
return { label, ...result }; const result = await checkProviderBinary(binary);
}); return { label, ...result };
}),
);
return results;
} }
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null { function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
@@ -295,7 +299,7 @@ export async function runStatusCommand(
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`); note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`);
} }
const providers = checkProviderBinaries(); const providers = await checkProviderBinaries();
const daemonStatus: DaemonStatus = { const daemonStatus: DaemonStatus = {
serverId, serverId,
@@ -0,0 +1,72 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts";
interface E2EContext extends TestDaemonContext {
paseo: (
args: string[],
opts?: { timeout?: number; cwd?: string },
) => Promise<{
exitCode: number;
stdout: string;
stderr: string;
}>;
}
let ctx: E2EContext;
async function setup(): Promise<void> {
ctx = await createE2ETestContext({ timeout: 45_000 });
}
async function cleanup(): Promise<void> {
if (ctx) {
await ctx.stop();
}
}
async function test_invalid_opencode_model_does_not_report_completed_while_still_running() {
const result = await ctx.paseo(
["run", "--provider", "opencode/adklasldkdas", "hello"],
{ timeout: 45_000 },
);
const output = `${result.stdout}\n${result.stderr}`;
const agentId = output.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0];
if (result.exitCode !== 0) {
assert(
output.toLowerCase().includes("error") || output.toLowerCase().includes("failed"),
`expected invalid model failure output\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
return;
}
assert(agentId, `expected run output to include an agent id\nstdout:\n${result.stdout}`);
const inspect = await ctx.paseo(["inspect", agentId], { timeout: 15_000 });
assert.strictEqual(inspect.exitCode, 0, `inspect failed\nstdout:\n${inspect.stdout}\nstderr:\n${inspect.stderr}`);
const runReportedCompleted = result.stdout.includes("completed");
const inspectStillRunning = inspect.stdout.includes("Status running");
assert(
!(runReportedCompleted && inspectStillRunning),
`run reported completed while inspect still showed running\nrun stdout:\n${result.stdout}\ninspect stdout:\n${inspect.stdout}`,
);
}
async function main(): Promise<void> {
try {
await setup();
await test_invalid_opencode_model_does_not_report_completed_while_still_running();
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
await cleanup();
}
}
main();
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/desktop", "name": "@getpaseo/desktop",
"version": "0.1.50", "version": "0.1.51-rc.1",
"private": true, "private": true,
"description": "Paseo desktop app (Electron wrapper)", "description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js", "main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@getpaseo/cli": "0.1.50", "@getpaseo/cli": "0.1.51-rc.1",
"@getpaseo/server": "0.1.50", "@getpaseo/server": "0.1.51-rc.1",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
"electron-updater": "^6.6.2", "electron-updater": "^6.6.2",
"ws": "^8.14.2" "ws": "^8.14.2"
+7 -11
View File
@@ -1,9 +1,9 @@
import { spawn, type ChildProcess } from "node:child_process"; import { type ChildProcess } from "node:child_process";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { app, ipcMain } from "electron"; import { app, ipcMain } from "electron";
import log from "electron-log/main"; import log from "electron-log/main";
import { resolvePaseoHome } from "@getpaseo/server"; import { resolvePaseoHome, spawnProcess } from "@getpaseo/server";
import { import {
copyAttachmentFileToManagedStorage, copyAttachmentFileToManagedStorage,
deleteManagedAttachmentFile, deleteManagedAttachmentFile,
@@ -266,15 +266,11 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
args: invocation.args, args: invocation.args,
}); });
const child: ChildProcess = spawn( const child: ChildProcess = spawnProcess(invocation.command, invocation.args, {
invocation.command, detached: true,
invocation.args, env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
{ stdio: ["ignore", "ignore", "ignore"],
detached: true, });
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
stdio: ["ignore", "ignore", "ignore"],
},
);
logDesktopDaemonLifecycle("detached spawn returned", { logDesktopDaemonLifecycle("detached spawn returned", {
childPid: child.pid ?? null, childPid: child.pid ?? null,
+6 -4
View File
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process"; import { spawnSync } from "node:child_process";
import { spawnProcess } from "@getpaseo/server";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import path from "node:path"; import path from "node:path";
import { app } from "electron"; import { app } from "electron";
@@ -234,6 +235,7 @@ export function runCliPassthroughCommand(args: string[]): number {
const result = spawnSync(invocation.command, invocation.args, { const result = spawnSync(invocation.command, invocation.args, {
env: invocation.env, env: invocation.env,
stdio: "inherit", stdio: "inherit",
windowsHide: true,
}); });
if (result.error) { if (result.error) {
throw result.error; throw result.error;
@@ -252,7 +254,7 @@ function spawnAsync(
options: { env: NodeJS.ProcessEnv }, options: { env: NodeJS.ProcessEnv },
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn(command, args, { const child = spawnProcess(command, args, {
env: options.env, env: options.env,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
@@ -260,10 +262,10 @@ function spawnAsync(
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
child.stdout.on("data", (data: Buffer) => { child.stdout!.on("data", (data: Buffer) => {
stdout += data.toString(); stdout += data.toString();
}); });
child.stderr.on("data", (data: Buffer) => { child.stderr!.on("data", (data: Buffer) => {
stderr += data.toString(); stderr += data.toString();
}); });
@@ -200,7 +200,20 @@ export async function installCli(): Promise<InstallStatus> {
if (await pathOrSymlinkExists(targetPath)) { if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath); await fs.unlink(targetPath);
} }
await fs.copyFile(installSourcePath, targetPath); // Generate a thin .cmd trampoline that delegates to the bundled shim.
// Only the app install path is baked in — internal details (asar layout,
// entrypoint scripts) live in the bundled shim and update with the app.
const cmdContent = [
"@echo off",
`set "BUNDLED_CLI=${shimPath}"`,
`if not exist "%BUNDLED_CLI%" (`,
` echo Paseo CLI not found at %BUNDLED_CLI% — is Paseo installed? 1>&2`,
` exit /b 1`,
`)`,
`call "%BUNDLED_CLI%" %*`,
`exit /b %errorlevel%`,
].join("\r\n");
await fs.writeFile(targetPath, cmdContent, "utf-8");
} else { } else {
if (await pathOrSymlinkExists(targetPath)) { if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath); await fs.unlink(targetPath);
+14
View File
@@ -1,4 +1,5 @@
import log from "electron-log/main"; import log from "electron-log/main";
log.transports.console.level = "info";
log.initialize({ spyRendererConsole: true }); log.initialize({ spyRendererConsole: true });
import { inheritLoginShellEnv } from "./login-shell-env.js"; import { inheritLoginShellEnv } from "./login-shell-env.js";
@@ -37,6 +38,18 @@ const APP_SCHEME = "paseo";
const OPEN_PROJECT_EVENT = "paseo:event:open-project"; const OPEN_PROJECT_EVENT = "paseo:event:open-project";
app.setName("Paseo"); app.setName("Paseo");
// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11").
// Must run before app.whenReady().
const electronFlags = process.env.PASEO_ELECTRON_FLAGS?.trim();
if (electronFlags) {
for (const token of electronFlags.split(/\s+/)) {
const [key, ...rest] = token.replace(/^--/, "").split("=");
app.commandLine.appendSwitch(key, rest.join("=") || undefined);
}
log.info("[electron-flags]", electronFlags);
}
let pendingOpenProjectPath = parseOpenProjectPathFromArgv({ let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
argv: process.argv, argv: process.argv,
isDefaultApp: process.defaultApp, isDefaultApp: process.defaultApp,
@@ -134,6 +147,7 @@ async function createMainWindow(): Promise<void> {
setupWindowResizeEvents(mainWindow); setupWindowResizeEvents(mainWindow);
setupDefaultContextMenu(mainWindow); setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow); setupDragDropPrevention(mainWindow);
mainWindow.once("ready-to-show", () => { mainWindow.once("ready-to-show", () => {
mainWindow.show(); mainWindow.show();
}); });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/expo-two-way-audio", "name": "@getpaseo/expo-two-way-audio",
"version": "0.1.50", "version": "0.1.51-rc.1",
"description": "Native module for two way audio streaming", "description": "Native module for two way audio streaming",
"main": "build/index.js", "main": "build/index.js",
"types": "build/index.d.ts", "types": "build/index.d.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/highlight", "name": "@getpaseo/highlight",
"version": "0.1.50", "version": "0.1.51-rc.1",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/relay", "name": "@getpaseo/relay",
"version": "0.1.50", "version": "0.1.51-rc.1",
"description": "Paseo relay for bridging daemon and client connections", "description": "Paseo relay for bridging daemon and client connections",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/server", "name": "@getpaseo/server",
"version": "0.1.50", "version": "0.1.51-rc.1",
"description": "Paseo backend server", "description": "Paseo backend server",
"type": "module", "type": "module",
"publishConfig": { "publishConfig": {
@@ -64,8 +64,8 @@
"@ai-sdk/openai": "2.0.52", "@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11", "@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0", "@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.50", "@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/relay": "0.1.50", "@getpaseo/relay": "0.1.51-rc.1",
"@isaacs/ttlcache": "^2.1.4", "@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1", "@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6", "@opencode-ai/sdk": "1.2.6",
@@ -20,11 +20,11 @@ import {
query, query,
type SDKUserMessage, type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk"; } from "@anthropic-ai/claude-agent-sdk";
import { isCommandAvailable } from "../utils/executable.js"; import { isCommandAvailableSync } from "../utils/executable.js";
const hasClaudeCredentials = const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials; const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials;
// Pattern from claude-agent.ts listModels(): // Pattern from claude-agent.ts listModels():
// Use an empty async generator when you just need control methods // Use an empty async generator when you just need control methods
@@ -17,6 +17,7 @@ import type {
AgentMode, AgentMode,
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
AgentPermissionResult,
AgentPersistenceHandle, AgentPersistenceHandle,
AgentPromptInput, AgentPromptInput,
AgentProvider, AgentProvider,
@@ -1156,11 +1157,10 @@ export class AgentManager {
agent.lastError = undefined; agent.lastError = undefined;
const self = this; const self = this;
const pendingRun = self.createPendingForegroundRun();
self.pendingForegroundRuns.set(agentId, pendingRun);
const streamForwarder = (async function* streamForwarder() { const streamForwarder = (async function* streamForwarder() {
const pendingRun = self.createPendingForegroundRun();
self.pendingForegroundRuns.set(agentId, pendingRun);
let turnId: string; let turnId: string;
let waiter: ForegroundTurnWaiter | null = null; let waiter: ForegroundTurnWaiter | null = null;
try { try {
@@ -1440,12 +1440,12 @@ export class AgentManager {
agentId: string, agentId: string,
requestId: string, requestId: string,
response: AgentPermissionResponse, response: AgentPermissionResponse,
): Promise<void> { ): Promise<AgentPermissionResult | void> {
const agent = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
agent.inFlightPermissionResponses.add(requestId); agent.inFlightPermissionResponses.add(requestId);
try { try {
await agent.session.respondToPermission(requestId, response); const result = await agent.session.respondToPermission(requestId, response);
agent.pendingPermissions.delete(requestId); agent.pendingPermissions.delete(requestId);
try { try {
@@ -1463,6 +1463,8 @@ export class AgentManager {
agent.bufferedPermissionResolutions.delete(requestId); agent.bufferedPermissionResolutions.delete(requestId);
this.dispatchStream(agent.id, bufferedResolution); this.dispatchStream(agent.id, bufferedResolution);
} }
return result;
} finally { } finally {
agent.inFlightPermissionResponses.delete(requestId); agent.inFlightPermissionResponses.delete(requestId);
agent.bufferedPermissionResolutions.delete(requestId); agent.bufferedPermissionResolutions.delete(requestId);
@@ -1625,8 +1627,9 @@ export class AgentManager {
throw new Error(`Agent ${agentId} not found`); throw new Error(`Agent ${agentId} not found`);
} }
const pendingForegroundRun = this.getPendingForegroundRun(agentId);
const hasForegroundTurn = const hasForegroundTurn =
Boolean(snapshot.activeForegroundTurnId) || this.hasPendingForegroundRun(agentId); Boolean(snapshot.activeForegroundTurnId) || Boolean(pendingForegroundRun);
const immediatePermission = this.peekPendingPermission(snapshot); const immediatePermission = this.peekPendingPermission(snapshot);
if (immediatePermission) { if (immediatePermission) {
@@ -1668,7 +1671,10 @@ export class AgentManager {
} }
let currentStatus: AgentLifecycleStatus = initialStatus; let currentStatus: AgentLifecycleStatus = initialStatus;
let hasStarted = initialBusy || hasForegroundTurn; let hasStarted =
isAgentBusy(initialStatus) ||
Boolean(snapshot.activeForegroundTurnId) ||
Boolean(pendingForegroundRun?.started);
let terminalStatusOverride: AgentLifecycleStatus | null = null; let terminalStatusOverride: AgentLifecycleStatus | null = null;
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them // Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
@@ -2124,6 +2130,10 @@ export class AgentManager {
void this.refreshRuntimeInfo(agent); void this.refreshRuntimeInfo(agent);
} }
break; break;
case "usage_updated":
agent.lastUsage = event.usage;
this.emitState(agent);
break;
case "timeline": case "timeline":
// Skip provider-replayed user_message items during history hydration. // Skip provider-replayed user_message items during history hydration.
if (options?.fromHistory && event.item.type === "user_message") { if (options?.fromHistory && event.item.type === "user_message") {
@@ -301,6 +301,7 @@ export type AgentStreamEvent =
| { type: "thread_started"; sessionId: string; provider: AgentProvider } | { type: "thread_started"; sessionId: string; provider: AgentProvider }
| { type: "turn_started"; provider: AgentProvider; turnId?: string } | { type: "turn_started"; provider: AgentProvider; turnId?: string }
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string } | { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string }
| { type: "usage_updated"; provider: AgentProvider; usage: AgentUsage; turnId?: string }
| { | {
type: "turn_failed"; type: "turn_failed";
provider: AgentProvider; provider: AgentProvider;
@@ -440,6 +441,14 @@ export interface AgentLaunchContext {
env?: Record<string, string>; env?: Record<string, string>;
} }
/**
* Returned by respondToPermission when the permission resolution requires
* a follow-up turn (e.g. Codex plan approval → implementation).
*/
export interface AgentPermissionResult {
followUpPrompt?: AgentPromptInput;
}
export interface AgentSession { export interface AgentSession {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly id: string | null; readonly id: string | null;
@@ -454,7 +463,10 @@ export interface AgentSession {
getCurrentMode(): Promise<string | null>; getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>; setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[]; getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>; respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
describePersistence(): AgentPersistenceHandle | null; describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>; interrupt(): Promise<void>;
close(): Promise<void>; close(): Promise<void>;
@@ -7,19 +7,19 @@ import {
} from "./provider-launch-config.js"; } from "./provider-launch-config.js";
describe("resolveProviderCommandPrefix", () => { describe("resolveProviderCommandPrefix", () => {
test("uses resolved default command in default mode", () => { test("uses resolved default command in default mode", async () => {
const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
const resolved = resolveProviderCommandPrefix(undefined, resolveDefault); const resolved = await resolveProviderCommandPrefix(undefined, resolveDefault);
expect(resolveDefault).toHaveBeenCalledTimes(1); expect(resolveDefault).toHaveBeenCalledTimes(1);
expect(resolved).toEqual({ command: "/usr/local/bin/claude", args: [] }); expect(resolved).toEqual({ command: "/usr/local/bin/claude", args: [] });
}); });
test("appends args in append mode", () => { test("appends args in append mode", async () => {
const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
const resolved = resolveProviderCommandPrefix( const resolved = await resolveProviderCommandPrefix(
{ {
mode: "append", mode: "append",
args: ["--chrome"], args: ["--chrome"],
@@ -34,10 +34,10 @@ describe("resolveProviderCommandPrefix", () => {
}); });
}); });
test("replaces command in replace mode without resolving default", () => { test("replaces command in replace mode without resolving default", async () => {
const resolveDefault = vi.fn(() => "/usr/local/bin/claude"); const resolveDefault = vi.fn(() => "/usr/local/bin/claude");
const resolved = resolveProviderCommandPrefix( const resolved = await resolveProviderCommandPrefix(
{ {
mode: "replace", mode: "replace",
argv: ["docker", "run", "--rm", "my-wrapper"], argv: ["docker", "run", "--rm", "my-wrapper"],
@@ -53,20 +53,20 @@ export type ProviderCommandPrefix = {
args: string[]; args: string[];
}; };
export function resolveProviderCommandPrefix( export async function resolveProviderCommandPrefix(
commandConfig: ProviderCommand | undefined, commandConfig: ProviderCommand | undefined,
resolveDefaultCommand: () => string, resolveDefaultCommand: () => string | Promise<string>,
): ProviderCommandPrefix { ): Promise<ProviderCommandPrefix> {
if (!commandConfig || commandConfig.mode === "default") { if (!commandConfig || commandConfig.mode === "default") {
return { return {
command: resolveDefaultCommand(), command: await resolveDefaultCommand(),
args: [], args: [],
}; };
} }
if (commandConfig.mode === "append") { if (commandConfig.mode === "append") {
return { return {
command: resolveDefaultCommand(), command: await resolveDefaultCommand(),
args: [...(commandConfig.args ?? [])], args: [...(commandConfig.args ?? [])],
}; };
} }
@@ -102,12 +102,12 @@ export function applyProviderEnv(
return merged; return merged;
} }
export function isProviderCommandAvailable( export async function isProviderCommandAvailable(
commandConfig: ProviderCommand | undefined, commandConfig: ProviderCommand | undefined,
resolveDefaultCommand: () => string, resolveDefaultCommand: () => string | Promise<string>,
): boolean { ): Promise<boolean> {
try { try {
const prefix = resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand); const prefix = await resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand);
return isCommandAvailable(prefix.command); return isCommandAvailable(prefix.command);
} catch { } catch {
return false; return false;
@@ -17,7 +17,7 @@ import path from "node:path";
import pino from "pino"; import pino from "pino";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { isCommandAvailable } from "../../../../utils/executable.js"; import { isCommandAvailableSync } from "../../../../utils/executable.js";
import { ClaudeAgentClient } from "../claude-agent.js"; import { ClaudeAgentClient } from "../claude-agent.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -28,7 +28,7 @@ const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger }); const client = new ClaudeAgentClient({ logger });
const hasClaudeCredentials = const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
const canRun = isCommandAvailable("claude") && hasClaudeCredentials; const canRun = isCommandAvailableSync("claude") && hasClaudeCredentials;
function tmpCwd(prefix: string): string { function tmpCwd(prefix: string): string {
return mkdtempSync(path.join(tmpdir(), prefix)); return mkdtempSync(path.join(tmpdir(), prefix));
@@ -1,5 +1,4 @@
import { import {
spawn,
type ChildProcess, type ChildProcess,
type ChildProcessWithoutNullStreams, type ChildProcessWithoutNullStreams,
} from "node:child_process"; } from "node:child_process";
@@ -89,6 +88,7 @@ import {
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js"; import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = { const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
@@ -466,7 +466,7 @@ export class ACPAgentClient implements AgentClient {
async isAvailable(): Promise<boolean> { async isAvailable(): Promise<boolean> {
try { try {
this.resolveLaunchCommand(); await this.resolveLaunchCommand();
return true; return true;
} catch { } catch {
return false; return false;
@@ -476,16 +476,15 @@ export class ACPAgentClient implements AgentClient {
protected async spawnProcess( protected async spawnProcess(
launchEnv?: Record<string, string>, launchEnv?: Record<string, string>,
): Promise<SpawnedACPProcess> { ): Promise<SpawnedACPProcess> {
const { command, args } = this.resolveLaunchCommand(); const { command, args } = await this.resolveLaunchCommand();
const child = spawn(command, args, { const child = spawnProcess(command, args, {
cwd: process.cwd(), cwd: process.cwd(),
env: { env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings), ...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...(launchEnv ?? {}), ...(launchEnv ?? {}),
}, },
shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}); }) as ChildProcessWithoutNullStreams;
const stderrChunks: string[] = []; const stderrChunks: string[] = [];
child.stderr.on("data", (chunk: Buffer | string) => { child.stderr.on("data", (chunk: Buffer | string) => {
@@ -552,9 +551,9 @@ export class ACPAgentClient implements AgentClient {
} }
} }
protected resolveLaunchCommand(): { command: string; args: string[] } { protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> {
const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { const resolved = await findExecutable(this.defaultCommand[0]);
const resolved = findExecutable(this.defaultCommand[0]); const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => {
if (!resolved) { if (!resolved) {
throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`);
} }
@@ -1172,13 +1171,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> { async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> {
const terminalId = randomUUID(); const terminalId = randomUUID();
const env = Object.fromEntries((params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value])); const env = Object.fromEntries((params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]));
const child = spawn(params.command, params.args ?? [], { const child = spawnProcess(params.command, params.args ?? [], {
cwd: params.cwd ?? this.config.cwd, cwd: params.cwd ?? this.config.cwd,
env: { env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings), ...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...env, ...env,
}, },
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}); });
@@ -1201,8 +1199,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
rejectExit, rejectExit,
}; };
child.stdout.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString())); child.stdout!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
child.stderr.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString())); child.stderr!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
child.once("error", (error) => rejectExit(error instanceof Error ? error : new Error(String(error)))); child.once("error", (error) => rejectExit(error instanceof Error ? error : new Error(String(error))));
child.once("exit", (code, signal) => { child.once("exit", (code, signal) => {
const exit = { exitCode: code, signal }; const exit = { exitCode: code, signal };
@@ -1245,8 +1243,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
private async spawnProcess(): Promise<SpawnedACPProcess> { private async spawnProcess(): Promise<SpawnedACPProcess> {
const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => { const resolved = await findExecutable(this.defaultCommand[0]);
const resolved = findExecutable(this.defaultCommand[0]); const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => {
if (!resolved) { if (!resolved) {
throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`); throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`);
} }
@@ -1255,15 +1253,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const command = prefix.command; const command = prefix.command;
const args = [...prefix.args, ...this.defaultCommand.slice(1)]; const args = [...prefix.args, ...this.defaultCommand.slice(1)];
const child = spawn(command, args, { const child = spawnProcess(command, args, {
cwd: this.config.cwd, cwd: this.config.cwd,
env: { env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings), ...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...(this.launchEnv ?? {}), ...(this.launchEnv ?? {}),
}, },
shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}); }) as ChildProcessWithoutNullStreams;
const stderrChunks: string[] = []; const stderrChunks: string[] = [];
child.stderr.on("data", (chunk: Buffer | string) => { child.stderr.on("data", (chunk: Buffer | string) => {
@@ -2,13 +2,13 @@ import { describe, expect, test } from "vitest";
import pino from "pino"; import pino from "pino";
import type { AgentSlashCommand } from "../agent-sdk-types.js"; import type { AgentSlashCommand } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../../../utils/executable.js"; import { isCommandAvailableSync } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session. // Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
describe("claude agent commands contract (real)", () => { describe("claude agent commands contract (real)", () => {
test("lists slash commands with the expected contract", async () => { test("lists slash commands with the expected contract", async () => {
expect(isCommandAvailable("claude")).toBe(true); expect(isCommandAvailableSync("claude")).toBe(true);
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
@@ -6,7 +6,7 @@ import pino from "pino";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js"; import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../../../utils/executable.js"; import { isCommandAvailableSync } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -174,11 +174,11 @@ async function cleanupSession(handle: { cwd: string; session: AgentSession }): P
} }
describe("ClaudeAgentSession integration", () => { describe("ClaudeAgentSession integration", () => {
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials; const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials;
beforeAll(() => { beforeAll(() => {
if (canRunClaudeIntegration) { if (canRunClaudeIntegration) {
expect(isCommandAvailable("claude")).toBe(true); expect(isCommandAvailableSync("claude")).toBe(true);
} }
}); });
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import pino from "pino"; import pino from "pino";
import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js"; import type { AgentStreamEvent, AgentSession } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../../../utils/executable.js"; import { isCommandAvailableSync } from "../../../utils/executable.js";
import { ClaudeAgentClient } from "./claude-agent.js"; import { ClaudeAgentClient } from "./claude-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js"; import { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -29,7 +29,7 @@ async function collectUntilTerminal(session: AgentSession): Promise<AgentStreamE
} }
describe("Claude max effort availability (real)", () => { describe("Claude max effort availability (real)", () => {
test.runIf(isCommandAvailable("claude") && hasClaudeCredentials)( test.runIf(isCommandAvailableSync("claude") && hasClaudeCredentials)(
"surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable", "surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable",
async () => { async () => {
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
@@ -159,6 +159,79 @@ describe("convertClaudeHistoryEntry", () => {
expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]); expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
}); });
test("skips <local-command-stdout> messages (model switch, /context, etc.)", () => {
// Real entries from Claude Code JSONL history files
const modelSwitch = {
type: "user",
message: {
role: "user",
content:
"<local-command-stdout>Set model to claude-opus-4-6</local-command-stdout>",
},
userType: "external",
};
const modelSwitchWithAnsi = {
type: "user",
message: {
role: "user",
content:
'<local-command-stdout>Set model to \u001b[1mopus (claude-opus-4-6)\u001b[22m</local-command-stdout>',
},
};
const contextDump = {
type: "user",
message: {
role: "user",
content:
"<local-command-stdout>## Context Usage\n\n**Model:** claude-opus-4-6\n**Tokens:** 19k</local-command-stdout>",
},
};
const planMode = {
type: "user",
message: {
role: "user",
content: "<local-command-stdout>Enabled plan mode</local-command-stdout>",
},
};
const goodbye = {
type: "user",
message: {
role: "user",
content: "<local-command-stdout>Bye!</local-command-stdout>",
},
};
const empty = {
type: "user",
message: {
role: "user",
content: "<local-command-stdout></local-command-stdout>",
},
};
const mapBlocks = vi.fn().mockReturnValue([]);
expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
// Real user messages must NOT be filtered
const realMessage = {
type: "user",
message: { role: "user", content: "fix the bug in auth.ts" },
};
expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
{ type: "user_message", text: "fix the bug in auth.ts" },
]);
});
test("maps task notifications to synthetic tool calls", () => { test("maps task notifications to synthetic tool calls", () => {
const entry = { const entry = {
type: "system", type: "system",
@@ -468,6 +541,124 @@ describe("ClaudeAgentSession context window usage", () => {
}); });
}); });
test("task_progress emits a usage_updated event", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
type: "system",
subtype: "task_progress",
task_id: "task-1",
description: "Processing",
usage: {
total_tokens: 999,
tool_uses: 1,
duration_ms: 50,
},
uuid: "task-progress-1",
session_id: "session-1",
});
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 999,
},
});
});
test("task_notification emits a usage_updated event", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
type: "system",
subtype: "task_notification",
uuid: "task-note-1",
task_id: "task-1",
status: "running",
summary: "Background task still running",
usage: {
total_tokens: 777,
tool_uses: 1,
duration_ms: 50,
},
session_id: "session-1",
} as any);
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 777,
},
});
});
test("message_start stream events emit usage_updated with per-request usage", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
},
session_id: "session-1",
} as any);
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 150,
},
});
});
test("message_delta stream events update per-request usage", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
},
session_id: "session-1",
} as any);
const events = session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 25,
},
},
session_id: "session-1",
} as any);
expect(events).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 175,
},
});
});
test("task_progress usage takes priority over derived result usage", async () => { test("task_progress usage takes priority over derived result usage", async () => {
const session = await createSessionForTest(); const session = await createSessionForTest();
@@ -638,4 +829,134 @@ describe("ClaudeAgentSession context window usage", () => {
contextWindowUsedTokens: 22, contextWindowUsedTokens: 22,
}); });
}); });
test("convertUsage uses per-request stream usage when no task_progress is available", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
},
session_id: "session-1",
} as any);
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 25,
},
},
session_id: "session-1",
} as any);
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 175,
});
});
test("per-request stream usage is not cumulative across API calls in a turn", async () => {
const session = await createSessionForTest();
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 100,
cache_creation_input_tokens: 20,
cache_read_input_tokens: 30,
},
},
},
session_id: "session-1",
} as any);
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 25,
},
},
session_id: "session-1",
} as any);
const secondStartEvents = session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_start",
message: {
usage: {
input_tokens: 40,
cache_creation_input_tokens: 5,
cache_read_input_tokens: 10,
},
},
},
session_id: "session-1",
} as any);
expect(secondStartEvents).toContainEqual({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 55,
},
});
session.translateMessageToEvents({
type: "stream_event",
event: {
type: "message_delta",
usage: {
output_tokens: 7,
},
},
session_id: "session-1",
} as any);
const usage = session.convertUsage({
type: "result",
subtype: "success",
usage: {
input_tokens: 10,
cache_read_input_tokens: 5,
output_tokens: 7,
},
total_cost_usd: 0.12,
});
expect(usage).toEqual({
inputTokens: 10,
cachedInputTokens: 5,
outputTokens: 7,
totalCostUsd: 0.12,
contextWindowUsedTokens: 62,
});
});
}); });
@@ -1,4 +1,5 @@
import { execFileSync, spawn } from "node:child_process"; import { execFile, type ChildProcessWithoutNullStreams } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import { promises } from "node:fs"; import { promises } from "node:fs";
@@ -79,14 +80,12 @@ import {
applyProviderEnv, applyProviderEnv,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
import { import { findExecutable } from "../../../utils/executable.js";
findExecutable, import { spawnProcess } from "../../../utils/spawn.js";
quoteWindowsArgument,
quoteWindowsCommand,
} from "../../../utils/executable.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
const fsPromises = promises; const fsPromises = promises;
const execFileAsync = promisify(execFile);
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"]; const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
type TurnState = "idle" | "foreground" | "autonomous"; type TurnState = "idle" | "foreground" | "autonomous";
@@ -225,26 +224,21 @@ function applyRuntimeSettingsToClaudeOptions(
const isDefaultRuntime = const isDefaultRuntime =
resolved.command === "node" || resolved.command === "bun"; resolved.command === "node" || resolved.command === "bun";
const command = isDefaultRuntime ? process.execPath : resolved.command; const command = isDefaultRuntime ? process.execPath : resolved.command;
const child = spawn( const child = spawnProcess(command, resolved.args, {
quoteWindowsCommand(command),
resolved.args.map((argument) => quoteWindowsArgument(argument)),
{
cwd: spawnOptions.cwd, cwd: spawnOptions.cwd,
env: { env: {
...applyProviderEnv(spawnOptions.env, runtimeSettings), ...applyProviderEnv(spawnOptions.env, runtimeSettings),
...(launchEnv ?? {}), ...(launchEnv ?? {}),
}, },
shell: process.platform === "win32",
signal: spawnOptions.signal, signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
}, });
);
if (typeof options.stderr === "function") { if (typeof options.stderr === "function") {
child.stderr?.on("data", (chunk: Buffer | string) => { child.stderr?.on("data", (chunk: Buffer | string) => {
options.stderr?.(chunk.toString()); options.stderr?.(chunk.toString());
}); });
} }
return child; return child as ChildProcessWithoutNullStreams;
}, },
}; };
} }
@@ -418,8 +412,19 @@ function isClaudeNoResponsePlaceholderText(value: unknown): boolean {
return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER; return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER;
} }
const LOCAL_COMMAND_STDOUT_PATTERN = /^\s*<local-command-stdout>[\s\S]*<\/local-command-stdout>\s*$/;
function isClaudeLocalCommandStdout(value: unknown): boolean {
const normalized = normalizeClaudeTranscriptText(value);
return normalized !== null && LOCAL_COMMAND_STDOUT_PATTERN.test(normalized);
}
function isClaudeTranscriptNoiseText(value: unknown): boolean { function isClaudeTranscriptNoiseText(value: unknown): boolean {
return isClaudeInterruptPlaceholderText(value) || isClaudeNoResponsePlaceholderText(value); return (
isClaudeInterruptPlaceholderText(value) ||
isClaudeNoResponsePlaceholderText(value) ||
isClaudeLocalCommandStdout(value)
);
} }
function collectClaudeTextContentParts(content: unknown): string[] { function collectClaudeTextContentParts(content: unknown): string[] {
@@ -1134,9 +1139,9 @@ export class ClaudeAgentClient implements AgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
try { try {
const resolvedBinary = findExecutable("claude") ?? "not found"; const resolvedBinary = await findExecutable("claude") ?? "not found";
const available = await this.isAvailable(); const available = await this.isAvailable();
const version = resolveClaudeVersion(this.runtimeSettings); const version = await resolveClaudeVersion(this.runtimeSettings);
let modelsValue = "Not checked"; let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available); let status = formatDiagnosticStatus(available);
@@ -1176,26 +1181,30 @@ export class ClaudeAgentClient implements AgentClient {
} }
} }
function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string | null { async function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): Promise<string | null> {
const command = runtimeSettings?.command; const command = runtimeSettings?.command;
try { try {
if (command?.mode === "replace") { if (command?.mode === "replace") {
return execFileSync(command.argv[0]!, [...command.argv.slice(1), "--version"], { const { stdout } = await execFileAsync(command.argv[0]!, [...command.argv.slice(1), "--version"], {
encoding: "utf8", encoding: "utf8",
timeout: 5_000, timeout: 5_000,
}).trim() || null; windowsHide: true,
});
return stdout.trim() || null;
} }
const executable = findExecutable("claude"); const executable = await findExecutable("claude");
if (!executable) { if (!executable) {
return null; return null;
} }
return execFileSync(executable, ["--version"], { const { stdout } = await execFileAsync(executable, ["--version"], {
encoding: "utf8", encoding: "utf8",
timeout: 5_000, timeout: 5_000,
}).trim() || null; windowsHide: true,
});
return stdout.trim() || null;
} catch { } catch {
return null; return null;
} }
@@ -1225,16 +1234,67 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
return maxContextWindow; return maxContextWindow;
} }
function readContextWindowUsedTokensFromTaskProgress( function readUsageTotalTokens(
message: SDKTaskProgressMessage, usage: unknown,
): number | undefined { ): number | undefined {
const totalTokens = message.usage?.total_tokens; if (!usage || typeof usage !== "object") {
return undefined;
}
const totalTokens = (usage as { total_tokens?: unknown }).total_tokens;
if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) { if (typeof totalTokens !== "number" || !Number.isFinite(totalTokens) || totalTokens < 0) {
return undefined; return undefined;
} }
return totalTokens; return totalTokens;
} }
function readContextWindowUsedTokensFromTaskProgress(
message: SDKTaskProgressMessage,
): number | undefined {
return readUsageTotalTokens(message.usage);
}
function readUsageFromTaskNotification(message: { usage?: unknown }): number | undefined {
return readUsageTotalTokens(message.usage);
}
function readStreamRequestInputTokens(event: Record<string, unknown>): number | undefined {
const messageUsage = (event.message as { usage?: unknown } | undefined)?.usage;
if (!messageUsage || typeof messageUsage !== "object") {
return undefined;
}
const usage = messageUsage as {
input_tokens?: unknown;
cache_creation_input_tokens?: unknown;
cache_read_input_tokens?: unknown;
};
const inputTokens =
typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens)
? usage.input_tokens
: undefined;
const cacheCreationInputTokens =
typeof usage.cache_creation_input_tokens === "number" &&
Number.isFinite(usage.cache_creation_input_tokens)
? usage.cache_creation_input_tokens
: 0;
const cacheReadInputTokens =
typeof usage.cache_read_input_tokens === "number" &&
Number.isFinite(usage.cache_read_input_tokens)
? usage.cache_read_input_tokens
: 0;
if (typeof inputTokens !== "number" || inputTokens < 0) {
return undefined;
}
return inputTokens + cacheCreationInputTokens + cacheReadInputTokens;
}
function readStreamRequestOutputTokens(event: Record<string, unknown>): number | undefined {
const outputTokens = (event.usage as { output_tokens?: unknown } | undefined)?.output_tokens;
if (typeof outputTokens !== "number" || !Number.isFinite(outputTokens) || outputTokens < 0) {
return undefined;
}
return outputTokens;
}
class ClaudeAgentSession implements AgentSession { class ClaudeAgentSession implements AgentSession {
readonly provider: "claude" = "claude"; readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES; readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1278,6 +1338,9 @@ class ClaudeAgentSession implements AgentSession {
private lastForegroundPromptText: string | null = null; private lastForegroundPromptText: string | null = null;
private foregroundHasVisibleActivity = false; private foregroundHasVisibleActivity = false;
private lastContextWindowUsedTokens: number | undefined; private lastContextWindowUsedTokens: number | undefined;
private lastContextWindowMaxTokens: number | undefined;
private lastStreamRequestInputTokens: number | undefined;
private lastStreamRequestOutputTokens: number | undefined;
private userMessageIds: string[] = []; private userMessageIds: string[] = [];
private recentStderr = ""; private recentStderr = "";
private closed = false; private closed = false;
@@ -1963,7 +2026,7 @@ class ClaudeAgentSession implements AgentSession {
this.persistence = null; this.persistence = null;
const input = createAsyncMessageInput<SDKUserMessage>(); const input = createAsyncMessageInput<SDKUserMessage>();
const options = this.buildOptions(); const options = await this.buildOptions();
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input; this.input = input;
this.query = this.queryFactory({ prompt: input.iterable, options }); this.query = this.queryFactory({ prompt: input.iterable, options });
@@ -2000,7 +2063,7 @@ class ClaudeAgentSession implements AgentSession {
} }
} }
private buildOptions(): ClaudeOptions { private async buildOptions(): Promise<ClaudeOptions> {
const thinkingOptionId = const thinkingOptionId =
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default" this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
? this.config.thinkingOptionId ? this.config.thinkingOptionId
@@ -2019,7 +2082,7 @@ class ClaudeAgentSession implements AgentSession {
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) .filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.join("\n\n"); .join("\n\n");
const claudeBinary = findExecutable("claude"); const claudeBinary = await findExecutable("claude");
this.logger.debug( this.logger.debug(
{ {
claudeBinary, claudeBinary,
@@ -2672,10 +2735,18 @@ class ClaudeAgentSession implements AgentSession {
provider: "claude", provider: "claude",
}); });
} }
const usage = readUsageFromTaskNotification(message);
if (typeof usage === "number") {
this.lastContextWindowUsedTokens = usage;
events.push(this.createUsageUpdatedEvent(usage));
}
} else if (message.subtype === "task_progress") { } else if (message.subtype === "task_progress") {
this.lastContextWindowUsedTokens = this.lastContextWindowUsedTokens =
readContextWindowUsedTokensFromTaskProgress(message) ?? readContextWindowUsedTokensFromTaskProgress(message) ??
this.lastContextWindowUsedTokens; this.lastContextWindowUsedTokens;
if (typeof this.lastContextWindowUsedTokens === "number") {
events.push(this.createUsageUpdatedEvent(this.lastContextWindowUsedTokens));
}
} }
break; break;
case "user": { case "user": {
@@ -2743,6 +2814,10 @@ class ClaudeAgentSession implements AgentSession {
break; break;
} }
case "stream_event": { case "stream_event": {
const usageUpdatedEvent = this.trackStreamEventUsage(message.event);
if (usageUpdatedEvent) {
events.push(usageUpdatedEvent);
}
const timelineItems = this.mapPartialEvent(message.event, { const timelineItems = this.mapPartialEvent(message.event, {
suppressAssistantText: options?.suppressAssistantText ?? false, suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false, suppressReasoning: options?.suppressReasoning ?? false,
@@ -2911,12 +2986,21 @@ class ClaudeAgentSession implements AgentSession {
modelUsage ?? message.modelUsage, modelUsage ?? message.modelUsage,
); );
if (contextWindowMaxTokens !== undefined) { if (contextWindowMaxTokens !== undefined) {
this.lastContextWindowMaxTokens = contextWindowMaxTokens;
usage.contextWindowMaxTokens = contextWindowMaxTokens; usage.contextWindowMaxTokens = contextWindowMaxTokens;
} else if (this.lastContextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.lastContextWindowMaxTokens;
} }
if (typeof this.lastContextWindowUsedTokens === "number") { if (typeof this.lastContextWindowUsedTokens === "number") {
// task_progress.total_tokens is the accurate context window fill level. // task_progress.total_tokens is the accurate context window fill level.
// Prefer it over result.usage which contains accumulated session totals. // Prefer it over result.usage which contains accumulated session totals.
usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens; usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens;
} else if (
typeof this.lastStreamRequestInputTokens === "number" &&
typeof this.lastStreamRequestOutputTokens === "number"
) {
usage.contextWindowUsedTokens =
this.lastStreamRequestInputTokens + this.lastStreamRequestOutputTokens;
} else if (message.usage) { } else if (message.usage) {
// Fallback: derive from result.usage when no task_progress has been // Fallback: derive from result.usage when no task_progress has been
// received yet. These values are accumulated across all API calls, but // received yet. These values are accumulated across all API calls, but
@@ -2937,6 +3021,54 @@ class ClaudeAgentSession implements AgentSession {
return usage; return usage;
} }
private createUsageUpdatedEvent(contextWindowUsedTokens: number): AgentStreamEvent {
const usage: AgentUsage = {
contextWindowUsedTokens,
};
if (this.lastContextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.lastContextWindowMaxTokens;
}
return {
type: "usage_updated",
provider: "claude",
usage,
};
}
private trackStreamEventUsage(event: unknown): AgentStreamEvent | null {
if (!event || typeof event !== "object") {
return null;
}
const streamEvent = event as Record<string, unknown>;
const eventType = readTrimmedString(streamEvent.type);
if (eventType === "message_start") {
const inputTokens = readStreamRequestInputTokens(streamEvent);
if (typeof inputTokens !== "number") {
return null;
}
this.lastStreamRequestInputTokens = inputTokens;
this.lastStreamRequestOutputTokens = 0;
} else if (eventType === "message_delta") {
const outputTokens = readStreamRequestOutputTokens(streamEvent);
if (typeof outputTokens !== "number") {
return null;
}
this.lastStreamRequestOutputTokens = outputTokens;
} else {
return null;
}
if (
typeof this.lastStreamRequestInputTokens !== "number" ||
typeof this.lastStreamRequestOutputTokens !== "number"
) {
return null;
}
return this.createUsageUpdatedEvent(
this.lastStreamRequestInputTokens + this.lastStreamRequestOutputTokens,
);
}
private handlePermissionRequest: CanUseTool = async ( private handlePermissionRequest: CanUseTool = async (
toolName, toolName,
input, input,
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { beforeAll, describe, expect, test } from "vitest"; import { beforeAll, describe, expect, test } from "vitest";
import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { findExecutable, isCommandAvailableSync } from "../../../utils/executable.js";
class Pushable<T> implements AsyncIterable<T> { class Pushable<T> implements AsyncIterable<T> {
private queue: T[] = []; private queue: T[] = [];
@@ -57,18 +57,18 @@ const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
describe("Claude SDK direct behavior", () => { describe("Claude SDK direct behavior", () => {
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials; const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials;
beforeAll(() => { beforeAll(() => {
if (canRunClaudeIntegration) { if (canRunClaudeIntegration) {
expect(isCommandAvailable("claude")).toBe(true); expect(isCommandAvailableSync("claude")).toBe(true);
} }
}); });
test.runIf(canRunClaudeIntegration)("shows what happens after interrupt()", async () => { test.runIf(canRunClaudeIntegration)("shows what happens after interrupt()", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const input = new Pushable<SDKUserMessage>(); const input = new Pushable<SDKUserMessage>();
const claudeBinary = findExecutable("claude"); const claudeBinary = await findExecutable("claude");
// Use same options as claude-agent.ts // Use same options as claude-agent.ts
const q = query({ const q = query({
@@ -577,17 +577,59 @@ describe("Codex app-server provider", () => {
}); });
}); });
test("approving a synthetic Codex plan permission disables plan and fast mode and starts implementation", async () => { test("emits usage_updated on token usage updates and keeps usage on turn completion", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
(session as any).handleNotification("thread/tokenUsage/updated", {
tokenUsage: {
model_context_window: 200000,
last: {
total_tokens: 50000,
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
},
},
});
(session as any).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(events).toContainEqual({
type: "usage_updated",
provider: "codex",
turnId: "test-turn",
usage: {
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
},
});
expect(events.at(-1)).toEqual({
type: "turn_completed",
provider: "codex",
turnId: "test-turn",
usage: {
inputTokens: 30000,
cachedInputTokens: 5000,
outputTokens: 15000,
contextWindowMaxTokens: 200000,
contextWindowUsedTokens: 50000,
},
});
});
test("approving a synthetic Codex plan permission disables plan and fast mode and returns follow-up prompt", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: true }, featureValues: { plan_mode: true, fast_mode: true },
}); });
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event)); session.subscribe((event) => events.push(event));
const startTurnSpy = vi
.spyOn(session, "startTurn")
.mockResolvedValue({ turnId: "follow-up-turn" });
(session as any).handleNotification("turn/started", { (session as any).handleNotification("turn/started", {
turn: { id: "turn-plan-2" }, turn: { id: "turn-plan-2" },
}); });
@@ -607,7 +649,7 @@ describe("Codex app-server provider", () => {
throw new Error("Expected synthetic plan approval permission"); throw new Error("Expected synthetic plan approval permission");
} }
await session.respondToPermission(request.request.id, { const result = await session.respondToPermission(request.request.id, {
behavior: "allow", behavior: "allow",
selectedActionId: "implement", selectedActionId: "implement",
}); });
@@ -618,7 +660,10 @@ describe("Codex app-server provider", () => {
plan_mode: false, plan_mode: false,
fast_mode: false, fast_mode: false,
}); });
expect(startTurnSpy).toHaveBeenCalledWith( // The session returns the follow-up prompt instead of calling startTurn directly.
// The caller (session/agent-manager) is responsible for sending it through streamAgent.
expect(result).toBeDefined();
expect(result!.followUpPrompt).toEqual(
expect.stringContaining("The user approved the plan. Implement it now."), expect.stringContaining("The user approved the plan. Implement it now."),
); );
expect(events.at(-1)).toEqual({ expect(events.at(-1)).toEqual({
@@ -631,56 +676,4 @@ describe("Codex app-server provider", () => {
}, },
}); });
}); });
test("failed synthetic Codex plan implementation keeps the permission pending for retry", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const startTurnSpy = vi
.spyOn(session, "startTurn")
.mockRejectedValueOnce(new Error("follow-up failed"));
(session as any).handleNotification("turn/started", {
turn: { id: "turn-plan-retry" },
});
(session as any).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the retriable flow", status: "pending" }],
});
(session as any).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const request = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
);
expect(request).toBeDefined();
if (!request) {
throw new Error("Expected synthetic plan approval permission");
}
await expect(
session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
}),
).rejects.toThrow("follow-up failed");
expect(startTurnSpy).toHaveBeenCalledTimes(1);
expect((session as any).planModeEnabled).toBe(true);
expect((session as any).config.featureValues).toEqual({
plan_mode: true,
fast_mode: true,
});
expect(session.getPendingPermissions()).toEqual([request.request]);
expect(
events.some(
(event) =>
event.type === "permission_resolved" && event.requestId === request.request.id,
),
).toBe(false);
});
}); });
@@ -9,6 +9,7 @@ import type {
McpServerConfig, McpServerConfig,
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
AgentPermissionResult,
AgentPromptContentBlock, AgentPromptContentBlock,
AgentPromptInput, AgentPromptInput,
AgentRunOptions, AgentRunOptions,
@@ -27,7 +28,7 @@ import type {
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { execSync, spawn } from "node:child_process"; import { execSync } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync, Dirent } from "node:fs"; import { existsSync, Dirent } from "node:fs";
@@ -46,11 +47,8 @@ import {
resolveProviderCommandPrefix, resolveProviderCommandPrefix,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
import { import { findExecutable } from "../../../utils/executable.js";
findExecutable, import { spawnProcess } from "../../../utils/spawn.js";
quoteWindowsArgument,
quoteWindowsCommand,
} from "../../../utils/executable.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
import { import {
@@ -228,8 +226,8 @@ function mergeCodexConfiguredDefaults(
}; };
} }
function resolveCodexBinary(): string { async function resolveCodexBinary(): Promise<string> {
const found = findExecutable("codex"); const found = await findExecutable("codex");
if (found) { if (found) {
return found; return found;
} }
@@ -238,10 +236,10 @@ function resolveCodexBinary(): string {
); );
} }
function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): { async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{
command: string; command: string;
args: string[]; args: string[];
} { }> {
return resolveProviderCommandPrefix(runtimeSettings?.command, resolveCodexBinary); return resolveProviderCommandPrefix(runtimeSettings?.command, resolveCodexBinary);
} }
@@ -2457,7 +2455,7 @@ class CodexAppServerAgentSession implements AgentSession {
config: AgentSessionConfig, config: AgentSessionConfig,
private readonly resumeHandle: { sessionId: string; metadata?: Record<string, unknown> } | null, private readonly resumeHandle: { sessionId: string; metadata?: Record<string, unknown> } | null,
logger: Logger, logger: Logger,
private readonly spawnAppServer: () => ChildProcessWithoutNullStreams, private readonly spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>,
) { ) {
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER }); this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
if (config.modeId === undefined) { if (config.modeId === undefined) {
@@ -2495,7 +2493,7 @@ class CodexAppServerAgentSession implements AgentSession {
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.connected) return; if (this.connected) return;
const child = this.spawnAppServer(); const child = await this.spawnAppServer();
this.client = new CodexAppServerClient(child, this.logger); this.client = new CodexAppServerClient(child, this.logger);
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params)); this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers(); this.registerRequestHandlers();
@@ -2676,22 +2674,19 @@ class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
} }
private async handleApprovedPlanPermission(params: { planText?: unknown }): Promise<void> { /**
* Prepare the session for plan implementation by disabling plan/fast mode
* and returning the implementation prompt. The caller is responsible for
* starting the turn through the normal streamAgent path.
*/
private preparePlanImplementation(params: { planText?: unknown }): string {
const planText = const planText =
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : ""; typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
const previousPlanMode = this.planModeEnabled;
const previousFastMode = this.serviceTier === "fast";
this.applyFeatureValue("plan_mode", false); this.applyFeatureValue("plan_mode", false);
this.applyFeatureValue("fast_mode", false); this.applyFeatureValue("fast_mode", false);
try { return buildCodexPlanImplementationPrompt(planText);
await this.startTurn(buildCodexPlanImplementationPrompt(planText));
} catch (error) {
this.applyFeatureValue("plan_mode", previousPlanMode);
this.applyFeatureValue("fast_mode", previousFastMode);
throw error;
}
} }
private registerRequestHandlers(): void { private registerRequestHandlers(): void {
@@ -3105,7 +3100,10 @@ class CodexAppServerAgentSession implements AgentSession {
return Array.from(this.pendingPermissions.values()); return Array.from(this.pendingPermissions.values());
} }
async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void> { async respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
const pending = this.pendingPermissionHandlers.get(requestId); const pending = this.pendingPermissionHandlers.get(requestId);
if (!pending) { if (!pending) {
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`); throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
@@ -3113,8 +3111,9 @@ class CodexAppServerAgentSession implements AgentSession {
const pendingRequest = this.pendingPermissions.get(requestId) ?? null; const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
if (pending.kind === "plan") { if (pending.kind === "plan") {
let followUpPrompt: string | undefined;
if (response.behavior === "allow") { if (response.behavior === "allow") {
await this.handleApprovedPlanPermission({ followUpPrompt = this.preparePlanImplementation({
planText: pending.planText ?? pendingRequest?.metadata?.planText, planText: pending.planText ?? pendingRequest?.metadata?.planText,
}); });
} }
@@ -3128,6 +3127,9 @@ class CodexAppServerAgentSession implements AgentSession {
requestId, requestId,
resolution: response, resolution: response,
}); });
if (followUpPrompt) {
return { followUpPrompt };
}
return; return;
} }
@@ -3506,6 +3508,13 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.kind === "token_usage_updated") { if (parsed.kind === "token_usage_updated") {
this.latestUsage = toAgentUsage(parsed.tokenUsage); this.latestUsage = toAgentUsage(parsed.tokenUsage);
if (this.latestUsage) {
this.notifySubscribers({
type: "usage_updated",
provider: CODEX_PROVIDER,
usage: this.latestUsage,
});
}
return; return;
} }
@@ -3990,24 +3999,19 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly runtimeSettings?: ProviderRuntimeSettings, private readonly runtimeSettings?: ProviderRuntimeSettings,
) {} ) {}
private spawnAppServer(launchEnv?: Record<string, string>): ChildProcessWithoutNullStreams { private async spawnAppServer(launchEnv?: Record<string, string>): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings); const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
this.logger.trace( this.logger.trace(
{ {
launchPrefix, launchPrefix,
}, },
"Spawning Codex app server", "Spawning Codex app server",
); );
return spawn( return spawnProcess(launchPrefix.command, [...launchPrefix.args, "app-server"], {
quoteWindowsCommand(launchPrefix.command), detached: process.platform !== "win32",
[...launchPrefix.args, "app-server"].map((argument) => quoteWindowsArgument(argument)), stdio: ["pipe", "pipe", "pipe"],
{ env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
detached: process.platform !== "win32", }) as ChildProcessWithoutNullStreams;
shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"],
env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
},
);
} }
async createSession( async createSession(
@@ -4044,7 +4048,7 @@ export class CodexAppServerAgentClient implements AgentClient {
async listPersistedAgents( async listPersistedAgents(
options?: ListPersistedAgentsOptions, options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> { ): Promise<PersistedAgentDescriptor[]> {
const child = this.spawnAppServer(); const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger); const client = new CodexAppServerClient(child, this.logger);
try { try {
@@ -4114,7 +4118,7 @@ export class CodexAppServerAgentClient implements AgentClient {
} }
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> { async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const child = this.spawnAppServer(); const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger); const client = new CodexAppServerClient(child, this.logger);
try { try {
@@ -4206,13 +4210,13 @@ export class CodexAppServerAgentClient implements AgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
try { try {
const available = await this.isAvailable(); const available = await this.isAvailable();
const resolvedBinary = findExecutable("codex"); const resolvedBinary = await findExecutable("codex");
const entries: Array<{ label: string; value: string }> = [ const entries: Array<{ label: string; value: string }> = [
{ {
label: "Binary", label: "Binary",
value: resolvedBinary ?? "not found", value: resolvedBinary ?? "not found",
}, },
{ label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, { label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
]; ];
let status = formatDiagnosticStatus(available); let status = formatDiagnosticStatus(available);
@@ -63,7 +63,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
try { try {
const available = await this.isAvailable(); const available = await this.isAvailable();
const resolvedBinary = findExecutable("copilot"); const resolvedBinary = await findExecutable("copilot");
let modelsValue = "Not checked"; let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available); let status = formatDiagnosticStatus(available);
@@ -97,7 +97,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
label: "Binary", label: "Binary",
value: resolvedBinary ?? "not found", value: resolvedBinary ?? "not found",
}, },
{ label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, { label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
{ label: "Models", value: modelsValue }, { label: "Models", value: modelsValue },
{ label: "Status", value: status }, { label: "Status", value: status },
]), ]),
@@ -1,7 +1,10 @@
import { execFileSync } from "node:child_process"; import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
const execFileAsync = promisify(execFile);
type DiagnosticEntry = { type DiagnosticEntry = {
label: string; label: string;
value: string; value: string;
@@ -50,14 +53,14 @@ export function toDiagnosticErrorMessage(error: unknown): string {
return "Unknown error"; return "Unknown error";
} }
export function resolveBinaryVersion(binaryPath: string): string { export async function resolveBinaryVersion(binaryPath: string): Promise<string> {
try { try {
return ( const { stdout } = await execFileAsync(binaryPath, ["--version"], {
execFileSync(binaryPath, ["--version"], { encoding: "utf8",
encoding: "utf8", timeout: 5_000,
timeout: 5_000, windowsHide: true,
}).trim() || "unknown" });
); return stdout.trim() || "unknown";
} catch { } catch {
return "unknown"; return "unknown";
} }
@@ -2,12 +2,12 @@ import { describe, expect, test } from "vitest";
import pino from "pino"; import pino from "pino";
import type { AgentSlashCommand } from "../agent-sdk-types.js"; import type { AgentSlashCommand } from "../agent-sdk-types.js";
import { isCommandAvailable } from "../../../utils/executable.js"; import { isCommandAvailableSync } from "../../../utils/executable.js";
import { OpenCodeAgentClient } from "./opencode-agent.js"; import { OpenCodeAgentClient } from "./opencode-agent.js";
describe("opencode agent commands contract (real)", () => { describe("opencode agent commands contract (real)", () => {
test("lists slash commands with the expected contract", async () => { test("lists slash commands with the expected contract", async () => {
expect(isCommandAvailable("opencode")).toBe(true); expect(isCommandAvailableSync("opencode")).toBe(true);
const client = new OpenCodeAgentClient(pino({ level: "silent" })); const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({ const session = await client.createSession({
@@ -37,13 +37,13 @@ describe("opencode agent commands contract (real)", () => {
}, 60_000); }, 60_000);
test("executes a slash command without arguments", async () => { test("executes a slash command without arguments", async () => {
expect(isCommandAvailable("opencode")).toBe(true); expect(isCommandAvailableSync("opencode")).toBe(true);
const client = new OpenCodeAgentClient(pino({ level: "silent" })); const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: process.cwd(), cwd: process.cwd(),
modeId: "default", modeId: "plan",
}); });
try { try {
@@ -58,14 +58,22 @@ describe("opencode agent commands contract (real)", () => {
const { turnId } = await session.startTurn(`/${command.name}`); const { turnId } = await session.startTurn(`/${command.name}`);
expect(turnId).toBeTruthy(); expect(turnId).toBeTruthy();
// Wait for the turn to complete or fail. // Wait for any terminal event OR a short timeout.
// Some commands trigger full AI turns that may hang waiting for tool
// permissions. The test only needs to verify the invocation path
// doesn't crash with type errors on the `arguments` field.
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
unsub();
resolve();
}, 15_000);
const unsub = session.subscribe((event) => { const unsub = session.subscribe((event) => {
if ( if (
event.type === "turn_completed" || event.type === "turn_completed" ||
event.type === "turn_failed" || event.type === "turn_failed" ||
event.type === "turn_canceled" event.type === "turn_canceled"
) { ) {
clearTimeout(timeout);
unsub(); unsub();
resolve(); resolve();
} }
@@ -0,0 +1,188 @@
import { describe, expect, test } from "vitest";
import pino from "pino";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { isCommandAvailableSync } from "../../../utils/executable.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
function isTerminalEvent(event: AgentStreamEvent): boolean {
return (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
);
}
/**
* Real e2e tests for OpenCode error handling.
*
* Validates that OpenCode surfaces errors properly instead of hanging forever
* when models are invalid, auth fails, or provider API calls fail.
*/
describe("opencode agent error handling (real)", () => {
test.runIf(isCommandAvailableSync("opencode"))(
"surfaces error for the exact opencode/<bad-model> path used by paseo run",
async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
try {
await session.setModel("opencode/adklasldkdas");
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, "hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
} finally {
await session.close().catch(() => undefined);
}
},
45_000,
);
test.runIf(isCommandAvailableSync("opencode"))(
"surfaces error for unknown provider model (fast path)",
async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
try {
await session.setModel("bogus-provider/totally-fake-model-12345");
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, "Say hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
} finally {
await session.close().catch(() => undefined);
}
},
30_000,
);
test.runIf(isCommandAvailableSync("opencode"))(
"sequential sessions: second session works after first errors",
async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
// Session 1: bogus model, will error quickly
const s1 = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
await s1.setModel("bogus-provider/fake-model-12345");
for await (const event of streamSession(s1, "Say hello")) {
if (isTerminalEvent(event)) break;
}
await s1.close();
// Session 2: different bogus model, should also work
const s2 = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
await s2.setModel("bogus-provider/fake-model-67890");
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(s2, "Say hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
await s2.close().catch(() => undefined);
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
},
30_000,
);
test.runIf(isCommandAvailableSync("opencode"))(
"surfaces error for known provider with nonexistent model (retry path)",
async () => {
// When the provider is recognized (anthropic) but the model doesn't exist,
// OpenCode retries before surfacing the error. This must not hang.
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
try {
await session.setModel("anthropic/claude-nonexistent-99");
const events: AgentStreamEvent[] = [];
const start = Date.now();
for await (const event of streamSession(session, "Say hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
const elapsed = Date.now() - start;
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(elapsed).toBeLessThan(30_000);
console.log(
`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`,
);
} finally {
await session.close().catch(() => undefined);
}
},
45_000,
);
test.runIf(isCommandAvailableSync("opencode"))(
"surfaces fatal retry status from zai/glm-5.1 instead of hanging forever",
async () => {
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "build",
});
try {
await session.setModel("zai/glm-5.1");
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, "Say hello")) {
events.push(event);
if (isTerminalEvent(event)) break;
}
const terminal = events.find(isTerminalEvent);
expect(terminal).toBeDefined();
expect(terminal!.type).toBe("turn_failed");
expect(
(terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase(),
).toMatch(/insufficient balance|resource package|recharge/);
} finally {
await session.close().catch(() => undefined);
}
},
45_000,
);
});
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: vi.fn(),
}));
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
describe("OpenCodeAgentClient.listModels timeout", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
vi.useFakeTimers();
const providerList = vi.fn(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
data: {
connected: ["zai"],
all: [
{
id: "zai",
name: "Z.AI",
models: {
"glm-5.1": {
name: "GLM 5.1",
limit: { context: 128_000 },
},
},
},
],
},
});
}, 15_000);
}),
);
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
list: providerList,
},
} as never);
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }),
} as never);
const client = new OpenCodeAgentClient(createTestLogger());
const modelsPromise = client.listModels();
await vi.advanceTimersByTimeAsync(15_000);
await expect(modelsPromise).resolves.toMatchObject([
{
provider: "opencode",
id: "zai/glm-5.1",
label: "GLM 5.1",
},
]);
});
});
@@ -12,6 +12,7 @@ import path from "node:path";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { Event as OpenCodeEvent } from "@opencode-ai/sdk/v2/client";
import { import {
__openCodeInternals, __openCodeInternals,
OpenCodeAgentClient, OpenCodeAgentClient,
@@ -116,15 +117,15 @@ const hasOpenCode = isBinaryInstalled("opencode");
"beforeAll: Retrieved models", "beforeAll: Retrieved models",
); );
// Prefer fast models for tests - nano models are typically fastest // Prefer cheap models that support tool use (required by OpenCode agents).
// Avoid free-tier OpenRouter models — they often lack tool-use support.
const fastModel = models.find( const fastModel = models.find(
(m) => (m) =>
m.id.includes("gpt-4.1-nano") || m.id.includes("gpt-4.1-nano") ||
m.id.includes("gpt-4.1-mini") ||
m.id.includes("gpt-5-nano") || m.id.includes("gpt-5-nano") ||
m.id.includes("gpt-5.1-codex-mini") || m.id.includes("gpt-5.1-codex-mini") ||
m.id.includes("gpt-4o-mini") || m.id.includes("gpt-4o-mini"),
m.id.includes("gpt-3.5") ||
m.id.includes("free"),
); );
if (fastModel) { if (fastModel) {
@@ -442,7 +443,7 @@ describe("OpenCode adapter context-window normalization", () => {
modelID: "gpt-5", modelID: "gpt-5",
}, },
}, },
}, } as OpenCodeEvent,
{ {
sessionId: "session-1", sessionId: "session-1",
messageRoles: new Map(), messageRoles: new Map(),
@@ -1,8 +1,11 @@
import { spawn, type ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { import {
createOpencodeClient, createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent,
type OpencodeClient, type OpencodeClient,
type Part as OpenCodePart,
} from "@opencode-ai/sdk/v2/client"; } from "@opencode-ai/sdk/v2/client";
import net from "node:net"; import net from "node:net";
import type { Logger } from "pino"; import type { Logger } from "pino";
@@ -12,7 +15,6 @@ import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentClient, AgentClient,
AgentLaunchContext, AgentLaunchContext,
AgentMetadata,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentPermissionRequest, AgentPermissionRequest,
@@ -40,11 +42,8 @@ import {
resolveProviderCommandPrefix, resolveProviderCommandPrefix,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
import { import { findExecutable } from "../../../utils/executable.js";
findExecutable, import { spawnProcess } from "../../../utils/spawn.js";
quoteWindowsArgument,
quoteWindowsCommand,
} from "../../../utils/executable.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import { import {
formatDiagnosticStatus, formatDiagnosticStatus,
@@ -94,6 +93,19 @@ type OpenCodeMcpConfig =
}; };
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const; const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [
"insufficient balance",
"no resource package",
"please recharge",
"invalid api key",
"unauthorized",
"authentication",
"model not found",
"unknown model",
"does not exist",
"unsupported model",
] as const;
const OpencodeToolStateSchema = z const OpencodeToolStateSchema = z
.object({ .object({
@@ -174,8 +186,8 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch
}), }),
); );
function resolveOpenCodeBinary(): string { async function resolveOpenCodeBinary(): Promise<string> {
const found = findExecutable("opencode"); const found = await findExecutable("opencode");
if (found) { if (found) {
return found; return found;
} }
@@ -218,6 +230,14 @@ function normalizeTurnFailureError(error: unknown): string {
return normalized.length > 0 ? normalized : "Unknown error"; return normalized.length > 0 ? normalized : "Unknown error";
} }
function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean {
const normalized = typeof message === "string" ? message.trim().toLowerCase() : "";
if (!normalized) {
return false;
}
return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token));
}
function isAlreadyPresentMcpError(error: unknown): boolean { function isAlreadyPresentMcpError(error: unknown): boolean {
const normalized = stringifyUnknownError(error).toLowerCase(); const normalized = stringifyUnknownError(error).toLowerCase();
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token)); return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
@@ -239,14 +259,15 @@ async function findAvailablePort(): Promise<number> {
}); });
} }
function resolvePartDedupeKey(part: AgentMetadata, partType: "text" | "reasoning"): string | null { function resolvePartDedupeKey(
const partId = part.id; part: { id: string; messageID: string },
if (typeof partId === "string" && partId.trim().length > 0) { partType: "text" | "reasoning",
return `${partType}:${partId}`; ): string | null {
if (part.id.trim().length > 0) {
return `${partType}:${part.id}`;
} }
const messageId = part.messageID; if (part.messageID.trim().length > 0) {
if (typeof messageId === "string" && messageId.trim().length > 0) { return `${partType}:message:${part.messageID}`;
return `${partType}:message:${messageId}`;
} }
return null; return null;
} }
@@ -404,15 +425,11 @@ function buildOpenCodeModelContextWindowLookup(providers: {
return lookup; return lookup;
} }
function resolveOpenCodeModelLookupKeyFromAssistantMessageInfo( function resolveOpenCodeModelLookupKeyFromAssistantMessage(
info: AgentMetadata | undefined, info: OpenCodeAssistantMessage,
): string | undefined { ): string | undefined {
if (!info || info.role !== "assistant") { const providerId = info.providerID;
return undefined; const modelId = info.modelID;
}
const providerId = readNonEmptyString(info.providerID);
const modelId = readNonEmptyString(info.modelID);
if (!providerId || !modelId) { if (!providerId || !modelId) {
return undefined; return undefined;
} }
@@ -450,16 +467,16 @@ function mergeOpenCodeStepFinishUsage(
const cost = readPositiveFiniteNumber(part.cost); const cost = readPositiveFiniteNumber(part.cost);
if (inputTokens !== undefined) { if (inputTokens !== undefined) {
usage.inputTokens = (usage.inputTokens ?? 0) + inputTokens; usage.inputTokens = inputTokens;
} }
if (cacheReadTokens !== undefined) { if (cacheReadTokens !== undefined) {
usage.cachedInputTokens = (usage.cachedInputTokens ?? 0) + cacheReadTokens; usage.cachedInputTokens = cacheReadTokens;
} }
if (outputTokens !== undefined) { if (outputTokens !== undefined) {
usage.outputTokens = (usage.outputTokens ?? 0) + outputTokens; usage.outputTokens = outputTokens;
} }
if (totalTokens > 0) { if (totalTokens > 0) {
usage.contextWindowUsedTokens = (usage.contextWindowUsedTokens ?? 0) + totalTokens; usage.contextWindowUsedTokens = totalTokens;
} }
if (cost !== undefined) { if (cost !== undefined) {
usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost; usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost;
@@ -485,7 +502,7 @@ export const __openCodeInternals = {
hasNormalizedOpenCodeUsage, hasNormalizedOpenCodeUsage,
mergeOpenCodeStepFinishUsage, mergeOpenCodeStepFinishUsage,
parseOpenCodeModelLookupKey, parseOpenCodeModelLookupKey,
resolveOpenCodeModelLookupKeyFromAssistantMessageInfo, resolveOpenCodeModelLookupKeyFromAssistantMessage,
resolveOpenCodeSelectedModelContextWindow, resolveOpenCodeSelectedModelContextWindow,
}; };
@@ -564,19 +581,16 @@ export class OpenCodeServerManager {
private async startServer(): Promise<{ port: number; url: string }> { private async startServer(): Promise<{ port: number; url: string }> {
this.port = await findAvailablePort(); this.port = await findAvailablePort();
const url = `http://127.0.0.1:${this.port}`; const url = `http://127.0.0.1:${this.port}`;
const launchPrefix = resolveProviderCommandPrefix( const launchPrefix = await resolveProviderCommandPrefix(
this.runtimeSettings?.command, this.runtimeSettings?.command,
resolveOpenCodeBinary, resolveOpenCodeBinary,
); );
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.server = spawn( this.server = spawnProcess(
quoteWindowsCommand(launchPrefix.command), launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(this.port)].map((argument) => [...launchPrefix.args, "serve", "--port", String(this.port)],
quoteWindowsArgument(argument),
),
{ {
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: applyProviderEnv(process.env, this.runtimeSettings), env: applyProviderEnv(process.env, this.runtimeSettings),
}, },
@@ -733,16 +747,17 @@ export class OpenCodeAgentClient implements AgentClient {
directory: options?.cwd ?? process.cwd(), directory: options?.cwd ?? process.cwd(),
}); });
// Set a timeout for the API call to fail fast if OpenCode isn't responding // Background model discovery can be legitimately slow while OpenCode refreshes
// provider state, so allow longer than turn execution paths.
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout( setTimeout(
() => () =>
reject( reject(
new Error( new Error(
"OpenCode provider.list timed out after 10s - server may not be authenticated or connected to any providers", `OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`,
), ),
), ),
10_000, OPENCODE_PROVIDER_LIST_TIMEOUT_MS,
); );
}); });
@@ -847,7 +862,7 @@ export class OpenCodeAgentClient implements AgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
try { try {
const available = await this.isAvailable(); const available = await this.isAvailable();
const resolvedBinary = findExecutable("opencode"); const resolvedBinary = await findExecutable("opencode");
let serverStatus = "Not running"; let serverStatus = "Not running";
let modelsValue = "Not checked"; let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available); let status = formatDiagnosticStatus(available);
@@ -889,7 +904,7 @@ export class OpenCodeAgentClient implements AgentClient {
label: "Binary", label: "Binary",
value: resolvedBinary ?? "not found", value: resolvedBinary ?? "not found",
}, },
{ label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, { label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
{ label: "Server", value: serverStatus }, { label: "Server", value: serverStatus },
{ label: "Models", value: modelsValue }, { label: "Models", value: modelsValue },
{ label: "Status", value: status }, { label: "Status", value: status },
@@ -962,51 +977,18 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
} }
function normalizeQuestionOptions(
value: unknown,
): Array<{ label: string; description?: string }> | null {
if (!Array.isArray(value)) {
return null;
}
const options: Array<{ label: string; description?: string }> = [];
for (const item of value) {
if (typeof item === "string" && item.trim().length > 0) {
options.push({ label: item.trim() });
continue;
}
const record = readOpenCodeRecord(item);
const label = readNonEmptyString(record?.label);
if (!label) {
continue;
}
const description = readNonEmptyString(record?.description);
options.push(description ? { label, description } : { label });
}
return options;
}
export function translateOpenCodeEvent( export function translateOpenCodeEvent(
event: unknown, event: OpenCodeEvent,
state: OpenCodeEventTranslationState, state: OpenCodeEventTranslationState,
): AgentStreamEvent[] { ): AgentStreamEvent[] {
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
if (!event || typeof event !== "object") { switch (event.type) {
return events;
}
const e = event as { type?: string; properties?: AgentMetadata };
const type = e.type;
const props = e.properties ?? {};
switch (type) {
case "session.created": case "session.created":
case "session.updated": { case "session.updated": {
const sessionId = props.id as string | undefined; if (event.properties.info.id === state.sessionId) {
if (sessionId === state.sessionId) {
events.push({ events.push({
type: "thread_started", type: "thread_started",
sessionId: state.sessionId, sessionId: state.sessionId,
@@ -1017,35 +999,28 @@ export function translateOpenCodeEvent(
} }
case "message.updated": { case "message.updated": {
const info = props.info as AgentMetadata | undefined; const info = event.properties.info;
if (!info) { if (info.sessionID !== state.sessionId) {
break; break;
} }
const messageId = info.id as string | undefined;
const messageSessionId = info.sessionID as string | undefined;
const role = info.role as OpenCodeMessageRole | undefined;
if (messageId && messageSessionId === state.sessionId && role) { state.messageRoles.set(info.id, info.role);
state.messageRoles.set(messageId, role); if (info.role === "assistant") {
if (role === "assistant") { const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info);
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(info); if (modelLookupKey) {
if (modelLookupKey) { const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey); if (contextWindowMaxTokens !== undefined) {
if (contextWindowMaxTokens !== undefined) { state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
}
} }
} }
if ( if (
role === "assistant" && !state.emittedStructuredMessageIds.has(info.id) &&
!state.emittedStructuredMessageIds.has(messageId) && info.time?.completed !== undefined
typeof info.time === "object" &&
info.time !== null &&
"completed" in info.time
) { ) {
const text = stringifyStructuredAssistantMessage(info.structured); const text = stringifyStructuredAssistantMessage(info.structured);
if (text) { if (text) {
state.emittedStructuredMessageIds.add(messageId); state.emittedStructuredMessageIds.add(info.id);
events.push({ events.push({
type: "timeline", type: "timeline",
provider: "opencode", provider: "opencode",
@@ -1058,82 +1033,46 @@ export function translateOpenCodeEvent(
} }
case "message.part.updated": { case "message.part.updated": {
const part = props.part as AgentMetadata | undefined; const part = event.properties.part;
const delta = props.delta as string | undefined; if (part.sessionID !== state.sessionId) {
if (!part) {
break; break;
} }
const partSessionId = part.sessionID as string | undefined; const messageRole = state.messageRoles.get(part.messageID);
if (partSessionId !== state.sessionId) { state.partTypes.set(part.id, part.type);
break;
}
const partId = part.id as string | undefined; if (part.type === "text") {
const messageId = part.messageID as string | undefined;
const messageRole = messageId ? state.messageRoles.get(messageId) : undefined;
const partType = part.type as string | undefined;
const partTime = part.time as { start?: number; end?: number } | undefined;
if (partId && partType) {
state.partTypes.set(partId, partType);
}
if (partType === "text") {
const partKey = resolvePartDedupeKey(part, "text"); const partKey = resolvePartDedupeKey(part, "text");
if (messageRole === "user") { if (messageRole === "user") {
break; break;
} }
if (!messageRole && !delta) { if (part.time?.end) {
break;
}
if (delta) {
if (partKey) {
state.streamedPartKeys.add(partKey);
}
events.push({
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text: delta },
});
} else if (partTime?.end) {
if (partKey && state.streamedPartKeys.delete(partKey)) { if (partKey && state.streamedPartKeys.delete(partKey)) {
break; break;
} }
const text = part.text as string | undefined; if (part.text) {
if (text) {
events.push({ events.push({
type: "timeline", type: "timeline",
provider: "opencode", provider: "opencode",
item: { type: "assistant_message", text }, item: { type: "assistant_message", text: part.text },
}); });
} }
} }
} else if (partType === "reasoning") { } else if (part.type === "reasoning") {
const partKey = resolvePartDedupeKey(part, "reasoning"); const partKey = resolvePartDedupeKey(part, "reasoning");
if (delta) { if (part.time.end) {
if (partKey) {
state.streamedPartKeys.add(partKey);
}
events.push({
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text: delta },
});
} else if (partTime?.end) {
if (partKey && state.streamedPartKeys.delete(partKey)) { if (partKey && state.streamedPartKeys.delete(partKey)) {
break; break;
} }
const text = part.text as string | undefined; if (part.text) {
if (text) {
events.push({ events.push({
type: "timeline", type: "timeline",
provider: "opencode", provider: "opencode",
item: { type: "reasoning", text }, item: { type: "reasoning", text: part.text },
}); });
} }
} }
} else if (partType === "tool") { } else if (part.type === "tool") {
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part); const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (parsedToolPart.success && parsedToolPart.data) { if (parsedToolPart.success && parsedToolPart.data) {
events.push({ events.push({
@@ -1142,118 +1081,98 @@ export function translateOpenCodeEvent(
item: parsedToolPart.data, item: parsedToolPart.data,
}); });
} }
} else if (partType === "step-finish") { } else if (part.type === "step-finish") {
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part); mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
events.push({
type: "usage_updated",
provider: "opencode",
usage: { ...state.accumulatedUsage },
});
}
} }
break; break;
} }
case "message.part.delta": { case "message.part.delta": {
const deltaSessionId = props.sessionID as string | undefined; const { sessionID, messageID, partID, field, delta } = event.properties;
if (deltaSessionId !== state.sessionId) { if (sessionID !== state.sessionId) {
break; break;
} }
const deltaMessageId = props.messageID as string | undefined; if (!delta || !field) {
const deltaMessageRole = deltaMessageId
? state.messageRoles.get(deltaMessageId)
: undefined;
const deltaField = props.field as string | undefined;
const deltaText = props.delta as string | undefined;
if (!deltaText || !deltaField) {
break; break;
} }
const partId = props.partID as string | undefined; const messageRole = messageID ? state.messageRoles.get(messageID) : undefined;
const knownPartType = partId ? state.partTypes.get(partId) : undefined; const knownPartType = partID ? state.partTypes.get(partID) : undefined;
const isReasoning = knownPartType === "reasoning" || deltaField === "reasoning"; const isReasoning = knownPartType === "reasoning" || field === "reasoning";
if (isReasoning) { if (isReasoning) {
const partKey = partId ? `reasoning:${partId}` : null; if (partID) {
if (partKey) { state.streamedPartKeys.add(`reasoning:${partID}`);
state.streamedPartKeys.add(partKey);
} }
events.push({ events.push({
type: "timeline", type: "timeline",
provider: "opencode", provider: "opencode",
item: { type: "reasoning", text: deltaText }, item: { type: "reasoning", text: delta },
}); });
} else if (deltaField === "text") { } else if (field === "text") {
if (deltaMessageRole === "user") { if (messageRole === "user") {
break; break;
} }
const partKey = partId ? `text:${partId}` : null; if (partID) {
if (partKey) { state.streamedPartKeys.add(`text:${partID}`);
state.streamedPartKeys.add(partKey);
} }
events.push({ events.push({
type: "timeline", type: "timeline",
provider: "opencode", provider: "opencode",
item: { type: "assistant_message", text: deltaText }, item: { type: "assistant_message", text: delta },
}); });
} }
break; break;
} }
case "permission.asked": { case "permission.asked": {
const sessionId = props.sessionID as string | undefined; if (event.properties.sessionID !== state.sessionId) {
if (sessionId !== state.sessionId) {
break; break;
} }
const requestId = props.id as string;
const permission = props.permission as string;
const metadata = props.metadata as AgentMetadata | undefined;
const patterns = props.patterns as string[] | undefined;
const permRequest: AgentPermissionRequest = {
id: requestId,
provider: "opencode",
name: permission,
kind: "tool",
title: permission,
description: patterns?.join(", "),
input: metadata,
};
events.push({ events.push({
type: "permission_requested", type: "permission_requested",
provider: "opencode", provider: "opencode",
request: permRequest, request: {
id: event.properties.id,
provider: "opencode",
name: event.properties.permission,
kind: "tool",
title: event.properties.permission,
description: event.properties.patterns?.join(", "),
input: event.properties.metadata,
},
}); });
break; break;
} }
case "question.asked": { case "question.asked": {
const sessionId = props.sessionID as string | undefined; if (event.properties.sessionID !== state.sessionId) {
if (sessionId !== state.sessionId) {
break; break;
} }
const requestId = props.id as string | undefined; const questions = event.properties.questions.flatMap((q) => {
const rawQuestions = Array.isArray(props.questions) ? props.questions : []; if (!q.question || !q.header) {
if (!requestId || rawQuestions.length === 0) {
break;
}
const questions = rawQuestions.flatMap((item) => {
const questionRecord = readOpenCodeRecord(item);
const question = readNonEmptyString(questionRecord?.question);
const header = readNonEmptyString(questionRecord?.header);
if (!question || !header) {
return []; return [];
} }
const options = q.options?.map((o) => ({
const options = normalizeQuestionOptions(questionRecord?.options) ?? []; label: o.label,
return [ ...(o.description ? { description: o.description } : {}),
{ })) ?? [];
question, return [{
header, question: q.question,
options, header: q.header,
...(questionRecord?.multiple === true ? { multiSelect: true } : {}), options,
}, ...(q.multiple === true ? { multiSelect: true } : {}),
]; }];
}); });
if (questions.length === 0) { if (questions.length === 0) {
@@ -1264,7 +1183,7 @@ export function translateOpenCodeEvent(
type: "permission_requested", type: "permission_requested",
provider: "opencode", provider: "opencode",
request: { request: {
id: requestId, id: event.properties.id,
provider: "opencode", provider: "opencode",
name: "question", name: "question",
kind: "question", kind: "question",
@@ -1272,7 +1191,7 @@ export function translateOpenCodeEvent(
input: { questions }, input: { questions },
metadata: { metadata: {
source: "opencode_question", source: "opencode_question",
...(readOpenCodeRecord(props.tool) ?? {}), ...(event.properties.tool ?? {}),
}, },
}, },
}); });
@@ -1280,8 +1199,7 @@ export function translateOpenCodeEvent(
} }
case "session.idle": { case "session.idle": {
const sessionId = props.sessionID as string | undefined; if (event.properties.sessionID === state.sessionId) {
if (sessionId === state.sessionId) {
state.streamedPartKeys.clear(); state.streamedPartKeys.clear();
state.partTypes.clear(); state.partTypes.clear();
events.push({ events.push({
@@ -1294,18 +1212,43 @@ export function translateOpenCodeEvent(
} }
case "session.error": { case "session.error": {
const sessionId = props.sessionID as string | undefined; if (event.properties.sessionID === state.sessionId) {
if (sessionId === state.sessionId) {
state.streamedPartKeys.clear(); state.streamedPartKeys.clear();
state.partTypes.clear(); state.partTypes.clear();
events.push({ events.push({
type: "turn_failed", type: "turn_failed",
provider: "opencode", provider: "opencode",
error: normalizeTurnFailureError(props.error), error: normalizeTurnFailureError(event.properties.error),
}); });
} }
break; break;
} }
case "session.status": {
if (event.properties.sessionID !== state.sessionId) {
break;
}
const { status } = event.properties;
if (status.type === "idle") {
state.streamedPartKeys.clear();
state.partTypes.clear();
events.push({
type: "turn_completed",
provider: "opencode",
usage: undefined,
});
} else if (status.type === "retry" && isFatalOpenCodeRetryMessage(status.message)) {
state.streamedPartKeys.clear();
state.partTypes.clear();
events.push({
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(status.message),
});
}
// "retry" and "busy" are transient — no terminal event.
break;
}
} }
return events; return events;
@@ -1340,7 +1283,6 @@ class OpenCodeAgentSession implements AgentSession {
private activeForegroundTurnId: string | null = null; private activeForegroundTurnId: string | null = null;
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>(); private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
private selectedModelContextWindowMaxTokens: number | undefined; private selectedModelContextWindowMaxTokens: number | undefined;
constructor( constructor(
config: OpenCodeAgentConfig, config: OpenCodeAgentConfig,
client: OpencodeClient, client: OpencodeClient,
@@ -1506,8 +1448,12 @@ class OpenCodeAgentSession implements AgentSession {
const slashCommand = await this.resolveSlashCommandInvocation(prompt); const slashCommand = await this.resolveSlashCommandInvocation(prompt);
if (slashCommand) { if (slashCommand) {
// command() blocks until completion, but events stream via SSE in the // command() blocks until the server finishes processing. OpenCode's SSE
// background — fire without awaiting so the event stream isn't starved. // endpoint does NOT replay past events, so if the command completes before
// our SSE reader connects, we miss `session.idle` and the turn hangs.
// Handle both success and error in the response handler as a fallback —
// finishForegroundTurn's guard prevents duplicate terminal events if the
// SSE stream already delivered the event.
void this.client.session.command({ void this.client.session.command({
sessionID: this.sessionId, sessionID: this.sessionId,
directory: this.config.cwd, directory: this.config.cwd,
@@ -1523,10 +1469,20 @@ class OpenCodeAgentSession implements AgentSession {
{ type: "turn_failed", provider: "opencode", error: errorMsg }, { type: "turn_failed", provider: "opencode", error: errorMsg },
turnId, turnId,
); );
} else {
this.finishForegroundTurn(
{ type: "turn_completed", provider: "opencode", usage: undefined },
turnId,
);
} }
}).catch((err) => {
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
turnId,
);
}); });
} else { } else {
const promptResponse = await this.client.session.promptAsync({ void this.client.session.promptAsync({
sessionID: this.sessionId, sessionID: this.sessionId,
directory: this.config.cwd, directory: this.config.cwd,
parts, parts,
@@ -1542,17 +1498,27 @@ class OpenCodeAgentSession implements AgentSession {
...(model ? { model } : {}), ...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}),
}).then((promptResponse) => {
if (promptResponse.error) {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(promptResponse.error),
},
turnId,
);
}
}).catch((error) => {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(error),
},
turnId,
);
}); });
if (promptResponse.error) {
const errorMsg = normalizeTurnFailureError(promptResponse.error);
this.notifySubscribers({
type: "turn_failed",
provider: "opencode",
error: errorMsg,
});
throw new Error(errorMsg);
}
} }
return { turnId }; return { turnId };
@@ -1569,12 +1535,13 @@ class OpenCodeAgentSession implements AgentSession {
turnId: string, turnId: string,
turnAbortController: AbortController, turnAbortController: AbortController,
): Promise<void> { ): Promise<void> {
const eventsResult = await this.client.event.subscribe({
directory: this.config.cwd,
});
try { try {
for await (const event of eventsResult.stream) { const result = await this.client.event.subscribe(
{ directory: this.config.cwd },
{ signal: turnAbortController.signal, sseMaxRetryAttempts: 0 },
);
for await (const event of result.stream) {
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) { if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
break; break;
} }
@@ -1605,6 +1572,17 @@ class OpenCodeAgentSession implements AgentSession {
this.notifySubscribers(e, turnId); this.notifySubscribers(e, turnId);
} }
} }
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: "OpenCode event stream ended before the turn reached a terminal state",
},
turnId,
);
}
} catch (error) { } catch (error) {
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) { if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn( this.finishForegroundTurn(
@@ -1646,6 +1624,9 @@ class OpenCodeAgentSession implements AgentSession {
this.runningToolCalls.clear(); this.runningToolCalls.clear();
} }
this.activeForegroundTurnId = null; this.activeForegroundTurnId = null;
// Abort the SSE connection so the SDK tears down the underlying fetch.
this.abortController?.abort();
this.abortController = null;
this.notifySubscribers(event, turnId); this.notifySubscribers(event, turnId);
} }
@@ -1701,16 +1682,12 @@ class OpenCodeAgentSession implements AgentSession {
return; return;
} }
const messages = response.data; for (const { info, parts } of response.data) {
if (info.role === "user") {
for (const message of messages) { const text = parts
const { info, parts } = message; .filter((p): p is Extract<OpenCodePart, { type: "text" }> => p.type === "text")
const role = info.role as "user" | "assistant"; .map((p) => p.text)
.join("");
if (role === "user") {
// Extract user message text from parts
const textParts = parts.filter((p) => (p as { type?: string }).type === "text");
const text = textParts.map((p) => (p as { text?: string }).text ?? "").join("");
if (text) { if (text) {
yield { yield {
@@ -1719,49 +1696,40 @@ class OpenCodeAgentSession implements AgentSession {
item: { type: "user_message", text }, item: { type: "user_message", text },
}; };
} }
} else if (role === "assistant") { } else {
let emittedAssistantText = false; let emittedAssistantText = false;
// Process each part
for (const part of parts) { for (const part of parts) {
const partType = (part as { type?: string }).type; if (part.type === "text") {
if (part.text) {
if (partType === "text") {
const text = (part as { text?: string }).text;
if (text) {
emittedAssistantText = true; emittedAssistantText = true;
yield { yield {
type: "timeline", type: "timeline",
provider: "opencode", provider: "opencode",
item: { type: "assistant_message", text }, item: { type: "assistant_message", text: part.text },
}; };
} }
} else if (partType === "reasoning") { } else if (part.type === "reasoning") {
const text = (part as { text?: string }).text; if (part.text) {
if (text) {
yield { yield {
type: "timeline", type: "timeline",
provider: "opencode", provider: "opencode",
item: { type: "reasoning", text }, item: { type: "reasoning", text: part.text },
}; };
} }
} else if (partType === "tool") { } else if (part.type === "tool") {
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part); const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (parsedToolPart.success) { if (parsedToolPart.success && parsedToolPart.data) {
if (parsedToolPart.data) { yield {
yield { type: "timeline",
type: "timeline", provider: "opencode",
provider: "opencode", item: parsedToolPart.data,
item: parsedToolPart.data, };
};
}
} }
} }
} }
if (!emittedAssistantText) { if (!emittedAssistantText) {
const text = stringifyStructuredAssistantMessage( const text = stringifyStructuredAssistantMessage(info.structured);
(info as { structured?: unknown }).structured,
);
if (text) { if (text) {
yield { yield {
type: "timeline", type: "timeline",
@@ -2020,7 +1988,7 @@ class OpenCodeAgentSession implements AgentSession {
); );
} }
private translateEvent(event: unknown): AgentStreamEvent[] { private translateEvent(event: OpenCodeEvent): AgentStreamEvent[] {
const translated = translateOpenCodeEvent(event, { const translated = translateOpenCodeEvent(event, {
sessionId: this.sessionId, sessionId: this.sessionId,
messageRoles: this.messageRoles, messageRoles: this.messageRoles,
@@ -255,6 +255,61 @@ describe("translateOpenCodeEvent", () => {
]); ]);
}); });
it("emits usage_updated after step-finish parts", () => {
const state = createState();
state.accumulatedUsage.contextWindowMaxTokens = 400_000;
const events = translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
id: "step-finish-1",
sessionID: "session-1",
messageID: "message-usage-1",
type: "step-finish",
reason: "stop",
cost: 0.25,
tokens: {
total: 999_999,
input: 30_000,
output: 12_000,
reasoning: 10_000,
cache: {
read: 2_000,
write: 1_000,
},
},
},
},
},
state,
);
expect(events).toEqual([
{
type: "usage_updated",
provider: "opencode",
usage: {
contextWindowMaxTokens: 400_000,
contextWindowUsedTokens: 55_000,
cachedInputTokens: 2_000,
inputTokens: 30_000,
outputTokens: 12_000,
totalCostUsd: 0.25,
},
},
]);
expect(state.accumulatedUsage).toEqual({
contextWindowMaxTokens: 400_000,
contextWindowUsedTokens: 55_000,
cachedInputTokens: 2_000,
inputTokens: 30_000,
outputTokens: 12_000,
totalCostUsd: 0.25,
});
});
it("emits reasoning from message.part.delta events", () => { it("emits reasoning from message.part.delta events", () => {
const state = createState(); const state = createState();
@@ -326,7 +326,7 @@ export class PiACPAgentClient extends ACPAgentClient {
if (!existsSync(resolvedPiAcpPath)) { if (!existsSync(resolvedPiAcpPath)) {
return false; return false;
} }
if (!isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi")) { if (!(await isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi"))) {
return false; return false;
} }
return ( return (
@@ -340,8 +340,8 @@ export class PiACPAgentClient extends ACPAgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> { async getDiagnostic(): Promise<{ diagnostic: string }> {
try { try {
const piCommand = process.env.PI_ACP_PI_COMMAND ?? "pi"; const piCommand = process.env.PI_ACP_PI_COMMAND ?? "pi";
const piCliPath = findExecutable(piCommand); const piCliPath = await findExecutable(piCommand);
const piVersion = piCliPath ? resolveBinaryVersion(piCliPath) : "unknown"; const piVersion = piCliPath ? await resolveBinaryVersion(piCliPath) : "unknown";
const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); const authConfigPath = join(homedir(), ".pi", "agent", "auth.json");
const available = await this.isAvailable(); const available = await this.isAvailable();
let modelsValue = "Not checked"; let modelsValue = "Not checked";
+32 -23
View File
@@ -120,21 +120,28 @@ import { resolveVoiceMcpBridgeFromRuntime } from "./voice-mcp-bridge-command.js"
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>; type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
function resolveVoiceMcpBridgeCommand(logger: Logger): { command: string; baseArgs: string[] } { function resolveVoiceMcpBridgeCommand(
const decision = resolveVoiceMcpBridgeFromRuntime({ logger: Logger,
bootstrapModuleUrl: import.meta.url, ): { command: string; baseArgs: string[] } | null {
execPath: process.execPath, try {
explicitScriptPath: process.env.PASEO_MCP_STDIO_SOCKET_BRIDGE_SCRIPT, const decision = resolveVoiceMcpBridgeFromRuntime({
}); bootstrapModuleUrl: import.meta.url,
logger.info( execPath: process.execPath,
{ explicitScriptPath: process.env.PASEO_MCP_STDIO_SOCKET_BRIDGE_SCRIPT,
source: decision.source, });
command: decision.resolved.command, logger.info(
baseArgs: decision.resolved.baseArgs, {
}, source: decision.source,
"Resolved voice MCP bridge command", command: decision.resolved.command,
); baseArgs: decision.resolved.baseArgs,
return decision.resolved; },
"Resolved voice MCP bridge command",
);
return decision.resolved;
} catch (err) {
logger.warn({ err }, "Voice MCP bridge script not available — voice MCP via stdio disabled");
return null;
}
} }
export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig; export type PaseoOpenAIConfig = OpenAiSpeechProviderConfig;
@@ -586,14 +593,16 @@ export async function createPaseoDaemon(
speechService, speechService,
terminalManager, terminalManager,
{ {
voiceAgentMcpStdio: { voiceAgentMcpStdio: voiceMcpBridgeCommand
command: voiceMcpBridgeCommand.command, ? {
baseArgs: [...voiceMcpBridgeCommand.baseArgs], command: voiceMcpBridgeCommand.command,
env: { baseArgs: [...voiceMcpBridgeCommand.baseArgs],
ELECTRON_RUN_AS_NODE: "1", env: {
PASEO_HOME: config.paseoHome, ELECTRON_RUN_AS_NODE: "1",
}, PASEO_HOME: config.paseoHome,
}, },
}
: null,
ensureVoiceMcpSocketForAgent: (agentId) => ensureVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ?? voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ??
Promise.reject(new Error("Voice MCP bridge manager is not initialized")), Promise.reject(new Error("Voice MCP bridge manager is not initialized")),
@@ -7,7 +7,7 @@ import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { homedir } from "node:os"; import { homedir } from "node:os";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { isCommandAvailable } from "../../utils/executable.js"; import { isCommandAvailableSync } from "../../utils/executable.js";
// Load .env.test eagerly so isProviderAvailable() has credentials at collection time. // Load .env.test eagerly so isProviderAvailable() has credentials at collection time.
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
@@ -105,21 +105,21 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
switch (provider) { switch (provider) {
case "claude": case "claude":
return ( return (
isCommandAvailable("claude") && isCommandAvailableSync("claude") &&
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY)) (Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
); );
case "codex": case "codex":
return ( return (
isCommandAvailable("codex") && isCommandAvailableSync("codex") &&
(existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY)) (existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY))
); );
case "copilot": case "copilot":
return isCommandAvailable("copilot"); return isCommandAvailableSync("copilot");
case "opencode": case "opencode":
return isCommandAvailable("opencode"); return isCommandAvailableSync("opencode");
case "pi": case "pi":
return ( return (
isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi") && isCommandAvailableSync(process.env.PI_ACP_PI_COMMAND ?? "pi") &&
(Boolean(process.env.OPENAI_API_KEY) || (Boolean(process.env.OPENAI_API_KEY) ||
Boolean(process.env.ANTHROPIC_API_KEY) || Boolean(process.env.ANTHROPIC_API_KEY) ||
Boolean(process.env.OPENROUTER_API_KEY) || Boolean(process.env.OPENROUTER_API_KEY) ||
@@ -0,0 +1,113 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-init-prompt-"));
}
async function createHarness(): Promise<{
client: DaemonClient;
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
}> {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { opencode: new OpenCodeAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-init-prompt" } });
return { client, daemon };
}
describe("daemon E2E (real opencode) - initial prompt wait", () => {
test.runIf(isProviderAvailable("opencode"))(
"waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
try {
const models = await client.listProviderModels("opencode");
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode initial prompt wait regression",
model: "opencode/big-pickle",
initialPrompt: "Reply with exactly: BIG_PICKLE_OK",
});
const finish = await client.waitForFinish(agent.id, 60_000);
expect(finish.status).toBe("idle");
expect(finish.lastMessage).toContain("BIG_PICKLE_OK");
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.status).toBe("idle");
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
const assistantMessages = timeline.entries.filter(
(entry) => entry.item.type === "assistant_message",
);
expect(assistantMessages.length).toBeGreaterThan(0);
expect(
assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK")),
).toBe(true);
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
90_000,
);
test.runIf(isProviderAvailable("opencode"))(
"waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
try {
const models = await client.listProviderModels("opencode");
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode zai fatal retry regression",
model: "zai/glm-5.1",
initialPrompt: "Reply with exactly: GLM_51_OK",
});
const finish = await client.waitForFinish(agent.id, 60_000);
expect(finish.status).toBe("error");
expect((finish.error ?? "").toLowerCase()).toMatch(
/insufficient balance|resource package|recharge/,
);
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
90_000,
);
});
@@ -0,0 +1,61 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { isProviderAvailable } from "./agent-configs.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-invalid-model-"));
}
async function createHarness(): Promise<{
client: DaemonClient;
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
}> {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { opencode: new OpenCodeAgentClient(logger) },
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-model" } });
return { client, daemon };
}
describe("daemon E2E (real opencode) - invalid model handling", () => {
test.runIf(isProviderAvailable("opencode"))(
"initial prompt with a nonexistent OpenCode model fails instead of hanging forever",
async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
try {
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode invalid model regression",
model: "opencode/adklasldkdas",
initialPrompt: "hello",
});
const finish = await client.waitForFinish(agent.id, 30_000);
expect(finish.status).toBe("error");
expect(finish.error).toBeTruthy();
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
60_000,
);
});
@@ -2,10 +2,10 @@ import { describe, expect, it, vi } from "vitest";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js"; import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
describe("editor-targets", () => { describe("editor-targets", () => {
it("lists available editors in deterministic order", () => { it("lists available editors in deterministic order", async () => {
const available = new Set(["code", "cursor", "explorer"]); const available = new Set(["code", "cursor", "explorer", "webstorm"]);
const editors = listAvailableEditorTargets({ const editors = await listAvailableEditorTargets({
platform: "win32", platform: "win32",
findExecutable: (command) => (available.has(command) ? command : null), findExecutable: (command) => (available.has(command) ? command : null),
}); });
@@ -13,12 +13,13 @@ describe("editor-targets", () => {
expect(editors).toEqual([ expect(editors).toEqual([
{ id: "cursor", label: "Cursor" }, { id: "cursor", label: "Cursor" },
{ id: "vscode", label: "VS Code" }, { id: "vscode", label: "VS Code" },
{ id: "webstorm", label: "WebStorm" },
{ id: "explorer", label: "Explorer" }, { id: "explorer", label: "Explorer" },
]); ]);
}); });
it("returns Finder on macOS", () => { it("returns Finder on macOS", async () => {
const editors = listAvailableEditorTargets({ const editors = await listAvailableEditorTargets({
platform: "darwin", platform: "darwin",
findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null), findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null),
}); });
@@ -26,8 +27,8 @@ describe("editor-targets", () => {
expect(editors).toEqual([{ id: "finder", label: "Finder" }]); expect(editors).toEqual([{ id: "finder", label: "Finder" }]);
}); });
it("returns the generic file manager target on Linux", () => { it("returns the generic file manager target on Linux", async () => {
const editors = listAvailableEditorTargets({ const editors = await listAvailableEditorTargets({
platform: "linux", platform: "linux",
findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null), findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null),
}); });
@@ -97,4 +98,19 @@ describe("editor-targets", () => {
), ),
).rejects.toThrow("Editor target unavailable: Finder"); ).rejects.toThrow("Editor target unavailable: Finder");
}); });
it("rejects unknown editor ids", async () => {
await expect(
openInEditorTarget(
{
editorId: "unknown-editor",
path: "/tmp/repo",
},
{
existsSync: () => true,
findExecutable: () => null,
},
),
).rejects.toThrow("Unknown editor target: unknown-editor");
});
}); });
+26 -22
View File
@@ -1,7 +1,11 @@
import { spawn, type ChildProcess } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { posix, win32 } from "node:path"; import { posix, win32 } from "node:path";
import type { EditorTargetDescriptorPayload, EditorTargetId } from "../shared/messages.js"; import type {
EditorTargetDescriptorPayload,
EditorTargetId,
KnownEditorTargetId,
} from "../shared/messages.js";
import { import {
findExecutable, findExecutable,
quoteWindowsArgument, quoteWindowsArgument,
@@ -9,7 +13,7 @@ import {
} from "../utils/executable.js"; } from "../utils/executable.js";
type EditorTargetDefinition = { type EditorTargetDefinition = {
id: EditorTargetId; id: KnownEditorTargetId;
label: string; label: string;
command: string; command: string;
platforms?: readonly NodeJS.Platform[]; platforms?: readonly NodeJS.Platform[];
@@ -18,7 +22,7 @@ type EditorTargetDefinition = {
type ListAvailableEditorTargetsDependencies = { type ListAvailableEditorTargetsDependencies = {
platform?: NodeJS.Platform; platform?: NodeJS.Platform;
findExecutable?: (command: string) => string | null; findExecutable?: (command: string) => string | null | Promise<string | null>;
}; };
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & { type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
@@ -29,6 +33,7 @@ type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [ const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
{ id: "cursor", label: "Cursor", command: "cursor" }, { id: "cursor", label: "Cursor", command: "cursor" },
{ id: "vscode", label: "VS Code", command: "code" }, { id: "vscode", label: "VS Code", command: "code" },
{ id: "webstorm", label: "WebStorm", command: "webstorm" },
{ id: "zed", label: "Zed", command: "zed" }, { id: "zed", label: "Zed", command: "zed" },
{ id: "finder", label: "Finder", command: "open", platforms: ["darwin"] }, { id: "finder", label: "Finder", command: "open", platforms: ["darwin"] },
{ id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] }, { id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] },
@@ -65,28 +70,27 @@ function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDe
return target; return target;
} }
export function listAvailableEditorTargets( export async function listAvailableEditorTargets(
dependencies: ListAvailableEditorTargetsDependencies = {}, dependencies: ListAvailableEditorTargetsDependencies = {},
): EditorTargetDescriptorPayload[] { ): Promise<EditorTargetDescriptorPayload[]> {
const platform = dependencies.platform ?? process.platform; const platform = dependencies.platform ?? process.platform;
const findExecutableFn = dependencies.findExecutable ?? findExecutable; const findExecutableFn = dependencies.findExecutable ?? findExecutable;
return EDITOR_TARGETS.flatMap((target) => { const results: EditorTargetDescriptorPayload[] = [];
for (const target of EDITOR_TARGETS) {
if (!isTargetSupportedOnPlatform(target, platform)) { if (!isTargetSupportedOnPlatform(target, platform)) {
return []; continue;
} }
const executable = findExecutableFn(target.command); const executable = await findExecutableFn(target.command);
if (!executable) { if (!executable) {
return []; continue;
} }
results.push({
return [ id: target.id,
{ label: target.label,
id: target.id, });
label: target.label, }
}, return results;
];
});
} }
type Launch = { type Launch = {
@@ -94,17 +98,17 @@ type Launch = {
args: string[]; args: string[];
}; };
function resolveEditorLaunch(input: { async function resolveEditorLaunch(input: {
editorId: EditorTargetId; editorId: EditorTargetId;
path: string; path: string;
platform: NodeJS.Platform; platform: NodeJS.Platform;
findExecutableFn: typeof findExecutable; findExecutableFn: (command: string) => string | null | Promise<string | null>;
}): Launch { }): Promise<Launch> {
const target = resolveEditorTargetDefinition(input.editorId); const target = resolveEditorTargetDefinition(input.editorId);
if (!isTargetSupportedOnPlatform(target, input.platform)) { if (!isTargetSupportedOnPlatform(target, input.platform)) {
throw new Error(`Editor target unavailable: ${target.label}`); throw new Error(`Editor target unavailable: ${target.label}`);
} }
const executable = input.findExecutableFn(target.command); const executable = await input.findExecutableFn(target.command);
if (!executable) { if (!executable) {
throw new Error(`Editor target unavailable: ${target.label}`); throw new Error(`Editor target unavailable: ${target.label}`);
} }
@@ -135,7 +139,7 @@ export async function openInEditorTarget(
throw new Error(`Path does not exist: ${pathToOpen}`); throw new Error(`Path does not exist: ${pathToOpen}`);
} }
const launch = resolveEditorLaunch({ const launch = await resolveEditorLaunch({
editorId: input.editorId, editorId: input.editorId,
path: pathToOpen, path: pathToOpen,
platform, platform,
+2 -1
View File
@@ -33,7 +33,8 @@ export {
export { export {
applyProviderEnv, applyProviderEnv,
} from "./agent/provider-launch-config.js"; } from "./agent/provider-launch-config.js";
export { findExecutable, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js"; export { findExecutable, findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
export { spawnProcess } from "../utils/spawn.js";
// Provider manifest (source of truth for provider definitions) // Provider manifest (source of truth for provider definitions)
export { export {
+7 -6
View File
@@ -143,6 +143,7 @@ async function main() {
logger.error({ pid: err.existingLock?.pid }, err.message); logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1); process.exit(1);
} }
logger.fatal({ err }, "Daemon bootstrap failed");
throw err; throw err;
} }
@@ -153,6 +154,7 @@ async function main() {
logger.error({ pid: err.existingLock?.pid }, err.message); logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1); process.exit(1);
} }
logger.fatal({ err }, "Daemon failed to start listening");
throw err; throw err;
} }
@@ -171,10 +173,9 @@ async function main() {
} }
main().catch((err) => { main().catch((err) => {
if (process.env.PASEO_DEBUG === "1") { process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`); // Give pino streams a moment to flush the fatal log entry to daemon.log
} else { // before the process exits. Without this, async file streams may lose the
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); // last few entries that explain why the daemon crashed.
} setTimeout(() => process.exit(1), 200);
process.exit(1);
}); });
+65 -42
View File
@@ -8,6 +8,7 @@ import { homedir } from "node:os";
import { z } from "zod"; import { z } from "zod";
import type { ToolSet } from "ai"; import type { ToolSet } from "ai";
import { import {
isLegacyEditorTargetId,
serializeAgentStreamEvent, serializeAgentStreamEvent,
type AgentSnapshotPayload, type AgentSnapshotPayload,
type SessionInboundMessage, type SessionInboundMessage,
@@ -28,6 +29,7 @@ import {
type SubscribeCheckoutDiffRequest, type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest, type DirectorySuggestionsRequest,
type EditorTargetDescriptorPayload,
type EditorTargetId, type EditorTargetId,
type ProjectPlacementPayload, type ProjectPlacementPayload,
type WorkspaceDescriptorPayload, type WorkspaceDescriptorPayload,
@@ -202,13 +204,14 @@ const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
// the entire session message if they encounter an unknown provider. // the entire session message if they encounter an unknown provider.
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";
function clientSupportsAllProviders(appVersion: string | null): boolean { function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean {
if (!appVersion) return false; if (!appVersion) return false;
// Strip RC/prerelease suffix: "0.1.45-rc.4" → "0.1.45" // Strip RC/prerelease suffix: "0.1.45-rc.4" → "0.1.45"
const base = appVersion.replace(/-.*$/, ""); const base = appVersion.replace(/-.*$/, "");
const parts = base.split(".").map(Number); const parts = base.split(".").map(Number);
const minParts = MIN_VERSION_ALL_PROVIDERS.split(".").map(Number); const minParts = minVersion.split(".").map(Number);
for (let i = 0; i < minParts.length; i++) { for (let i = 0; i < minParts.length; i++) {
const a = parts[i] ?? 0; const a = parts[i] ?? 0;
const b = minParts[i] ?? 0; const b = minParts[i] ?? 0;
@@ -218,6 +221,14 @@ function clientSupportsAllProviders(appVersion: string | null): boolean {
return true; return true;
} }
function clientSupportsAllProviders(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS);
}
function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS);
}
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
@@ -1215,6 +1226,15 @@ export class Session {
return LEGACY_PROVIDER_IDS.has(provider); return LEGACY_PROVIDER_IDS.has(provider);
} }
private filterEditorsForClient(
editors: EditorTargetDescriptorPayload[],
): EditorTargetDescriptorPayload[] {
if (clientSupportsFlexibleEditorIds(this.appVersion)) {
return editors;
}
return editors.filter((editor) => isLegacyEditorTargetId(editor.id));
}
private matchesAgentFilter(options: { private matchesAgentFilter(options: {
agent: AgentSnapshotPayload; agent: AgentSnapshotPayload;
project: ProjectPlacementPayload; project: ProjectPlacementPayload;
@@ -2836,7 +2856,7 @@ export class Session {
images?: Array<{ data: string; mimeType: string }>, images?: Array<{ data: string; mimeType: string }>,
runOptions?: AgentRunOptions, runOptions?: AgentRunOptions,
options?: { spokenInput?: boolean }, options?: { spokenInput?: boolean },
): Promise<void> { ): Promise<{ ok: true } | { ok: false; error: string }> {
this.sessionLogger.info( this.sessionLogger.info(
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 }, { agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
`Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ""}`, `Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ""}`,
@@ -2848,7 +2868,10 @@ export class Session {
await this.ensureAgentLoaded(agentId); await this.ensureAgentLoaded(agentId);
} catch (error) { } catch (error) {
this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt"); this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt");
return; return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
} }
try { try {
@@ -2866,7 +2889,7 @@ export class Session {
const promptText = options?.spokenInput ? wrapSpokenInput(text) : text; const promptText = options?.spokenInput ? wrapSpokenInput(text) : text;
const prompt = this.buildAgentPrompt(promptText, images); const prompt = this.buildAgentPrompt(promptText, images);
this.startAgentStream(agentId, prompt, runOptions); return this.startAgentStream(agentId, prompt, runOptions);
} }
/** /**
@@ -2914,6 +2937,29 @@ export class Session {
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels }); const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels });
await this.forwardAgentUpdate(snapshot); await this.forwardAgentUpdate(snapshot);
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
agentManager: this.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
initialPrompt: trimmedPrompt,
explicitTitle,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
const started = await this.handleSendAgentMessage(
snapshot.id,
trimmedPrompt,
resolveClientMessageId(clientMessageId),
images,
outputSchema ? { outputSchema } : undefined,
);
if (!started.ok) {
throw new Error(started.error);
}
}
if (requestId) { if (requestId) {
const agentPayload = await this.getAgentPayloadById(snapshot.id); const agentPayload = await this.getAgentPayloadById(snapshot.id);
if (!agentPayload) { if (!agentPayload) {
@@ -2930,40 +2976,6 @@ export class Session {
}); });
} }
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
agentManager: this.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
initialPrompt: trimmedPrompt,
explicitTitle,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
void this.handleSendAgentMessage(
snapshot.id,
trimmedPrompt,
resolveClientMessageId(clientMessageId),
images,
outputSchema ? { outputSchema } : undefined,
).catch((promptError) => {
this.sessionLogger.error(
{ err: promptError, agentId: snapshot.id },
`Failed to run initial prompt for agent ${snapshot.id}`,
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
},
});
});
}
if (worktreeConfig) { if (worktreeConfig) {
void runAsyncWorktreeBootstrap({ void runAsyncWorktreeBootstrap({
agentId: snapshot.id, agentId: snapshot.id,
@@ -3924,8 +3936,16 @@ export class Session {
); );
try { try {
await this.agentManager.respondToPermission(agentId, requestId, response); const result = await this.agentManager.respondToPermission(agentId, requestId, response);
this.sessionLogger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`); this.sessionLogger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`);
if (result?.followUpPrompt) {
this.sessionLogger.debug(
{ agentId },
"Permission response requires follow-up turn, starting agent stream",
);
this.startAgentStream(agentId, result.followUpPrompt);
}
} catch (error: any) { } catch (error: any) {
this.sessionLogger.error( this.sessionLogger.error(
{ err: error, agentId, requestId }, { err: error, agentId, requestId },
@@ -6095,7 +6115,7 @@ export class Session {
} }
async getAvailableEditorTargets() { async getAvailableEditorTargets() {
return listAvailableEditorTargets(); return this.filterEditorsForClient(await listAvailableEditorTargets());
} }
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> { async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
@@ -6602,6 +6622,7 @@ export class Session {
try { try {
let result = await this.agentManager.waitForAgentEvent(agentId, { let result = await this.agentManager.waitForAgentEvent(agentId, {
signal: abortController.signal, signal: abortController.signal,
waitForActive: true,
}); });
let final = await this.getAgentPayloadById(agentId); let final = await this.getAgentPayloadById(agentId);
if (!final) { if (!final) {
@@ -7522,7 +7543,9 @@ export class Session {
listStoredAgents: () => this.agentStorage.list(), listStoredAgents: () => this.agentStorage.list(),
listLiveAgents: () => this.agentManager.listAgents(), listLiveAgents: () => this.agentManager.listAgents(),
resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier), resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier),
sendAgentMessage: (agentId, text) => this.handleSendAgentMessage(agentId, text), sendAgentMessage: async (agentId, text) => {
await this.handleSendAgentMessage(agentId, text);
},
}); });
} catch (error) { } catch (error) {
this.emitChatRpcError(request, error); this.emitChatRpcError(request, error);
@@ -61,7 +61,7 @@ function makeAgent(input: {
}; };
} }
function createSessionForWorkspaceTests(): Session { function createSessionForWorkspaceTests(options: { appVersion?: string | null } = {}): Session {
const logger = { const logger = {
child: () => logger, child: () => logger,
trace: vi.fn(), trace: vi.fn(),
@@ -73,6 +73,7 @@ function createSessionForWorkspaceTests(): Session {
const session = new Session({ const session = new Session({
clientId: "test-client", clientId: "test-client",
appVersion: options.appVersion ?? null,
onMessage: vi.fn(), onMessage: vi.fn(),
logger: logger as any, logger: logger as any,
downloadTokenStore: {} as any, downloadTokenStore: {} as any,
@@ -1246,19 +1247,52 @@ describe("workspace aggregation", () => {
test("list_available_editors_request returns available targets", async () => { test("list_available_editors_request returns available targets", async () => {
const emitted: Array<{ type: string; payload: unknown }> = []; const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests() as any; const session = createSessionForWorkspaceTests({ appVersion: "0.1.50" }) as any;
session.emit = (message: any) => emitted.push(message); session.emit = (message: any) => emitted.push(message);
session.getAvailableEditorTargets = async () => [ session.getAvailableEditorTargets = async () =>
{ id: "cursor", label: "Cursor" }, session.filterEditorsForClient([
{ id: "finder", label: "Finder" }, { id: "cursor", label: "Cursor" },
]; { id: "webstorm", label: "WebStorm" },
{ id: "finder", label: "Finder" },
{ id: "unknown-editor", label: "Unknown Editor" },
]);
await session.handleMessage({ await session.handleMessage({
type: "list_available_editors_request", type: "list_available_editors_request",
requestId: "req-editors", requestId: "req-editors",
}); });
const response = emitted.find(
(message) => message.type === "list_available_editors_response",
) as any;
expect(response?.payload.error).toBeNull();
expect(response?.payload.editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "webstorm", label: "WebStorm" },
{ id: "finder", label: "Finder" },
{ id: "unknown-editor", label: "Unknown Editor" },
]);
});
test("list_available_editors_request filters unsupported ids for legacy clients", async () => {
const emitted: Array<{ type: string; payload: unknown }> = [];
const session = createSessionForWorkspaceTests({ appVersion: "0.1.49" }) as any;
session.emit = (message: any) => emitted.push(message);
session.getAvailableEditorTargets = async () =>
session.filterEditorsForClient([
{ id: "cursor", label: "Cursor" },
{ id: "webstorm", label: "WebStorm" },
{ id: "unknown-editor", label: "Unknown Editor" },
{ id: "finder", label: "Finder" },
]);
await session.handleMessage({
type: "list_available_editors_request",
requestId: "req-editors-legacy",
});
const response = emitted.find( const response = emitted.find(
(message) => message.type === "list_available_editors_response", (message) => message.type === "list_available_editors_response",
) as any; ) as any;
@@ -0,0 +1,3 @@
// Adapted from type-fest's LiteralUnion:
// https://github.com/sindresorhus/type-fest/blob/main/source/literal-union.d.ts
export type LiteralUnion<T extends U, U extends string> = T | (U & Record<never, never>);
+24 -3
View File
@@ -47,6 +47,7 @@ import {
LoopLogsResponseSchema, LoopLogsResponseSchema,
LoopStopResponseSchema, LoopStopResponseSchema,
} from "../server/loop/rpc-schemas.js"; } from "../server/loop/rpc-schemas.js";
import type { LiteralUnion } from "./literal-union.js";
import type { import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentModelDefinition, AgentModelDefinition,
@@ -1096,14 +1097,32 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const EditorTargetIdSchema = z.enum([ // TODO(2026-07): Remove once most clients are on >=0.1.50 and support arbitrary editor ids.
export const LEGACY_EDITOR_TARGET_IDS = [
"cursor", "cursor",
"vscode", "vscode",
"zed", "zed",
"finder", "finder",
"explorer", "explorer",
"file-manager", "file-manager",
]); ] as const;
export const KNOWN_EDITOR_TARGET_IDS = [...LEGACY_EDITOR_TARGET_IDS, "webstorm"] as const;
export const KnownEditorTargetIdSchema = z.enum(KNOWN_EDITOR_TARGET_IDS);
export const LegacyEditorTargetIdSchema = z.enum(LEGACY_EDITOR_TARGET_IDS);
export const EditorTargetIdSchema = z.string().trim().min(1);
const KNOWN_EDITOR_TARGET_ID_SET = new Set<string>(KNOWN_EDITOR_TARGET_IDS);
const LEGACY_EDITOR_TARGET_ID_SET = new Set<string>(LEGACY_EDITOR_TARGET_IDS);
export function isKnownEditorTargetId(value: string): value is KnownEditorTargetId {
return KNOWN_EDITOR_TARGET_ID_SET.has(value);
}
export function isLegacyEditorTargetId(value: string): value is LegacyEditorTargetId {
return LEGACY_EDITOR_TARGET_ID_SET.has(value);
}
export const EditorTargetDescriptorPayloadSchema = z.object({ export const EditorTargetDescriptorPayloadSchema = z.object({
id: EditorTargetIdSchema, id: EditorTargetIdSchema,
@@ -2626,7 +2645,9 @@ export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePaylo
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>; export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>; export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>; export type WorkspaceDescriptorPayload = z.infer<typeof WorkspaceDescriptorPayloadSchema>;
export type EditorTargetId = z.infer<typeof EditorTargetIdSchema>; export type KnownEditorTargetId = z.infer<typeof KnownEditorTargetIdSchema>;
export type LegacyEditorTargetId = z.infer<typeof LegacyEditorTargetIdSchema>;
export type EditorTargetId = LiteralUnion<KnownEditorTargetId, string>;
export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>; export type EditorTargetDescriptorPayload = z.infer<typeof EditorTargetDescriptorPayloadSchema>;
export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>; export type FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>; export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;
@@ -38,6 +38,24 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("list_available_editors_request"); expect(parsed.type).toBe("list_available_editors_request");
}); });
test("parses open_in_editor_request with flexible editor ids", () => {
const knownEditor = SessionInboundMessageSchema.parse({
type: "open_in_editor_request",
requestId: "req-open-webstorm",
editorId: "webstorm",
path: "/tmp/repo",
});
const unknownEditor = SessionInboundMessageSchema.parse({
type: "open_in_editor_request",
requestId: "req-open-custom",
editorId: "unknown-editor",
path: "/tmp/repo",
});
expect(knownEditor.type).toBe("open_in_editor_request");
expect(unknownEditor.type).toBe("open_in_editor_request");
});
test("parses open_in_editor_response", () => { test("parses open_in_editor_response", () => {
const parsed = SessionOutboundMessageSchema.parse({ const parsed = SessionOutboundMessageSchema.parse({
type: "open_in_editor_response", type: "open_in_editor_response",
@@ -50,6 +68,33 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("open_in_editor_response"); expect(parsed.type).toBe("open_in_editor_response");
}); });
test("parses list_available_editors_response with unknown editor ids", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "list_available_editors_response",
payload: {
requestId: "req-editors",
editors: [
{ id: "cursor", label: "Cursor" },
{ id: "unknown-editor", label: "Unknown Editor" },
],
error: null,
},
});
expect(parsed.type).toBe("list_available_editors_response");
});
test("rejects empty editor ids", () => {
const result = SessionInboundMessageSchema.safeParse({
type: "open_in_editor_request",
requestId: "req-open-empty",
editorId: "",
path: "/tmp/repo",
});
expect(result.success).toBe(false);
});
test("rejects invalid workspace update payload", () => { test("rejects invalid workspace update payload", () => {
const result = SessionOutboundMessageSchema.safeParse({ const result = SessionOutboundMessageSchema.safeParse({
type: "workspace_update", type: "workspace_update",
+9 -8
View File
@@ -1,4 +1,5 @@
import { exec, execFile, spawn } from "child_process"; import { exec, execFile } from "child_process";
import { spawnProcess } from "./spawn.js";
import { promisify } from "util"; import { promisify } from "util";
import { resolve, dirname, basename } from "path"; import { resolve, dirname, basename } from "path";
import { realpathSync } from "fs"; import { realpathSync } from "fs";
@@ -87,7 +88,7 @@ async function spawnLimitedText(params: {
const accept = new Set(params.acceptExitCodes ?? [0]); const accept = new Set(params.acceptExitCodes ?? [0]);
return new Promise((resolvePromise, rejectPromise) => { return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(params.cmd, params.args, { const child = spawnProcess(params.cmd, params.args, {
cwd: params.cwd, cwd: params.cwd,
env: params.env, env: params.env,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
@@ -106,7 +107,7 @@ async function spawnLimitedText(params: {
} }
}; };
child.stdout.on("data", (chunk: Buffer) => { child.stdout!.on("data", (chunk: Buffer) => {
if (truncated) return; if (truncated) return;
stdoutBytes += chunk.length; stdoutBytes += chunk.length;
if (stdoutBytes > params.maxBytes) { if (stdoutBytes > params.maxBytes) {
@@ -119,7 +120,7 @@ async function spawnLimitedText(params: {
// We don't buffer stderr (it can be large too). Keep it minimal for debugging. // We don't buffer stderr (it can be large too). Keep it minimal for debugging.
let stderrPreview = ""; let stderrPreview = "";
child.stderr.on("data", (chunk: Buffer) => { child.stderr!.on("data", (chunk: Buffer) => {
if (stderrPreview.length > 2048) return; if (stderrPreview.length > 2048) return;
stderrPreview += chunk.toString("utf8"); stderrPreview += chunk.toString("utf8");
}); });
@@ -1783,9 +1784,9 @@ export interface PullRequestStatusResult {
githubFeaturesEnabled: boolean; githubFeaturesEnabled: boolean;
} }
function resolveGhPath(): string { async function resolveGhPath(): Promise<string> {
if (cachedGhPath === undefined) { if (cachedGhPath === undefined) {
cachedGhPath = findExecutable("gh"); cachedGhPath = await findExecutable("gh");
} }
if (cachedGhPath === null) { if (cachedGhPath === null) {
throw new Error("GitHub CLI (gh) is not installed or not in PATH"); throw new Error("GitHub CLI (gh) is not installed or not in PATH");
@@ -1859,7 +1860,7 @@ export async function createPullRequest(
options: CreatePullRequestOptions, options: CreatePullRequestOptions,
): Promise<{ url: string; number: number }> { ): Promise<{ url: string; number: number }> {
await requireGitRepo(cwd); await requireGitRepo(cwd);
const ghPath = resolveGhPath(); const ghPath = await resolveGhPath();
const repo = await resolveGitHubRepo(cwd); const repo = await resolveGitHubRepo(cwd);
if (!repo) { if (!repo) {
throw new Error("Unable to determine GitHub repo from git remote"); throw new Error("Unable to determine GitHub repo from git remote");
@@ -1932,7 +1933,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
} }
let ghPath: string; let ghPath: string;
try { try {
ghPath = resolveGhPath(); ghPath = await resolveGhPath();
} catch { } catch {
return { return {
status: null, status: null,
+19 -45
View File
@@ -1,12 +1,12 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { import {
findExecutable, findExecutableSync,
quoteWindowsArgument, quoteWindowsArgument,
quoteWindowsCommand, quoteWindowsCommand,
} from "./executable.js"; } from "./executable.js";
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutable>[1]>; type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutableSync>[1]>;
function createFindExecutableDependencies(): FindExecutableDependencies { function createFindExecutableDependencies(): FindExecutableDependencies {
return { return {
@@ -22,57 +22,31 @@ beforeEach(() => {
findExecutableDependencies = createFindExecutableDependencies(); findExecutableDependencies = createFindExecutableDependencies();
}); });
describe("findExecutable", () => { describe("findExecutableSync", () => {
test("on Windows, resolves executables using current machine and user PATH entries", () => { test("on Windows, resolves executables using where.exe with inherited PATH", () => {
findExecutableDependencies.platform = vi.fn(() => "win32"); findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32"; findExecutableDependencies.execFileSync.mockReturnValue(
findExecutableDependencies.execFileSync.mockImplementation( "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n",
((command: string, args?: string[]) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\Users\\boudr\\.local\\bin\r\n";
}
if (command === "where.exe") {
return "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
); );
expect(findExecutable("claude", findExecutableDependencies)).toBe( expect(findExecutableSync("claude", findExecutableDependencies)).toBe(
"C:\\Users\\boudr\\.local\\bin\\claude.exe", "C:\\Users\\boudr\\.local\\bin\\claude.exe",
); );
const powershellCall = findExecutableDependencies.execFileSync.mock.calls[0]; expect(findExecutableDependencies.execFileSync).toHaveBeenCalledOnce();
expect(powershellCall?.[0]).toBe("powershell"); const call = findExecutableDependencies.execFileSync.mock.calls[0];
expect(powershellCall?.[1]).toContain("-NoProfile"); expect(call?.[0]).toBe("where.exe");
expect(powershellCall?.[1]).toContain("-NonInteractive"); expect(call?.[1]).toEqual(["claude"]);
expect(powershellCall?.[1]).toContain( expect(call?.[2]?.encoding).toBe("utf8");
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine"); $user = [Environment]::GetEnvironmentVariable("Path", "User"); if ($machine) { Write-Output $machine }; if ($user) { Write-Output $user }', expect(call?.[2]?.windowsHide).toBe(true);
);
const whereCall = findExecutableDependencies.execFileSync.mock.calls[1];
expect(whereCall?.[0]).toBe("where.exe");
expect(whereCall?.[1]).toEqual(["claude"]);
expect(whereCall?.[2]?.encoding).toBe("utf8");
const env = whereCall?.[2]?.env as Record<string, string | undefined>;
expect(env.PATH).toContain("C:\\Users\\boudr\\.local\\bin");
expect(env.Path).toContain("C:\\Users\\boudr\\.local\\bin");
}); });
test("on Windows, preserves the first where.exe match", () => { test("on Windows, preserves the first where.exe match", () => {
findExecutableDependencies.platform = vi.fn(() => "win32"); findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32"; findExecutableDependencies.execFileSync.mockReturnValue(
findExecutableDependencies.execFileSync.mockImplementation( "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n",
((command: string) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\nvm4w\\nodejs\r\n";
}
if (command === "where.exe") {
return "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
); );
expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex"); expect(findExecutableSync("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex");
}); });
test("on Unix, uses the last line from which output", () => { test("on Unix, uses the last line from which output", () => {
@@ -80,7 +54,7 @@ describe("findExecutable", () => {
"/usr/local/bin/codex\n", "/usr/local/bin/codex\n",
); );
expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex"); expect(findExecutableSync("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith( expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith(
"which", "which",
["codex"], ["codex"],
@@ -92,7 +66,7 @@ describe("findExecutable", () => {
findExecutableDependencies.execFileSync.mockReturnValue("codex\n"); findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(findExecutable("codex", findExecutableDependencies)).toBeNull(); expect(findExecutableSync("codex", findExecutableDependencies)).toBeNull();
expect(warnSpy).toHaveBeenCalledOnce(); expect(warnSpy).toHaveBeenCalledOnce();
warnSpy.mockRestore(); warnSpy.mockRestore();
@@ -101,7 +75,7 @@ describe("findExecutable", () => {
test("returns direct paths when they exist", () => { test("returns direct paths when they exist", () => {
findExecutableDependencies.existsSync.mockReturnValue(true); findExecutableDependencies.existsSync.mockReturnValue(true);
expect(findExecutable("/usr/local/bin/codex", findExecutableDependencies)).toBe( expect(findExecutableSync("/usr/local/bin/codex", findExecutableDependencies)).toBe(
"/usr/local/bin/codex", "/usr/local/bin/codex",
); );
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex"); expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
+56 -52
View File
@@ -1,40 +1,17 @@
import { execFileSync } from "node:child_process"; import { execFile, execFileSync } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { platform } from "node:os"; import { platform } from "node:os";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface FindExecutableDependencies { export interface FindExecutableDependencies {
execFileSync: typeof execFileSync; execFileSync: typeof execFileSync;
existsSync: typeof existsSync; existsSync: typeof existsSync;
platform: typeof platform; platform: typeof platform;
} }
function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] {
try {
const output = deps.execFileSync(
"powershell",
[
"-NoProfile",
"-NonInteractive",
"-Command",
[
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine")',
'$user = [Environment]::GetEnvironmentVariable("Path", "User")',
"if ($machine) { Write-Output $machine }",
"if ($user) { Write-Output $user }",
].join("; "),
],
{ encoding: "utf8" },
);
return output
.split(/\r?\n/)
.flatMap((line) => line.split(";"))
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
} catch {
return [];
}
}
function resolveExecutableFromWhichOutput( function resolveExecutableFromWhichOutput(
name: string, name: string,
output: string, output: string,
@@ -61,15 +38,13 @@ function resolveExecutableFromWhichOutput(
} }
/** /**
* On Unix we use plain `which` — the daemon's process.env.PATH is enriched * On Unix we use `which`. On Windows we use `where.exe`.
* with the login shell environment at Electron desktop startup.
* *
* On Windows we augment the daemon PATH with machine/user registry PATH values * Both rely on the inherited process.env.PATH — on macOS/Linux, Electron
* and return the first `where.exe` match. Launch-time execution decides whether * enriches it at startup via inheritLoginShellEnv(); on Windows, Electron
* the resolved path needs `cmd.exe` semantics (for example npm shims under * inherits the full user environment from Explorer.
* nvm4w such as `C:\nvm4w\nodejs\codex`).
*/ */
export function findExecutable( export function findExecutableSync(
name: string, name: string,
dependencies?: FindExecutableDependencies, dependencies?: FindExecutableDependencies,
): string | null { ): string | null {
@@ -91,21 +66,10 @@ export function findExecutable(
if (deps.platform() === "win32") { if (deps.platform() === "win32") {
try { try {
const inheritedPath = process.env["Path"] ?? process.env["PATH"] ?? ""; const out = deps.execFileSync("where.exe", [trimmed], {
const resolvedPath = [ encoding: "utf8",
...inheritedPath.split(";"), windowsHide: true,
...resolveWindowsPathEntries(deps), }).trim();
]
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.filter((entry, index, entries) => entries.indexOf(entry) === index)
.join(";");
const env = {
...process.env,
PATH: resolvedPath,
Path: resolvedPath,
};
const out = deps.execFileSync("where.exe", [trimmed], { encoding: "utf8", env }).trim();
return ( return (
out out
.split(/\r?\n/) .split(/\r?\n/)
@@ -128,8 +92,48 @@ export function findExecutable(
} }
} }
export function isCommandAvailable(command: string): boolean { export function isCommandAvailableSync(command: string): boolean {
return findExecutable(command) !== null; return findExecutableSync(command) !== null;
}
export async function findExecutable(name: string): Promise<string | null> {
const trimmed = name.trim();
if (!trimmed) {
return null;
}
if (trimmed.includes("/") || trimmed.includes("\\")) {
return existsSync(trimmed) ? trimmed : null;
}
if (platform() === "win32") {
try {
const { stdout } = await execFileAsync("where.exe", [trimmed], {
encoding: "utf8",
windowsHide: true,
});
return (
stdout
.trim()
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? null
);
} catch {
return null;
}
}
try {
const { stdout } = await execFileAsync("which", [trimmed], { encoding: "utf8" });
return resolveExecutableFromWhichOutput(trimmed, stdout.trim(), "which");
} catch {
return null;
}
}
export async function isCommandAvailable(command: string): Promise<boolean> {
return (await findExecutable(command)) !== null;
} }
/** /**
+42
View File
@@ -0,0 +1,42 @@
import {
spawn,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
/**
* Platform-aware spawn that centralizes Windows shell and quoting concerns.
*
* On Windows:
* - Enables `shell: true` (routes through cmd.exe) unless the caller explicitly sets `shell`
* - Quotes the command and arguments so paths with spaces survive cmd.exe parsing
* - Always sets `windowsHide: true` to prevent console window flashes
*
* On other platforms the call is passed through to `spawn` unchanged (with `windowsHide: true`).
*/
export function spawnProcess(
command: string,
args: string[],
options?: SpawnOptions,
): ChildProcess {
const isWindows = process.platform === "win32";
const resolvedCommand = isWindows ? quoteForCmd(command) : command;
const resolvedArgs = isWindows ? args.map(quoteForCmd) : args;
return spawn(resolvedCommand, resolvedArgs, {
...options,
shell: options?.shell ?? isWindows,
windowsHide: true,
});
}
/**
* Quote a string for cmd.exe if it contains spaces and isn't already quoted.
* No-op for strings without spaces or strings that are already double-quoted.
*/
function quoteForCmd(value: string): string {
if (!value.includes(" ")) return value;
if (value.startsWith('"') && value.endsWith('"')) return value;
return `"${value}"`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@getpaseo/website", "name": "@getpaseo/website",
"version": "0.1.50", "version": "0.1.51-rc.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+6
View File
@@ -53,6 +53,12 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
<html lang="en"> <html lang="en">
<head> <head>
<HeadContent /> <HeadContent />
<script async src="https://plausible.io/js/pa-cKNUoWbeH_Iksb2fh82s3.js" />
<script
dangerouslySetInnerHTML={{
__html: `window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()`,
}}
/>
</head> </head>
<body className="antialiased bg-background text-foreground"> <body className="antialiased bg-background text-foreground">
{children} {children}
+2 -2
View File
@@ -77,8 +77,8 @@ function UpdatesDocs() {
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-xl font-medium">Mobile apps</h2> <h2 className="text-xl font-medium">Mobile apps</h2>
<p className="text-white/60"> <p className="text-white/60">
Mobile apps are not yet publicly available in the App Store or Play Store. We are working Mobile apps are available on the App Store and Play Store. Update through your respective
on public store availability. store. Store versions may lag behind the latest release due to review processes.
</p> </p>
</section> </section>
</div> </div>
+1 -1
View File
@@ -15,7 +15,7 @@ export const Route = createFileRoute("/")({
function Home() { function Home() {
return ( return (
<LandingPage <LandingPage
title={<>The development environment<br />built for coding agents</>} title={<>Orchestrate coding agents<br />from your desk and your phone</>}
subtitle="Run any coding agent from your phone, desktop, or terminal. Self-hosted, multi-provider, open source." subtitle="Run any coding agent from your phone, desktop, or terminal. Self-hosted, multi-provider, open source."
/> />
); );