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

View File

@@ -35,8 +35,43 @@ env:
DESKTOP_PACKAGE_PATH: 'packages/desktop'
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:
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:
fail-fast: false
matrix:
@@ -219,7 +254,8 @@ jobs:
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
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:
contents: write
packages: read
@@ -285,7 +321,8 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
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:
contents: write
packages: read

View File

@@ -3,40 +3,36 @@
## 0.1.50 - 2026-04-07
### 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%.
- 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.
- Side-by-side diff layout — toggle between unified and split-column diff views in the Changes pane, with a whitespace visibility toggle.
- Spoken messages — voice-mode speak tool calls now render inline in the conversation as labeled spoken messages instead of raw tool call blocks.
- Plan approval actions — plan permission cards now show provider-defined action buttons (e.g. "Implement", "Deny") instead of hardcoded accept/reject.
- Background git fetch — the daemon periodically fetches from origin so the Changes pane shows accurate ahead/behind counts without manual refreshes.
- 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 — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
### Improved
- File explorer and diff pane expanded/collapsed state persists across tab switches and rehydration.
- Workspace list and updates are served instantly on connect; reconciliation happens in the background, eliminating the initial loading delay.
- Provider list in Settings now includes a Refresh button and shows inline error details.
- Workspace tabs close optimistically — the tab disappears immediately while the daemon archives the agent in the background.
- Reload agent action moved away from the close button to prevent accidental taps.
- Workspaces load instantly on connect instead of waiting for a full sync.
- File explorer and diff pane remember which folders are expanded when you switch tabs.
- Closing a workspace tab is now instant.
- Settings shows a Refresh button for providers and displays error details inline.
- Reload agent moved away from the close button to prevent accidental taps.
### Fixed
- WorkingIndicator no longer remounts on every stream update on native.
- Silero VAD state is now reset between voice turns, preventing LSTM drift that could cause false speech detections in long sessions.
- OpenCode context window meter updates correctly after the first turn.
- Garbled overlapping text in plan card markdown.
- Worktree branch tracking now prefers `origin/{branch}` over the local branch ref, fixing stale diff baselines.
- Session ID reset on query restart prevents an overwrite crash when restarting an agent quickly.
- Copilot ACP permission prompts are now bypassed in autopilot mode.
- Direct connection and pairing modal content now displays correctly on tablets.
- `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.
- Voice mode no longer drifts into false speech detection during long sessions.
- Garbled overlapping text on plan cards.
- Changes pane could show stale diffs when working with git worktrees.
- Restarting an agent quickly could crash the session.
- Copilot no longer pauses for permission prompts in autopilot mode.
- Connection and pairing dialogs now display correctly on tablets.
- Orchestration errors from agents are now surfaced instead of silently lost.
- Diff stats no longer reset to zero when reconnecting.
## 0.1.49 - 2026-04-07
### Fixed
- Models and providers now load reliably on first app connect instead of requiring a second status refresh.
- Model picker on running agents now 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.
- Draft and running-agent flows now share the same provider data path, eliminating stale model lists from legacy fallbacks.
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
- Model picker only shows models from the agent's own provider, not every provider on the server.
- Model lists stay consistent regardless of which screen you open first.
## 0.1.48 - 2026-04-05

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.**
- 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
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# 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
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

38
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34906,16 +34906,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.50",
"@getpaseo/highlight": "0.1.50",
"@getpaseo/server": "0.1.50",
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
"@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35032,11 +35032,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.50",
"@getpaseo/server": "0.1.50",
"@getpaseo/relay": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35077,11 +35077,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.50",
"@getpaseo/server": "0.1.50",
"@getpaseo/cli": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35115,7 +35115,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35316,7 +35316,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35342,7 +35342,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35358,14 +35358,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.50",
"@getpaseo/relay": "0.1.50",
"@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/relay": "0.1.51-rc.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35764,7 +35764,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.50",
"@getpaseo/highlight": "0.1.50",
"@getpaseo/server": "0.1.50",
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
"@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -369,10 +369,41 @@ function AppContainer({
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const isCompactLayout = isCompactFormFactor();
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({
enabled: chromeEnabled,
isMobile: isCompactLayout,
@@ -574,7 +605,7 @@ function OfferLinkListener({
if (cancelled) return;
const serverId = (profile as any)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
})
.catch((error) => {
if (cancelled) return;
@@ -692,7 +723,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
if (hosts.some((host) => host.serverId === activeServerId)) {
return;
}
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId) as any);
router.replace(mapPathnameToServer(pathname, hosts[0]!.serverId));
}, [activeServerId, hosts, pathname, router]);
// Parse selectedAgentKey directly from pathname

View File

@@ -58,7 +58,7 @@ export default function HostAgentReadyRoute() {
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
}
}, [agentCwd, agentId, client, isConnected, router, serverId]);
@@ -89,14 +89,14 @@ export default function HostAgentReadyRoute() {
);
return;
}
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
})
.catch(() => {
if (cancelled || redirectedRef.current) {
return;
}
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
});
return () => {

View File

@@ -68,11 +68,11 @@ export default function HostIndexRoute() {
const primaryWorkspace = visibleWorkspaces[0];
if (primaryWorkspace?.id?.trim()) {
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
return;
}
router.replace(buildHostOpenProjectRoute(serverId) as any);
router.replace(buildHostOpenProjectRoute(serverId));
}, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer);

View File

@@ -58,7 +58,7 @@ export default function Index() {
const targetRoute = anyOnlineServerId
? buildHostRootRoute(anyOnlineServerId)
: WELCOME_ROUTE;
router.replace(targetRoute as any);
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
return <StartupSplashScreen bootstrapState={bootstrapState} />;

View File

@@ -174,7 +174,7 @@ export default function PairScanScreen() {
const returnToSource = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
return;
}
if (source === "editHost" && targetServerId) {
@@ -190,7 +190,7 @@ export default function PairScanScreen() {
router.back();
} catch {
const settingsServerId = sourceServerId ?? serverId;
router.replace(buildHostSettingsRoute(settingsServerId) as any);
router.replace(buildHostSettingsRoute(settingsServerId));
}
},
[router, source, sourceServerId, targetServerId],
@@ -209,7 +209,7 @@ export default function PairScanScreen() {
router.back();
} catch {
if (sourceServerId) {
router.replace(buildHostSettingsRoute(sourceServerId) as any);
router.replace(buildHostSettingsRoute(sourceServerId));
return;
}
router.replace("/" as any);

View File

@@ -16,7 +16,7 @@ export default function LegacySettingsRoute() {
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId) as any);
router.replace(buildHostSettingsRoute(targetServerId));
}, [router, targetServerId]);
if (!targetServerId) {

View File

@@ -240,7 +240,7 @@ export function AgentList({
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
});
router.navigate(route as any);
router.navigate(route);
},
[isActionSheetVisible, onAgentSelect],
);

View File

@@ -182,7 +182,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route as any);
router.navigate(route);
return;
}

View File

@@ -983,7 +983,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";

View File

@@ -1,5 +1,10 @@
import { SquareTerminal } from "lucide-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 {
editorId: EditorTargetId;
@@ -8,9 +13,10 @@ interface EditorAppIconProps {
}
/* 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"),
vscode: require("../../../assets/images/editor-apps/vscode.png"),
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
zed: require("../../../assets/images/editor-apps/zed.png"),
finder: require("../../../assets/images/editor-apps/finder.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 */
export function hasBundledEditorAppIcon(
editorId: EditorTargetId,
): editorId is KnownEditorTargetId {
return isKnownEditorTargetId(editorId);
}
export function EditorAppIcon({
editorId,
size = 16,
color,
}: EditorAppIconProps) {
if (!hasBundledEditorAppIcon(editorId)) {
return <SquareTerminal size={size} color={color} />;
}
return (
<Image
source={EDITOR_APP_IMAGES[editorId]}

View File

@@ -229,21 +229,21 @@ export const LeftSidebar = memo(function LeftSidebar({
return;
}
closeToAgent();
router.push(buildHostSettingsRoute(activeServerId) as any);
router.push(buildHostSettingsRoute(activeServerId));
}, [activeServerId, closeToAgent]);
const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) {
return;
}
router.push(buildHostSettingsRoute(activeServerId) as any);
router.push(buildHostSettingsRoute(activeServerId));
}, [activeServerId]);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
return;
}
router.push(buildHostSessionsRoute(activeServerId) as any);
router.push(buildHostSessionsRoute(activeServerId));
}, [activeServerId]);
const handleHostSelect = useCallback(
@@ -253,7 +253,7 @@ export const LeftSidebar = memo(function LeftSidebar({
}
const nextPath = mapPathnameToServer(pathname, nextServerId);
setIsHostPickerOpen(false);
router.push(nextPath as any);
router.push(nextPath);
},
[pathname],
);

View File

@@ -1113,7 +1113,7 @@ function WorkspaceRowWithMenu({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
}) as any,
}),
);
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);

View File

@@ -270,12 +270,12 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
if (pendingNameHost) {
return;
}
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
router.replace(buildHostRootRoute(anyOnlineServerId));
}, [anyOnlineServerId, pendingNameHost, router]);
const finishOnboarding = useCallback(
(serverId: string) => {
router.replace(buildHostRootRoute(serverId) as any);
router.replace(buildHostRootRoute(serverId));
},
[router],
);

View File

@@ -1,7 +1,6 @@
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 QRCode from "qrcode";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
@@ -12,7 +11,6 @@ import {
RotateCw,
Copy,
FileText,
Smartphone,
Activity,
} from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
@@ -24,7 +22,6 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus,
restartDesktopDaemon,
shouldUseDesktopDaemon,
@@ -32,7 +29,6 @@ import {
stopDesktopDaemon,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
} from "@/desktop/daemon/desktop-daemon";
export interface LocalDaemonSectionProps {
@@ -52,10 +48,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
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 [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
@@ -238,46 +230,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
setIsLogsModalOpen(true);
}, [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 () => {
setIsLoadingCliStatus(true);
try {
@@ -418,20 +370,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button>
</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.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
@@ -460,20 +398,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
) : 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
visible={isLogsModalOpen}
onClose={() => setIsLogsModalOpen(false)}
@@ -516,107 +440,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
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) => ({
actionGroup: {
flexDirection: "row",
@@ -659,40 +482,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
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: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,

View File

@@ -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,
}));

View File

@@ -165,7 +165,7 @@ export function useCommandCenter() {
const settingsRoute = useMemo<Href>(() => {
const serverIdFromPath = activeServerId;
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/";
}, [activeServerId]);
const actionItems = useMemo(() => {
@@ -220,7 +220,7 @@ export function useCommandCenter() {
workspaceId: agent.cwd,
target: { kind: "agent", agentId: agent.id },
});
router.navigate(route as any);
router.navigate(route);
},
[setOpen],
);

View File

@@ -190,7 +190,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any);
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";

View File

@@ -14,6 +14,12 @@ describe("resolvePreferredEditorId", () => {
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", () => {
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
});

View File

@@ -11,7 +11,7 @@ import { buildHostWorkspaceRoute } from "@/utils/host-routes";
*/
export function navigateToWorkspace(serverId: string, workspaceId: string) {
const href = buildHostWorkspaceRoute(serverId, workspaceId);
router.navigate(href as any);
router.navigate(href);
}
export function useWorkspaceNavigation() {

View File

@@ -54,7 +54,7 @@ function SessionsScreenContent({ serverId }: { serverId: string }) {
<Button
variant="ghost"
leftIcon={ChevronLeft}
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId) as any)}
onPress={() => router.navigate(buildHostOpenProjectRoute(serverId))}
>
Back
</Button>

View File

@@ -22,6 +22,7 @@ import {
Shield,
Puzzle,
Blocks,
Smartphone,
} from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
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 { IntegrationsSection } from "@/desktop/components/integrations-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
import { isElectronRuntime } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -82,7 +84,8 @@ type SettingsSectionId =
| "diagnostics"
| "about"
| "permissions"
| "daemon";
| "daemon"
| "pair-device";
interface SettingsSectionDef {
id: SettingsSectionId;
@@ -101,6 +104,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
if (context.isDesktopApp) {
sections.push(
{ id: "integrations", label: "Integrations", icon: Puzzle },
{ id: "pair-device", label: "Pair device", icon: Smartphone },
{ id: "daemon", label: "Daemon", icon: Settings },
);
}
@@ -647,6 +651,8 @@ function SettingsSectionContent({
return isDesktopApp ? <IntegrationsSection /> : null;
case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "pair-device":
return isDesktopApp ? <PairDeviceSection /> : null;
case "daemon":
return isDesktopApp ? (
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />

View File

@@ -268,99 +268,99 @@ export function parseHostWorkspaceRouteFromPathname(
return { serverId, workspaceId };
}
export function buildHostWorkspaceRoute(serverId: string, workspaceId: string): string {
export function buildHostWorkspaceRoute(serverId: string, workspaceId: string) {
const normalizedServerId = trimNonEmpty(serverId);
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (!normalizedServerId || !normalizedWorkspaceId) {
return "/";
return "/" as const;
}
const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(normalizedWorkspaceId);
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(
serverId: string,
agentId: string,
workspaceId?: string,
): string {
) {
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
if (normalizedWorkspaceId) {
const normalizedAgentId = trimNonEmpty(agentId);
if (!normalizedAgentId) {
return "/";
return "/" as const;
}
const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId);
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 normalizedAgentId = trimNonEmpty(agentId);
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);
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);
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);
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);
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);
if (!normalized) {
return "/";
return "/" as const;
}
const suffix = pathname.replace(/^\/h\/[^/]+\/?/, "");
const base = buildHostRootRoute(normalized);
if (suffix.startsWith("settings")) {
return `${base}/settings`;
return `${base}/settings` as const;
}
if (suffix.startsWith("sessions")) {
return `${base}/sessions`;
return `${base}/sessions` as const;
}
if (suffix.startsWith("open-project")) {
return `${base}/open-project`;
return `${base}/open-project` as const;
}
const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
if (workspaceRoute) {
return buildHostWorkspaceRoute(normalized, workspaceRoute.workspaceId);
}
if (suffix.startsWith("agent/")) {
return `${base}/${suffix}`;
return `${base}/${suffix}` as const;
}
return base;
}

View File

@@ -69,7 +69,7 @@ export function resolveNewAgentWorkingDir(
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();
return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || ".");
}

View File

@@ -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);
if (serverId && agentId) {
if (workspaceId) {
const base = buildHostWorkspaceRoute(serverId, workspaceId);
return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}`;
return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}` as const;
}
return buildHostAgentDetailRoute(serverId, agentId);
}
if (serverId) {
return buildHostRootRoute(serverId);
}
return "/";
return "/" as const;
}

View File

@@ -52,7 +52,7 @@ export function buildWorkspaceArchiveRedirectRoute(input: {
serverId: string;
archivedWorkspaceId: string;
workspaces: Iterable<WorkspaceDescriptor>;
}): string {
}) {
const redirectWorkspaceId = resolveWorkspaceArchiveRedirectWorkspaceId({
archivedWorkspaceId: input.archivedWorkspaceId,
workspaces: input.workspaces,

View File

@@ -20,7 +20,7 @@ function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
return { kind: "draft", draftId: generateDraftId() };
}
export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string {
export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput) {
const target = getPreparedTarget(input.target);
const key =
buildWorkspaceTabPersistenceKey({

View File

@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.50",
"@getpaseo/server": "0.1.50",
"@getpaseo/relay": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,12 +1,14 @@
import type { Command } from "commander";
import { execFileSync } from "node:child_process";
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { promisify } from "node:util";
import {
getOrCreateServerId,
findExecutable,
quoteWindowsCommand,
applyProviderEnv,
} from "@getpaseo/server";
const execFileAsync = promisify(execFile);
import { tryConnectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
@@ -171,31 +173,33 @@ const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: "OpenCode", binary: "opencode" },
];
function checkProviderBinary(binary: string): { path: string | null; version: string | null } {
const binaryPath = findExecutable(binary);
async function checkProviderBinary(binary: string): Promise<{ path: string | null; version: string | null }> {
const binaryPath = await findExecutable(binary);
if (!binaryPath) {
return { path: null, version: null };
}
const env = applyProviderEnv(process.env);
try {
const output = execFileSync(quoteWindowsCommand(binaryPath), ["--version"], {
const { stdout } = await execFileAsync(binaryPath, ["--version"], {
encoding: "utf8",
timeout: 5000,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: process.platform === "win32",
}).trim();
return { path: binaryPath, version: output || null };
windowsHide: true,
});
return { path: binaryPath, version: stdout.trim() || null };
} catch {
return { path: binaryPath, version: null };
}
}
function checkProviderBinaries(): ProviderBinaryStatus[] {
return PROVIDER_BINARIES.map(({ label, binary }) => {
const result = checkProviderBinary(binary);
return { label, ...result };
});
async function checkProviderBinaries(): Promise<ProviderBinaryStatus[]> {
const results = await Promise.all(
PROVIDER_BINARIES.map(async ({ label, binary }) => {
const result = await checkProviderBinary(binary);
return { label, ...result };
}),
);
return results;
}
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))}`);
}
const providers = checkProviderBinaries();
const providers = await checkProviderBinaries();
const daemonStatus: DaemonStatus = {
serverId,

View File

@@ -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();

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.50",
"@getpaseo/server": "0.1.50",
"@getpaseo/cli": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"

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 path from "node:path";
import { app, ipcMain } from "electron";
import log from "electron-log/main";
import { resolvePaseoHome } from "@getpaseo/server";
import { resolvePaseoHome, spawnProcess } from "@getpaseo/server";
import {
copyAttachmentFileToManagedStorage,
deleteManagedAttachmentFile,
@@ -266,15 +266,11 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
args: invocation.args,
});
const child: ChildProcess = spawn(
invocation.command,
invocation.args,
{
detached: true,
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
stdio: ["ignore", "ignore", "ignore"],
},
);
const child: ChildProcess = spawnProcess(invocation.command, invocation.args, {
detached: true,
env: { ...invocation.env, PASEO_DESKTOP_MANAGED: "1" },
stdio: ["ignore", "ignore", "ignore"],
});
logDesktopDaemonLifecycle("detached spawn returned", {
childPid: child.pid ?? null,

View File

@@ -1,5 +1,6 @@
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 path from "node:path";
import { app } from "electron";
@@ -234,6 +235,7 @@ export function runCliPassthroughCommand(args: string[]): number {
const result = spawnSync(invocation.command, invocation.args, {
env: invocation.env,
stdio: "inherit",
windowsHide: true,
});
if (result.error) {
throw result.error;
@@ -252,7 +254,7 @@ function spawnAsync(
options: { env: NodeJS.ProcessEnv },
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
const child = spawnProcess(command, args, {
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -260,10 +262,10 @@ function spawnAsync(
let stdout = "";
let stderr = "";
child.stdout.on("data", (data: Buffer) => {
child.stdout!.on("data", (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on("data", (data: Buffer) => {
child.stderr!.on("data", (data: Buffer) => {
stderr += data.toString();
});

View File

@@ -200,7 +200,20 @@ export async function installCli(): Promise<InstallStatus> {
if (await pathOrSymlinkExists(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 {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);

View File

@@ -1,4 +1,5 @@
import log from "electron-log/main";
log.transports.console.level = "info";
log.initialize({ spyRendererConsole: true });
import { inheritLoginShellEnv } from "./login-shell-env.js";
@@ -37,6 +38,18 @@ const APP_SCHEME = "paseo";
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
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({
argv: process.argv,
isDefaultApp: process.defaultApp,
@@ -134,6 +147,7 @@ async function createMainWindow(): Promise<void> {
setupWindowResizeEvents(mainWindow);
setupDefaultContextMenu(mainWindow);
setupDragDropPrevention(mainWindow);
mainWindow.once("ready-to-show", () => {
mainWindow.show();
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"type": "module",
"publishConfig": {
"access": "public"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -64,8 +64,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.50",
"@getpaseo/relay": "0.1.50",
"@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/relay": "0.1.51-rc.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",

View File

@@ -20,11 +20,11 @@ import {
query,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { isCommandAvailable } from "../utils/executable.js";
import { isCommandAvailableSync } from "../utils/executable.js";
const hasClaudeCredentials =
!!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():
// Use an empty async generator when you just need control methods

View File

@@ -17,6 +17,7 @@ import type {
AgentMode,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPermissionResult,
AgentPersistenceHandle,
AgentPromptInput,
AgentProvider,
@@ -1156,11 +1157,10 @@ export class AgentManager {
agent.lastError = undefined;
const self = this;
const pendingRun = self.createPendingForegroundRun();
self.pendingForegroundRuns.set(agentId, pendingRun);
const streamForwarder = (async function* streamForwarder() {
const pendingRun = self.createPendingForegroundRun();
self.pendingForegroundRuns.set(agentId, pendingRun);
let turnId: string;
let waiter: ForegroundTurnWaiter | null = null;
try {
@@ -1440,12 +1440,12 @@ export class AgentManager {
agentId: string,
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
): Promise<AgentPermissionResult | void> {
const agent = this.requireAgent(agentId);
agent.inFlightPermissionResponses.add(requestId);
try {
await agent.session.respondToPermission(requestId, response);
const result = await agent.session.respondToPermission(requestId, response);
agent.pendingPermissions.delete(requestId);
try {
@@ -1463,6 +1463,8 @@ export class AgentManager {
agent.bufferedPermissionResolutions.delete(requestId);
this.dispatchStream(agent.id, bufferedResolution);
}
return result;
} finally {
agent.inFlightPermissionResponses.delete(requestId);
agent.bufferedPermissionResolutions.delete(requestId);
@@ -1625,8 +1627,9 @@ export class AgentManager {
throw new Error(`Agent ${agentId} not found`);
}
const pendingForegroundRun = this.getPendingForegroundRun(agentId);
const hasForegroundTurn =
Boolean(snapshot.activeForegroundTurnId) || this.hasPendingForegroundRun(agentId);
Boolean(snapshot.activeForegroundTurnId) || Boolean(pendingForegroundRun);
const immediatePermission = this.peekPendingPermission(snapshot);
if (immediatePermission) {
@@ -1668,7 +1671,10 @@ export class AgentManager {
}
let currentStatus: AgentLifecycleStatus = initialStatus;
let hasStarted = initialBusy || hasForegroundTurn;
let hasStarted =
isAgentBusy(initialStatus) ||
Boolean(snapshot.activeForegroundTurnId) ||
Boolean(pendingForegroundRun?.started);
let terminalStatusOverride: AgentLifecycleStatus | null = null;
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
@@ -2124,6 +2130,10 @@ export class AgentManager {
void this.refreshRuntimeInfo(agent);
}
break;
case "usage_updated":
agent.lastUsage = event.usage;
this.emitState(agent);
break;
case "timeline":
// Skip provider-replayed user_message items during history hydration.
if (options?.fromHistory && event.item.type === "user_message") {

View File

@@ -301,6 +301,7 @@ export type AgentStreamEvent =
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
| { type: "turn_started"; provider: AgentProvider; turnId?: string }
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage; turnId?: string }
| { type: "usage_updated"; provider: AgentProvider; usage: AgentUsage; turnId?: string }
| {
type: "turn_failed";
provider: AgentProvider;
@@ -440,6 +441,14 @@ export interface AgentLaunchContext {
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 {
readonly provider: AgentProvider;
readonly id: string | null;
@@ -454,7 +463,10 @@ export interface AgentSession {
getCurrentMode(): Promise<string | null>;
setMode(modeId: string): Promise<void>;
getPendingPermissions(): AgentPermissionRequest[];
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>;
close(): Promise<void>;

View File

@@ -7,19 +7,19 @@ import {
} from "./provider-launch-config.js";
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 resolved = resolveProviderCommandPrefix(undefined, resolveDefault);
const resolved = await resolveProviderCommandPrefix(undefined, resolveDefault);
expect(resolveDefault).toHaveBeenCalledTimes(1);
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 resolved = resolveProviderCommandPrefix(
const resolved = await resolveProviderCommandPrefix(
{
mode: "append",
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 resolved = resolveProviderCommandPrefix(
const resolved = await resolveProviderCommandPrefix(
{
mode: "replace",
argv: ["docker", "run", "--rm", "my-wrapper"],

View File

@@ -53,20 +53,20 @@ export type ProviderCommandPrefix = {
args: string[];
};
export function resolveProviderCommandPrefix(
export async function resolveProviderCommandPrefix(
commandConfig: ProviderCommand | undefined,
resolveDefaultCommand: () => string,
): ProviderCommandPrefix {
resolveDefaultCommand: () => string | Promise<string>,
): Promise<ProviderCommandPrefix> {
if (!commandConfig || commandConfig.mode === "default") {
return {
command: resolveDefaultCommand(),
command: await resolveDefaultCommand(),
args: [],
};
}
if (commandConfig.mode === "append") {
return {
command: resolveDefaultCommand(),
command: await resolveDefaultCommand(),
args: [...(commandConfig.args ?? [])],
};
}
@@ -102,12 +102,12 @@ export function applyProviderEnv(
return merged;
}
export function isProviderCommandAvailable(
export async function isProviderCommandAvailable(
commandConfig: ProviderCommand | undefined,
resolveDefaultCommand: () => string,
): boolean {
resolveDefaultCommand: () => string | Promise<string>,
): Promise<boolean> {
try {
const prefix = resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand);
const prefix = await resolveProviderCommandPrefix(commandConfig, resolveDefaultCommand);
return isCommandAvailable(prefix.command);
} catch {
return false;

View File

@@ -17,7 +17,7 @@ import path from "node:path";
import pino from "pino";
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";
// ---------------------------------------------------------------------------
@@ -28,7 +28,7 @@ const logger = pino({ level: "silent" });
const client = new ClaudeAgentClient({ logger });
const hasClaudeCredentials =
!!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 {
return mkdtempSync(path.join(tmpdir(), prefix));

View File

@@ -1,5 +1,4 @@
import {
spawn,
type ChildProcess,
type ChildProcessWithoutNullStreams,
} from "node:child_process";
@@ -89,6 +88,7 @@ import {
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -466,7 +466,7 @@ export class ACPAgentClient implements AgentClient {
async isAvailable(): Promise<boolean> {
try {
this.resolveLaunchCommand();
await this.resolveLaunchCommand();
return true;
} catch {
return false;
@@ -476,16 +476,15 @@ export class ACPAgentClient implements AgentClient {
protected async spawnProcess(
launchEnv?: Record<string, string>,
): Promise<SpawnedACPProcess> {
const { command, args } = this.resolveLaunchCommand();
const child = spawn(command, args, {
const { command, args } = await this.resolveLaunchCommand();
const child = spawnProcess(command, args, {
cwd: process.cwd(),
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...(launchEnv ?? {}),
},
shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"],
});
}) as ChildProcessWithoutNullStreams;
const stderrChunks: string[] = [];
child.stderr.on("data", (chunk: Buffer | string) => {
@@ -552,9 +551,9 @@ export class ACPAgentClient implements AgentClient {
}
}
protected resolveLaunchCommand(): { command: string; args: string[] } {
const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => {
const resolved = findExecutable(this.defaultCommand[0]);
protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> {
const resolved = await findExecutable(this.defaultCommand[0]);
const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => {
if (!resolved) {
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 }> {
const terminalId = randomUUID();
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,
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...env,
},
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
});
@@ -1201,8 +1199,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
rejectExit,
};
child.stdout.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
child.stderr.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.once("error", (error) => rejectExit(error instanceof Error ? error : new Error(String(error))));
child.once("exit", (code, signal) => {
const exit = { exitCode: code, signal };
@@ -1245,8 +1243,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private async spawnProcess(): Promise<SpawnedACPProcess> {
const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => {
const resolved = findExecutable(this.defaultCommand[0]);
const resolved = await findExecutable(this.defaultCommand[0]);
const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => {
if (!resolved) {
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 args = [...prefix.args, ...this.defaultCommand.slice(1)];
const child = spawn(command, args, {
const child = spawnProcess(command, args, {
cwd: this.config.cwd,
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...(this.launchEnv ?? {}),
},
shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"],
});
}) as ChildProcessWithoutNullStreams;
const stderrChunks: string[] = [];
child.stderr.on("data", (chunk: Buffer | string) => {

View File

@@ -2,13 +2,13 @@ import { describe, expect, test } from "vitest";
import pino from "pino";
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";
// Real-Claude contract coverage: validates slash command shape from a live Claude CLI session.
describe("claude agent commands contract (real)", () => {
test("lists slash commands with the expected contract", async () => {
expect(isCommandAvailable("claude")).toBe(true);
expect(isCommandAvailableSync("claude")).toBe(true);
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),

View File

@@ -6,7 +6,7 @@ import pino from "pino";
import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
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 { streamSession } from "./test-utils/session-stream-adapter.js";
@@ -174,11 +174,11 @@ async function cleanupSession(handle: { cwd: string; session: AgentSession }): P
}
describe("ClaudeAgentSession integration", () => {
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials;
beforeAll(() => {
if (canRunClaudeIntegration) {
expect(isCommandAvailable("claude")).toBe(true);
expect(isCommandAvailableSync("claude")).toBe(true);
}
});

View File

@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import pino from "pino";
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 { 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)", () => {
test.runIf(isCommandAvailable("claude") && hasClaudeCredentials)(
test.runIf(isCommandAvailableSync("claude") && hasClaudeCredentials)(
"surfaces the Claude stderr diagnostic when bypassPermissions + max effort is unavailable",
async () => {
const client = new ClaudeAgentClient({

View File

@@ -159,6 +159,79 @@ describe("convertClaudeHistoryEntry", () => {
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", () => {
const entry = {
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 () => {
const session = await createSessionForTest();
@@ -638,4 +829,134 @@ describe("ClaudeAgentSession context window usage", () => {
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,
});
});
});

View File

@@ -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 fs from "node:fs";
import { promises } from "node:fs";
@@ -79,14 +80,12 @@ import {
applyProviderEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../../../utils/executable.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
const fsPromises = promises;
const execFileAsync = promisify(execFile);
const CLAUDE_SETTING_SOURCES: NonNullable<Options["settingSources"]> = ["user", "project"];
type TurnState = "idle" | "foreground" | "autonomous";
@@ -225,26 +224,21 @@ function applyRuntimeSettingsToClaudeOptions(
const isDefaultRuntime =
resolved.command === "node" || resolved.command === "bun";
const command = isDefaultRuntime ? process.execPath : resolved.command;
const child = spawn(
quoteWindowsCommand(command),
resolved.args.map((argument) => quoteWindowsArgument(argument)),
{
const child = spawnProcess(command, resolved.args, {
cwd: spawnOptions.cwd,
env: {
...applyProviderEnv(spawnOptions.env, runtimeSettings),
...(launchEnv ?? {}),
},
shell: process.platform === "win32",
signal: spawnOptions.signal,
stdio: ["pipe", "pipe", "pipe"],
},
);
});
if (typeof options.stderr === "function") {
child.stderr?.on("data", (chunk: Buffer | string) => {
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;
}
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 {
return isClaudeInterruptPlaceholderText(value) || isClaudeNoResponsePlaceholderText(value);
return (
isClaudeInterruptPlaceholderText(value) ||
isClaudeNoResponsePlaceholderText(value) ||
isClaudeLocalCommandStdout(value)
);
}
function collectClaudeTextContentParts(content: unknown): string[] {
@@ -1134,9 +1139,9 @@ export class ClaudeAgentClient implements AgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const resolvedBinary = findExecutable("claude") ?? "not found";
const resolvedBinary = await findExecutable("claude") ?? "not found";
const available = await this.isAvailable();
const version = resolveClaudeVersion(this.runtimeSettings);
const version = await resolveClaudeVersion(this.runtimeSettings);
let modelsValue = "Not checked";
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;
try {
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",
timeout: 5_000,
}).trim() || null;
windowsHide: true,
});
return stdout.trim() || null;
}
const executable = findExecutable("claude");
const executable = await findExecutable("claude");
if (!executable) {
return null;
}
return execFileSync(executable, ["--version"], {
const { stdout } = await execFileAsync(executable, ["--version"], {
encoding: "utf8",
timeout: 5_000,
}).trim() || null;
windowsHide: true,
});
return stdout.trim() || null;
} catch {
return null;
}
@@ -1225,16 +1234,67 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
return maxContextWindow;
}
function readContextWindowUsedTokensFromTaskProgress(
message: SDKTaskProgressMessage,
function readUsageTotalTokens(
usage: unknown,
): 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) {
return undefined;
}
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 {
readonly provider: "claude" = "claude";
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1278,6 +1338,9 @@ class ClaudeAgentSession implements AgentSession {
private lastForegroundPromptText: string | null = null;
private foregroundHasVisibleActivity = false;
private lastContextWindowUsedTokens: number | undefined;
private lastContextWindowMaxTokens: number | undefined;
private lastStreamRequestInputTokens: number | undefined;
private lastStreamRequestOutputTokens: number | undefined;
private userMessageIds: string[] = [];
private recentStderr = "";
private closed = false;
@@ -1963,7 +2026,7 @@ class ClaudeAgentSession implements AgentSession {
this.persistence = null;
const input = createAsyncMessageInput<SDKUserMessage>();
const options = this.buildOptions();
const options = await this.buildOptions();
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
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 =
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
? this.config.thinkingOptionId
@@ -2019,7 +2082,7 @@ class ClaudeAgentSession implements AgentSession {
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.join("\n\n");
const claudeBinary = findExecutable("claude");
const claudeBinary = await findExecutable("claude");
this.logger.debug(
{
claudeBinary,
@@ -2672,10 +2735,18 @@ class ClaudeAgentSession implements AgentSession {
provider: "claude",
});
}
const usage = readUsageFromTaskNotification(message);
if (typeof usage === "number") {
this.lastContextWindowUsedTokens = usage;
events.push(this.createUsageUpdatedEvent(usage));
}
} else if (message.subtype === "task_progress") {
this.lastContextWindowUsedTokens =
readContextWindowUsedTokensFromTaskProgress(message) ??
this.lastContextWindowUsedTokens;
if (typeof this.lastContextWindowUsedTokens === "number") {
events.push(this.createUsageUpdatedEvent(this.lastContextWindowUsedTokens));
}
}
break;
case "user": {
@@ -2743,6 +2814,10 @@ class ClaudeAgentSession implements AgentSession {
break;
}
case "stream_event": {
const usageUpdatedEvent = this.trackStreamEventUsage(message.event);
if (usageUpdatedEvent) {
events.push(usageUpdatedEvent);
}
const timelineItems = this.mapPartialEvent(message.event, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
@@ -2911,12 +2986,21 @@ class ClaudeAgentSession implements AgentSession {
modelUsage ?? message.modelUsage,
);
if (contextWindowMaxTokens !== undefined) {
this.lastContextWindowMaxTokens = contextWindowMaxTokens;
usage.contextWindowMaxTokens = contextWindowMaxTokens;
} else if (this.lastContextWindowMaxTokens !== undefined) {
usage.contextWindowMaxTokens = this.lastContextWindowMaxTokens;
}
if (typeof this.lastContextWindowUsedTokens === "number") {
// task_progress.total_tokens is the accurate context window fill level.
// Prefer it over result.usage which contains accumulated session totals.
usage.contextWindowUsedTokens = this.lastContextWindowUsedTokens;
} else if (
typeof this.lastStreamRequestInputTokens === "number" &&
typeof this.lastStreamRequestOutputTokens === "number"
) {
usage.contextWindowUsedTokens =
this.lastStreamRequestInputTokens + this.lastStreamRequestOutputTokens;
} else if (message.usage) {
// Fallback: derive from result.usage when no task_progress has been
// received yet. These values are accumulated across all API calls, but
@@ -2937,6 +3021,54 @@ class ClaudeAgentSession implements AgentSession {
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 (
toolName,
input,

View File

@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { beforeAll, describe, expect, test } from "vitest";
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> {
private queue: T[] = [];
@@ -57,18 +57,18 @@ const hasClaudeCredentials =
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
describe("Claude SDK direct behavior", () => {
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
const canRunClaudeIntegration = isCommandAvailableSync("claude") && hasClaudeCredentials;
beforeAll(() => {
if (canRunClaudeIntegration) {
expect(isCommandAvailable("claude")).toBe(true);
expect(isCommandAvailableSync("claude")).toBe(true);
}
});
test.runIf(canRunClaudeIntegration)("shows what happens after interrupt()", async () => {
const cwd = tmpCwd();
const input = new Pushable<SDKUserMessage>();
const claudeBinary = findExecutable("claude");
const claudeBinary = await findExecutable("claude");
// Use same options as claude-agent.ts
const q = query({

View File

@@ -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({
featureValues: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const startTurnSpy = vi
.spyOn(session, "startTurn")
.mockResolvedValue({ turnId: "follow-up-turn" });
(session as any).handleNotification("turn/started", {
turn: { id: "turn-plan-2" },
});
@@ -607,7 +649,7 @@ describe("Codex app-server provider", () => {
throw new Error("Expected synthetic plan approval permission");
}
await session.respondToPermission(request.request.id, {
const result = await session.respondToPermission(request.request.id, {
behavior: "allow",
selectedActionId: "implement",
});
@@ -618,7 +660,10 @@ describe("Codex app-server provider", () => {
plan_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(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);
});
});

View File

@@ -9,6 +9,7 @@ import type {
McpServerConfig,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPermissionResult,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
@@ -27,7 +28,7 @@ import type {
} from "../agent-sdk-types.js";
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 { randomUUID } from "node:crypto";
import { existsSync, Dirent } from "node:fs";
@@ -46,11 +47,8 @@ import {
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../../../utils/executable.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
import {
@@ -228,8 +226,8 @@ function mergeCodexConfiguredDefaults(
};
}
function resolveCodexBinary(): string {
const found = findExecutable("codex");
async function resolveCodexBinary(): Promise<string> {
const found = await findExecutable("codex");
if (found) {
return found;
}
@@ -238,10 +236,10 @@ function resolveCodexBinary(): string {
);
}
function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): {
async function resolveCodexLaunchPrefix(runtimeSettings?: ProviderRuntimeSettings): Promise<{
command: string;
args: string[];
} {
}> {
return resolveProviderCommandPrefix(runtimeSettings?.command, resolveCodexBinary);
}
@@ -2457,7 +2455,7 @@ class CodexAppServerAgentSession implements AgentSession {
config: AgentSessionConfig,
private readonly resumeHandle: { sessionId: string; metadata?: Record<string, unknown> } | null,
logger: Logger,
private readonly spawnAppServer: () => ChildProcessWithoutNullStreams,
private readonly spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>,
) {
this.logger = logger.child({ module: "agent", provider: CODEX_PROVIDER });
if (config.modeId === undefined) {
@@ -2495,7 +2493,7 @@ class CodexAppServerAgentSession implements AgentSession {
async connect(): Promise<void> {
if (this.connected) return;
const child = this.spawnAppServer();
const child = await this.spawnAppServer();
this.client = new CodexAppServerClient(child, this.logger);
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers();
@@ -2676,22 +2674,19 @@ class CodexAppServerAgentSession implements AgentSession {
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 =
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
const previousPlanMode = this.planModeEnabled;
const previousFastMode = this.serviceTier === "fast";
this.applyFeatureValue("plan_mode", false);
this.applyFeatureValue("fast_mode", false);
try {
await this.startTurn(buildCodexPlanImplementationPrompt(planText));
} catch (error) {
this.applyFeatureValue("plan_mode", previousPlanMode);
this.applyFeatureValue("fast_mode", previousFastMode);
throw error;
}
return buildCodexPlanImplementationPrompt(planText);
}
private registerRequestHandlers(): void {
@@ -3105,7 +3100,10 @@ class CodexAppServerAgentSession implements AgentSession {
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);
if (!pending) {
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;
if (pending.kind === "plan") {
let followUpPrompt: string | undefined;
if (response.behavior === "allow") {
await this.handleApprovedPlanPermission({
followUpPrompt = this.preparePlanImplementation({
planText: pending.planText ?? pendingRequest?.metadata?.planText,
});
}
@@ -3128,6 +3127,9 @@ class CodexAppServerAgentSession implements AgentSession {
requestId,
resolution: response,
});
if (followUpPrompt) {
return { followUpPrompt };
}
return;
}
@@ -3506,6 +3508,13 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.kind === "token_usage_updated") {
this.latestUsage = toAgentUsage(parsed.tokenUsage);
if (this.latestUsage) {
this.notifySubscribers({
type: "usage_updated",
provider: CODEX_PROVIDER,
usage: this.latestUsage,
});
}
return;
}
@@ -3990,24 +3999,19 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly runtimeSettings?: ProviderRuntimeSettings,
) {}
private spawnAppServer(launchEnv?: Record<string, string>): ChildProcessWithoutNullStreams {
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
private async spawnAppServer(launchEnv?: Record<string, string>): Promise<ChildProcessWithoutNullStreams> {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
this.logger.trace(
{
launchPrefix,
},
"Spawning Codex app server",
);
return spawn(
quoteWindowsCommand(launchPrefix.command),
[...launchPrefix.args, "app-server"].map((argument) => quoteWindowsArgument(argument)),
{
detached: process.platform !== "win32",
shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"],
env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
},
);
return spawnProcess(launchPrefix.command, [...launchPrefix.args, "app-server"], {
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
}) as ChildProcessWithoutNullStreams;
}
async createSession(
@@ -4044,7 +4048,7 @@ export class CodexAppServerAgentClient implements AgentClient {
async listPersistedAgents(
options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
const child = this.spawnAppServer();
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
try {
@@ -4114,7 +4118,7 @@ export class CodexAppServerAgentClient implements AgentClient {
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const child = this.spawnAppServer();
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
try {
@@ -4206,13 +4210,13 @@ export class CodexAppServerAgentClient implements AgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const available = await this.isAvailable();
const resolvedBinary = findExecutable("codex");
const resolvedBinary = await findExecutable("codex");
const entries: Array<{ label: string; value: string }> = [
{
label: "Binary",
value: resolvedBinary ?? "not found",
},
{ label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" },
{ label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
];
let status = formatDiagnosticStatus(available);

View File

@@ -63,7 +63,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const available = await this.isAvailable();
const resolvedBinary = findExecutable("copilot");
const resolvedBinary = await findExecutable("copilot");
let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available);
@@ -97,7 +97,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
{ label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" },
{ label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
]),

View File

@@ -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";
const execFileAsync = promisify(execFile);
type DiagnosticEntry = {
label: string;
value: string;
@@ -50,14 +53,14 @@ export function toDiagnosticErrorMessage(error: unknown): string {
return "Unknown error";
}
export function resolveBinaryVersion(binaryPath: string): string {
export async function resolveBinaryVersion(binaryPath: string): Promise<string> {
try {
return (
execFileSync(binaryPath, ["--version"], {
encoding: "utf8",
timeout: 5_000,
}).trim() || "unknown"
);
const { stdout } = await execFileAsync(binaryPath, ["--version"], {
encoding: "utf8",
timeout: 5_000,
windowsHide: true,
});
return stdout.trim() || "unknown";
} catch {
return "unknown";
}

View File

@@ -2,12 +2,12 @@ import { describe, expect, test } from "vitest";
import pino from "pino";
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";
describe("opencode agent commands contract (real)", () => {
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 session = await client.createSession({
@@ -37,13 +37,13 @@ describe("opencode agent commands contract (real)", () => {
}, 60_000);
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 session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
modeId: "default",
modeId: "plan",
});
try {
@@ -58,14 +58,22 @@ describe("opencode agent commands contract (real)", () => {
const { turnId } = await session.startTurn(`/${command.name}`);
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) => {
const timeout = setTimeout(() => {
unsub();
resolve();
}, 15_000);
const unsub = session.subscribe((event) => {
if (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
clearTimeout(timeout);
unsub();
resolve();
}

View File

@@ -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,
);
});

View File

@@ -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",
},
]);
});
});

View File

@@ -12,6 +12,7 @@ import path from "node:path";
import { execFileSync } from "node:child_process";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { Event as OpenCodeEvent } from "@opencode-ai/sdk/v2/client";
import {
__openCodeInternals,
OpenCodeAgentClient,
@@ -116,15 +117,15 @@ const hasOpenCode = isBinaryInstalled("opencode");
"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(
(m) =>
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.1-codex-mini") ||
m.id.includes("gpt-4o-mini") ||
m.id.includes("gpt-3.5") ||
m.id.includes("free"),
m.id.includes("gpt-4o-mini"),
);
if (fastModel) {
@@ -442,7 +443,7 @@ describe("OpenCode adapter context-window normalization", () => {
modelID: "gpt-5",
},
},
},
} as OpenCodeEvent,
{
sessionId: "session-1",
messageRoles: new Map(),

View File

@@ -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 {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent,
type OpencodeClient,
type Part as OpenCodePart,
} from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import type { Logger } from "pino";
@@ -12,7 +15,6 @@ import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
@@ -40,11 +42,8 @@ import {
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../../../utils/executable.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import {
formatDiagnosticStatus,
@@ -94,6 +93,19 @@ type OpenCodeMcpConfig =
};
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
.object({
@@ -174,8 +186,8 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch
}),
);
function resolveOpenCodeBinary(): string {
const found = findExecutable("opencode");
async function resolveOpenCodeBinary(): Promise<string> {
const found = await findExecutable("opencode");
if (found) {
return found;
}
@@ -218,6 +230,14 @@ function normalizeTurnFailureError(error: unknown): string {
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 {
const normalized = stringifyUnknownError(error).toLowerCase();
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 {
const partId = part.id;
if (typeof partId === "string" && partId.trim().length > 0) {
return `${partType}:${partId}`;
function resolvePartDedupeKey(
part: { id: string; messageID: string },
partType: "text" | "reasoning",
): string | null {
if (part.id.trim().length > 0) {
return `${partType}:${part.id}`;
}
const messageId = part.messageID;
if (typeof messageId === "string" && messageId.trim().length > 0) {
return `${partType}:message:${messageId}`;
if (part.messageID.trim().length > 0) {
return `${partType}:message:${part.messageID}`;
}
return null;
}
@@ -404,15 +425,11 @@ function buildOpenCodeModelContextWindowLookup(providers: {
return lookup;
}
function resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(
info: AgentMetadata | undefined,
function resolveOpenCodeModelLookupKeyFromAssistantMessage(
info: OpenCodeAssistantMessage,
): string | undefined {
if (!info || info.role !== "assistant") {
return undefined;
}
const providerId = readNonEmptyString(info.providerID);
const modelId = readNonEmptyString(info.modelID);
const providerId = info.providerID;
const modelId = info.modelID;
if (!providerId || !modelId) {
return undefined;
}
@@ -450,16 +467,16 @@ function mergeOpenCodeStepFinishUsage(
const cost = readPositiveFiniteNumber(part.cost);
if (inputTokens !== undefined) {
usage.inputTokens = (usage.inputTokens ?? 0) + inputTokens;
usage.inputTokens = inputTokens;
}
if (cacheReadTokens !== undefined) {
usage.cachedInputTokens = (usage.cachedInputTokens ?? 0) + cacheReadTokens;
usage.cachedInputTokens = cacheReadTokens;
}
if (outputTokens !== undefined) {
usage.outputTokens = (usage.outputTokens ?? 0) + outputTokens;
usage.outputTokens = outputTokens;
}
if (totalTokens > 0) {
usage.contextWindowUsedTokens = (usage.contextWindowUsedTokens ?? 0) + totalTokens;
usage.contextWindowUsedTokens = totalTokens;
}
if (cost !== undefined) {
usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost;
@@ -485,7 +502,7 @@ export const __openCodeInternals = {
hasNormalizedOpenCodeUsage,
mergeOpenCodeStepFinishUsage,
parseOpenCodeModelLookupKey,
resolveOpenCodeModelLookupKeyFromAssistantMessageInfo,
resolveOpenCodeModelLookupKeyFromAssistantMessage,
resolveOpenCodeSelectedModelContextWindow,
};
@@ -564,19 +581,16 @@ export class OpenCodeServerManager {
private async startServer(): Promise<{ port: number; url: string }> {
this.port = await findAvailablePort();
const url = `http://127.0.0.1:${this.port}`;
const launchPrefix = resolveProviderCommandPrefix(
const launchPrefix = await resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary,
);
return new Promise((resolve, reject) => {
this.server = spawn(
quoteWindowsCommand(launchPrefix.command),
[...launchPrefix.args, "serve", "--port", String(this.port)].map((argument) =>
quoteWindowsArgument(argument),
),
this.server = spawnProcess(
launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(this.port)],
{
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
env: applyProviderEnv(process.env, this.runtimeSettings),
},
@@ -733,16 +747,17 @@ export class OpenCodeAgentClient implements AgentClient {
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) => {
setTimeout(
() =>
reject(
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 }> {
try {
const available = await this.isAvailable();
const resolvedBinary = findExecutable("opencode");
const resolvedBinary = await findExecutable("opencode");
let serverStatus = "Not running";
let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available);
@@ -889,7 +904,7 @@ export class OpenCodeAgentClient implements AgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
{ label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" },
{ label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
{ label: "Server", value: serverStatus },
{ label: "Models", value: modelsValue },
{ 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;
}
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(
event: unknown,
event: OpenCodeEvent,
state: OpenCodeEventTranslationState,
): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
if (!event || typeof event !== "object") {
return events;
}
const e = event as { type?: string; properties?: AgentMetadata };
const type = e.type;
const props = e.properties ?? {};
switch (type) {
switch (event.type) {
case "session.created":
case "session.updated": {
const sessionId = props.id as string | undefined;
if (sessionId === state.sessionId) {
if (event.properties.info.id === state.sessionId) {
events.push({
type: "thread_started",
sessionId: state.sessionId,
@@ -1017,35 +999,28 @@ export function translateOpenCodeEvent(
}
case "message.updated": {
const info = props.info as AgentMetadata | undefined;
if (!info) {
const info = event.properties.info;
if (info.sessionID !== state.sessionId) {
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(messageId, role);
if (role === "assistant") {
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(info);
if (modelLookupKey) {
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
if (contextWindowMaxTokens !== undefined) {
state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
}
state.messageRoles.set(info.id, info.role);
if (info.role === "assistant") {
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info);
if (modelLookupKey) {
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
if (contextWindowMaxTokens !== undefined) {
state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
}
}
if (
role === "assistant" &&
!state.emittedStructuredMessageIds.has(messageId) &&
typeof info.time === "object" &&
info.time !== null &&
"completed" in info.time
!state.emittedStructuredMessageIds.has(info.id) &&
info.time?.completed !== undefined
) {
const text = stringifyStructuredAssistantMessage(info.structured);
if (text) {
state.emittedStructuredMessageIds.add(messageId);
state.emittedStructuredMessageIds.add(info.id);
events.push({
type: "timeline",
provider: "opencode",
@@ -1058,82 +1033,46 @@ export function translateOpenCodeEvent(
}
case "message.part.updated": {
const part = props.part as AgentMetadata | undefined;
const delta = props.delta as string | undefined;
if (!part) {
const part = event.properties.part;
if (part.sessionID !== state.sessionId) {
break;
}
const partSessionId = part.sessionID as string | undefined;
if (partSessionId !== state.sessionId) {
break;
}
const messageRole = state.messageRoles.get(part.messageID);
state.partTypes.set(part.id, part.type);
const partId = part.id as string | undefined;
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") {
if (part.type === "text") {
const partKey = resolvePartDedupeKey(part, "text");
if (messageRole === "user") {
break;
}
if (!messageRole && !delta) {
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 (part.time?.end) {
if (partKey && state.streamedPartKeys.delete(partKey)) {
break;
}
const text = part.text as string | undefined;
if (text) {
if (part.text) {
events.push({
type: "timeline",
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");
if (delta) {
if (partKey) {
state.streamedPartKeys.add(partKey);
}
events.push({
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text: delta },
});
} else if (partTime?.end) {
if (part.time.end) {
if (partKey && state.streamedPartKeys.delete(partKey)) {
break;
}
const text = part.text as string | undefined;
if (text) {
if (part.text) {
events.push({
type: "timeline",
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);
if (parsedToolPart.success && parsedToolPart.data) {
events.push({
@@ -1142,118 +1081,98 @@ export function translateOpenCodeEvent(
item: parsedToolPart.data,
});
}
} else if (partType === "step-finish") {
} else if (part.type === "step-finish") {
mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part);
if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) {
events.push({
type: "usage_updated",
provider: "opencode",
usage: { ...state.accumulatedUsage },
});
}
}
break;
}
case "message.part.delta": {
const deltaSessionId = props.sessionID as string | undefined;
if (deltaSessionId !== state.sessionId) {
const { sessionID, messageID, partID, field, delta } = event.properties;
if (sessionID !== state.sessionId) {
break;
}
const deltaMessageId = props.messageID as string | undefined;
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) {
if (!delta || !field) {
break;
}
const partId = props.partID as string | undefined;
const knownPartType = partId ? state.partTypes.get(partId) : undefined;
const isReasoning = knownPartType === "reasoning" || deltaField === "reasoning";
const messageRole = messageID ? state.messageRoles.get(messageID) : undefined;
const knownPartType = partID ? state.partTypes.get(partID) : undefined;
const isReasoning = knownPartType === "reasoning" || field === "reasoning";
if (isReasoning) {
const partKey = partId ? `reasoning:${partId}` : null;
if (partKey) {
state.streamedPartKeys.add(partKey);
if (partID) {
state.streamedPartKeys.add(`reasoning:${partID}`);
}
events.push({
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text: deltaText },
item: { type: "reasoning", text: delta },
});
} else if (deltaField === "text") {
if (deltaMessageRole === "user") {
} else if (field === "text") {
if (messageRole === "user") {
break;
}
const partKey = partId ? `text:${partId}` : null;
if (partKey) {
state.streamedPartKeys.add(partKey);
if (partID) {
state.streamedPartKeys.add(`text:${partID}`);
}
events.push({
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text: deltaText },
item: { type: "assistant_message", text: delta },
});
}
break;
}
case "permission.asked": {
const sessionId = props.sessionID as string | undefined;
if (sessionId !== state.sessionId) {
if (event.properties.sessionID !== state.sessionId) {
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({
type: "permission_requested",
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;
}
case "question.asked": {
const sessionId = props.sessionID as string | undefined;
if (sessionId !== state.sessionId) {
if (event.properties.sessionID !== state.sessionId) {
break;
}
const requestId = props.id as string | undefined;
const rawQuestions = Array.isArray(props.questions) ? props.questions : [];
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) {
const questions = event.properties.questions.flatMap((q) => {
if (!q.question || !q.header) {
return [];
}
const options = normalizeQuestionOptions(questionRecord?.options) ?? [];
return [
{
question,
header,
options,
...(questionRecord?.multiple === true ? { multiSelect: true } : {}),
},
];
const options = q.options?.map((o) => ({
label: o.label,
...(o.description ? { description: o.description } : {}),
})) ?? [];
return [{
question: q.question,
header: q.header,
options,
...(q.multiple === true ? { multiSelect: true } : {}),
}];
});
if (questions.length === 0) {
@@ -1264,7 +1183,7 @@ export function translateOpenCodeEvent(
type: "permission_requested",
provider: "opencode",
request: {
id: requestId,
id: event.properties.id,
provider: "opencode",
name: "question",
kind: "question",
@@ -1272,7 +1191,7 @@ export function translateOpenCodeEvent(
input: { questions },
metadata: {
source: "opencode_question",
...(readOpenCodeRecord(props.tool) ?? {}),
...(event.properties.tool ?? {}),
},
},
});
@@ -1280,8 +1199,7 @@ export function translateOpenCodeEvent(
}
case "session.idle": {
const sessionId = props.sessionID as string | undefined;
if (sessionId === state.sessionId) {
if (event.properties.sessionID === state.sessionId) {
state.streamedPartKeys.clear();
state.partTypes.clear();
events.push({
@@ -1294,18 +1212,43 @@ export function translateOpenCodeEvent(
}
case "session.error": {
const sessionId = props.sessionID as string | undefined;
if (sessionId === state.sessionId) {
if (event.properties.sessionID === state.sessionId) {
state.streamedPartKeys.clear();
state.partTypes.clear();
events.push({
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(props.error),
error: normalizeTurnFailureError(event.properties.error),
});
}
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;
@@ -1340,7 +1283,6 @@ class OpenCodeAgentSession implements AgentSession {
private activeForegroundTurnId: string | null = null;
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
private selectedModelContextWindowMaxTokens: number | undefined;
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
@@ -1506,8 +1448,12 @@ class OpenCodeAgentSession implements AgentSession {
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
if (slashCommand) {
// command() blocks until completion, but events stream via SSE in the
// background — fire without awaiting so the event stream isn't starved.
// command() blocks until the server finishes processing. OpenCode's SSE
// 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({
sessionID: this.sessionId,
directory: this.config.cwd,
@@ -1523,10 +1469,20 @@ class OpenCodeAgentSession implements AgentSession {
{ type: "turn_failed", provider: "opencode", error: errorMsg },
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 {
const promptResponse = await this.client.session.promptAsync({
void this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
parts,
@@ -1542,17 +1498,27 @@ class OpenCodeAgentSession implements AgentSession {
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(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 };
@@ -1569,12 +1535,13 @@ class OpenCodeAgentSession implements AgentSession {
turnId: string,
turnAbortController: AbortController,
): Promise<void> {
const eventsResult = await this.client.event.subscribe({
directory: this.config.cwd,
});
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) {
break;
}
@@ -1605,6 +1572,17 @@ class OpenCodeAgentSession implements AgentSession {
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) {
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn(
@@ -1646,6 +1624,9 @@ class OpenCodeAgentSession implements AgentSession {
this.runningToolCalls.clear();
}
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);
}
@@ -1701,16 +1682,12 @@ class OpenCodeAgentSession implements AgentSession {
return;
}
const messages = response.data;
for (const message of messages) {
const { info, parts } = message;
const role = info.role as "user" | "assistant";
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("");
for (const { info, parts } of response.data) {
if (info.role === "user") {
const text = parts
.filter((p): p is Extract<OpenCodePart, { type: "text" }> => p.type === "text")
.map((p) => p.text)
.join("");
if (text) {
yield {
@@ -1719,49 +1696,40 @@ class OpenCodeAgentSession implements AgentSession {
item: { type: "user_message", text },
};
}
} else if (role === "assistant") {
} else {
let emittedAssistantText = false;
// Process each part
for (const part of parts) {
const partType = (part as { type?: string }).type;
if (partType === "text") {
const text = (part as { text?: string }).text;
if (text) {
if (part.type === "text") {
if (part.text) {
emittedAssistantText = true;
yield {
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text },
item: { type: "assistant_message", text: part.text },
};
}
} else if (partType === "reasoning") {
const text = (part as { text?: string }).text;
if (text) {
} else if (part.type === "reasoning") {
if (part.text) {
yield {
type: "timeline",
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);
if (parsedToolPart.success) {
if (parsedToolPart.data) {
yield {
type: "timeline",
provider: "opencode",
item: parsedToolPart.data,
};
}
if (parsedToolPart.success && parsedToolPart.data) {
yield {
type: "timeline",
provider: "opencode",
item: parsedToolPart.data,
};
}
}
}
if (!emittedAssistantText) {
const text = stringifyStructuredAssistantMessage(
(info as { structured?: unknown }).structured,
);
const text = stringifyStructuredAssistantMessage(info.structured);
if (text) {
yield {
type: "timeline",
@@ -2020,7 +1988,7 @@ class OpenCodeAgentSession implements AgentSession {
);
}
private translateEvent(event: unknown): AgentStreamEvent[] {
private translateEvent(event: OpenCodeEvent): AgentStreamEvent[] {
const translated = translateOpenCodeEvent(event, {
sessionId: this.sessionId,
messageRoles: this.messageRoles,

View File

@@ -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", () => {
const state = createState();

View File

@@ -326,7 +326,7 @@ export class PiACPAgentClient extends ACPAgentClient {
if (!existsSync(resolvedPiAcpPath)) {
return false;
}
if (!isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi")) {
if (!(await isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi"))) {
return false;
}
return (
@@ -340,8 +340,8 @@ export class PiACPAgentClient extends ACPAgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const piCommand = process.env.PI_ACP_PI_COMMAND ?? "pi";
const piCliPath = findExecutable(piCommand);
const piVersion = piCliPath ? resolveBinaryVersion(piCliPath) : "unknown";
const piCliPath = await findExecutable(piCommand);
const piVersion = piCliPath ? await resolveBinaryVersion(piCliPath) : "unknown";
const authConfigPath = join(homedir(), ".pi", "agent", "auth.json");
const available = await this.isAvailable();
let modelsValue = "Not checked";

View File

@@ -120,21 +120,28 @@ import { resolveVoiceMcpBridgeFromRuntime } from "./voice-mcp-bridge-command.js"
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
function resolveVoiceMcpBridgeCommand(logger: Logger): { command: string; baseArgs: string[] } {
const decision = resolveVoiceMcpBridgeFromRuntime({
bootstrapModuleUrl: import.meta.url,
execPath: process.execPath,
explicitScriptPath: process.env.PASEO_MCP_STDIO_SOCKET_BRIDGE_SCRIPT,
});
logger.info(
{
source: decision.source,
command: decision.resolved.command,
baseArgs: decision.resolved.baseArgs,
},
"Resolved voice MCP bridge command",
);
return decision.resolved;
function resolveVoiceMcpBridgeCommand(
logger: Logger,
): { command: string; baseArgs: string[] } | null {
try {
const decision = resolveVoiceMcpBridgeFromRuntime({
bootstrapModuleUrl: import.meta.url,
execPath: process.execPath,
explicitScriptPath: process.env.PASEO_MCP_STDIO_SOCKET_BRIDGE_SCRIPT,
});
logger.info(
{
source: decision.source,
command: decision.resolved.command,
baseArgs: decision.resolved.baseArgs,
},
"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;
@@ -586,14 +593,16 @@ export async function createPaseoDaemon(
speechService,
terminalManager,
{
voiceAgentMcpStdio: {
command: voiceMcpBridgeCommand.command,
baseArgs: [...voiceMcpBridgeCommand.baseArgs],
env: {
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: config.paseoHome,
},
},
voiceAgentMcpStdio: voiceMcpBridgeCommand
? {
command: voiceMcpBridgeCommand.command,
baseArgs: [...voiceMcpBridgeCommand.baseArgs],
env: {
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: config.paseoHome,
},
}
: null,
ensureVoiceMcpSocketForAgent: (agentId) =>
voiceMcpBridgeManager?.ensureBridgeForCaller(agentId) ??
Promise.reject(new Error("Voice MCP bridge manager is not initialized")),

View File

@@ -7,7 +7,7 @@ import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
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.
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
@@ -105,21 +105,21 @@ export function isProviderAvailable(provider: AgentProvider): boolean {
switch (provider) {
case "claude":
return (
isCommandAvailable("claude") &&
isCommandAvailableSync("claude") &&
(Boolean(process.env.CLAUDE_CODE_OAUTH_TOKEN) || Boolean(process.env.ANTHROPIC_API_KEY))
);
case "codex":
return (
isCommandAvailable("codex") &&
isCommandAvailableSync("codex") &&
(existsSync(join(homedir(), ".codex", "auth.json")) || Boolean(process.env.OPENAI_API_KEY))
);
case "copilot":
return isCommandAvailable("copilot");
return isCommandAvailableSync("copilot");
case "opencode":
return isCommandAvailable("opencode");
return isCommandAvailableSync("opencode");
case "pi":
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.ANTHROPIC_API_KEY) ||
Boolean(process.env.OPENROUTER_API_KEY) ||

View File

@@ -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,
);
});

View File

@@ -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,
);
});

View File

@@ -2,10 +2,10 @@ import { describe, expect, it, vi } from "vitest";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
describe("editor-targets", () => {
it("lists available editors in deterministic order", () => {
const available = new Set(["code", "cursor", "explorer"]);
it("lists available editors in deterministic order", async () => {
const available = new Set(["code", "cursor", "explorer", "webstorm"]);
const editors = listAvailableEditorTargets({
const editors = await listAvailableEditorTargets({
platform: "win32",
findExecutable: (command) => (available.has(command) ? command : null),
});
@@ -13,12 +13,13 @@ describe("editor-targets", () => {
expect(editors).toEqual([
{ id: "cursor", label: "Cursor" },
{ id: "vscode", label: "VS Code" },
{ id: "webstorm", label: "WebStorm" },
{ id: "explorer", label: "Explorer" },
]);
});
it("returns Finder on macOS", () => {
const editors = listAvailableEditorTargets({
it("returns Finder on macOS", async () => {
const editors = await listAvailableEditorTargets({
platform: "darwin",
findExecutable: (command) => (command === "open" ? "/usr/bin/open" : null),
});
@@ -26,8 +27,8 @@ describe("editor-targets", () => {
expect(editors).toEqual([{ id: "finder", label: "Finder" }]);
});
it("returns the generic file manager target on Linux", () => {
const editors = listAvailableEditorTargets({
it("returns the generic file manager target on Linux", async () => {
const editors = await listAvailableEditorTargets({
platform: "linux",
findExecutable: (command) => (command === "xdg-open" ? "/usr/bin/xdg-open" : null),
});
@@ -97,4 +98,19 @@ describe("editor-targets", () => {
),
).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");
});
});

View File

@@ -1,7 +1,11 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { posix, win32 } from "node:path";
import type { EditorTargetDescriptorPayload, EditorTargetId } from "../shared/messages.js";
import type {
EditorTargetDescriptorPayload,
EditorTargetId,
KnownEditorTargetId,
} from "../shared/messages.js";
import {
findExecutable,
quoteWindowsArgument,
@@ -9,7 +13,7 @@ import {
} from "../utils/executable.js";
type EditorTargetDefinition = {
id: EditorTargetId;
id: KnownEditorTargetId;
label: string;
command: string;
platforms?: readonly NodeJS.Platform[];
@@ -18,7 +22,7 @@ type EditorTargetDefinition = {
type ListAvailableEditorTargetsDependencies = {
platform?: NodeJS.Platform;
findExecutable?: (command: string) => string | null;
findExecutable?: (command: string) => string | null | Promise<string | null>;
};
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
@@ -29,6 +33,7 @@ type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
{ id: "cursor", label: "Cursor", command: "cursor" },
{ id: "vscode", label: "VS Code", command: "code" },
{ id: "webstorm", label: "WebStorm", command: "webstorm" },
{ id: "zed", label: "Zed", command: "zed" },
{ id: "finder", label: "Finder", command: "open", platforms: ["darwin"] },
{ id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] },
@@ -65,28 +70,27 @@ function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDe
return target;
}
export function listAvailableEditorTargets(
export async function listAvailableEditorTargets(
dependencies: ListAvailableEditorTargetsDependencies = {},
): EditorTargetDescriptorPayload[] {
): Promise<EditorTargetDescriptorPayload[]> {
const platform = dependencies.platform ?? process.platform;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
return EDITOR_TARGETS.flatMap((target) => {
const results: EditorTargetDescriptorPayload[] = [];
for (const target of EDITOR_TARGETS) {
if (!isTargetSupportedOnPlatform(target, platform)) {
return [];
continue;
}
const executable = findExecutableFn(target.command);
const executable = await findExecutableFn(target.command);
if (!executable) {
return [];
continue;
}
return [
{
id: target.id,
label: target.label,
},
];
});
results.push({
id: target.id,
label: target.label,
});
}
return results;
}
type Launch = {
@@ -94,17 +98,17 @@ type Launch = {
args: string[];
};
function resolveEditorLaunch(input: {
async function resolveEditorLaunch(input: {
editorId: EditorTargetId;
path: string;
platform: NodeJS.Platform;
findExecutableFn: typeof findExecutable;
}): Launch {
findExecutableFn: (command: string) => string | null | Promise<string | null>;
}): Promise<Launch> {
const target = resolveEditorTargetDefinition(input.editorId);
if (!isTargetSupportedOnPlatform(target, input.platform)) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const executable = input.findExecutableFn(target.command);
const executable = await input.findExecutableFn(target.command);
if (!executable) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
@@ -135,7 +139,7 @@ export async function openInEditorTarget(
throw new Error(`Path does not exist: ${pathToOpen}`);
}
const launch = resolveEditorLaunch({
const launch = await resolveEditorLaunch({
editorId: input.editorId,
path: pathToOpen,
platform,

View File

@@ -33,7 +33,8 @@ export {
export {
applyProviderEnv,
} 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)
export {

View File

@@ -143,6 +143,7 @@ async function main() {
logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1);
}
logger.fatal({ err }, "Daemon bootstrap failed");
throw err;
}
@@ -153,6 +154,7 @@ async function main() {
logger.error({ pid: err.existingLock?.pid }, err.message);
process.exit(1);
}
logger.fatal({ err }, "Daemon failed to start listening");
throw err;
}
@@ -171,10 +173,9 @@ async function main() {
}
main().catch((err) => {
if (process.env.PASEO_DEBUG === "1") {
process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
} else {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
}
process.exit(1);
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
// before the process exits. Without this, async file streams may lose the
// last few entries that explain why the daemon crashed.
setTimeout(() => process.exit(1), 200);
});

View File

@@ -8,6 +8,7 @@ import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
import {
isLegacyEditorTargetId,
serializeAgentStreamEvent,
type AgentSnapshotPayload,
type SessionInboundMessage,
@@ -28,6 +29,7 @@ import {
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type EditorTargetDescriptorPayload,
type EditorTargetId,
type ProjectPlacementPayload,
type WorkspaceDescriptorPayload,
@@ -202,13 +204,14 @@ const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
// the entire session message if they encounter an unknown provider.
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
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;
// Strip RC/prerelease suffix: "0.1.45-rc.4" → "0.1.45"
const base = appVersion.replace(/-.*$/, "");
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++) {
const a = parts[i] ?? 0;
const b = minParts[i] ?? 0;
@@ -218,6 +221,14 @@ function clientSupportsAllProviders(appVersion: string | null): boolean {
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_REMOVED_FINGERPRINT = "__removed__";
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
@@ -1215,6 +1226,15 @@ export class Session {
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: {
agent: AgentSnapshotPayload;
project: ProjectPlacementPayload;
@@ -2836,7 +2856,7 @@ export class Session {
images?: Array<{ data: string; mimeType: string }>,
runOptions?: AgentRunOptions,
options?: { spokenInput?: boolean },
): Promise<void> {
): Promise<{ ok: true } | { ok: false; error: string }> {
this.sessionLogger.info(
{ 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)` : ""}`,
@@ -2848,7 +2868,10 @@ export class Session {
await this.ensureAgentLoaded(agentId);
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt");
return;
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
try {
@@ -2866,7 +2889,7 @@ export class Session {
const promptText = options?.spokenInput ? wrapSpokenInput(text) : text;
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 });
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) {
const agentPayload = await this.getAgentPayloadById(snapshot.id);
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) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
@@ -3924,8 +3936,16 @@ export class Session {
);
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}`);
if (result?.followUpPrompt) {
this.sessionLogger.debug(
{ agentId },
"Permission response requires follow-up turn, starting agent stream",
);
this.startAgentStream(agentId, result.followUpPrompt);
}
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId, requestId },
@@ -6095,7 +6115,7 @@ export class Session {
}
async getAvailableEditorTargets() {
return listAvailableEditorTargets();
return this.filterEditorsForClient(await listAvailableEditorTargets());
}
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
@@ -6602,6 +6622,7 @@ export class Session {
try {
let result = await this.agentManager.waitForAgentEvent(agentId, {
signal: abortController.signal,
waitForActive: true,
});
let final = await this.getAgentPayloadById(agentId);
if (!final) {
@@ -7522,7 +7543,9 @@ export class Session {
listStoredAgents: () => this.agentStorage.list(),
listLiveAgents: () => this.agentManager.listAgents(),
resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier),
sendAgentMessage: (agentId, text) => this.handleSendAgentMessage(agentId, text),
sendAgentMessage: async (agentId, text) => {
await this.handleSendAgentMessage(agentId, text);
},
});
} catch (error) {
this.emitChatRpcError(request, error);

View File

@@ -61,7 +61,7 @@ function makeAgent(input: {
};
}
function createSessionForWorkspaceTests(): Session {
function createSessionForWorkspaceTests(options: { appVersion?: string | null } = {}): Session {
const logger = {
child: () => logger,
trace: vi.fn(),
@@ -73,6 +73,7 @@ function createSessionForWorkspaceTests(): Session {
const session = new Session({
clientId: "test-client",
appVersion: options.appVersion ?? null,
onMessage: vi.fn(),
logger: logger as any,
downloadTokenStore: {} as any,
@@ -1246,19 +1247,52 @@ describe("workspace aggregation", () => {
test("list_available_editors_request returns available targets", async () => {
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.getAvailableEditorTargets = async () => [
{ id: "cursor", label: "Cursor" },
{ id: "finder", label: "Finder" },
];
session.getAvailableEditorTargets = async () =>
session.filterEditorsForClient([
{ id: "cursor", label: "Cursor" },
{ id: "webstorm", label: "WebStorm" },
{ id: "finder", label: "Finder" },
{ id: "unknown-editor", label: "Unknown Editor" },
]);
await session.handleMessage({
type: "list_available_editors_request",
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(
(message) => message.type === "list_available_editors_response",
) as any;

View File

@@ -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>);

View File

@@ -47,6 +47,7 @@ import {
LoopLogsResponseSchema,
LoopStopResponseSchema,
} from "../server/loop/rpc-schemas.js";
import type { LiteralUnion } from "./literal-union.js";
import type {
AgentCapabilityFlags,
AgentModelDefinition,
@@ -1096,14 +1097,32 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
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",
"vscode",
"zed",
"finder",
"explorer",
"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({
id: EditorTargetIdSchema,
@@ -2626,7 +2645,9 @@ export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePaylo
export type ProjectPlacementPayload = z.infer<typeof ProjectPlacementPayloadSchema>;
export type WorkspaceStateBucket = z.infer<typeof WorkspaceStateBucketSchema>;
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 FetchAgentsResponseMessage = z.infer<typeof FetchAgentsResponseMessageSchema>;
export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesResponseMessageSchema>;

View File

@@ -38,6 +38,24 @@ describe("workspace message schemas", () => {
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", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "open_in_editor_response",
@@ -50,6 +68,33 @@ describe("workspace message schemas", () => {
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", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "workspace_update",

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 { resolve, dirname, basename } from "path";
import { realpathSync } from "fs";
@@ -87,7 +88,7 @@ async function spawnLimitedText(params: {
const accept = new Set(params.acceptExitCodes ?? [0]);
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(params.cmd, params.args, {
const child = spawnProcess(params.cmd, params.args, {
cwd: params.cwd,
env: params.env,
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;
stdoutBytes += chunk.length;
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.
let stderrPreview = "";
child.stderr.on("data", (chunk: Buffer) => {
child.stderr!.on("data", (chunk: Buffer) => {
if (stderrPreview.length > 2048) return;
stderrPreview += chunk.toString("utf8");
});
@@ -1783,9 +1784,9 @@ export interface PullRequestStatusResult {
githubFeaturesEnabled: boolean;
}
function resolveGhPath(): string {
async function resolveGhPath(): Promise<string> {
if (cachedGhPath === undefined) {
cachedGhPath = findExecutable("gh");
cachedGhPath = await findExecutable("gh");
}
if (cachedGhPath === null) {
throw new Error("GitHub CLI (gh) is not installed or not in PATH");
@@ -1859,7 +1860,7 @@ export async function createPullRequest(
options: CreatePullRequestOptions,
): Promise<{ url: string; number: number }> {
await requireGitRepo(cwd);
const ghPath = resolveGhPath();
const ghPath = await resolveGhPath();
const repo = await resolveGitHubRepo(cwd);
if (!repo) {
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;
try {
ghPath = resolveGhPath();
ghPath = await resolveGhPath();
} catch {
return {
status: null,

View File

@@ -1,12 +1,12 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
findExecutable,
findExecutableSync,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./executable.js";
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutable>[1]>;
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutableSync>[1]>;
function createFindExecutableDependencies(): FindExecutableDependencies {
return {
@@ -22,57 +22,31 @@ beforeEach(() => {
findExecutableDependencies = createFindExecutableDependencies();
});
describe("findExecutable", () => {
test("on Windows, resolves executables using current machine and user PATH entries", () => {
describe("findExecutableSync", () => {
test("on Windows, resolves executables using where.exe with inherited PATH", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((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,
findExecutableDependencies.execFileSync.mockReturnValue(
"C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n",
);
expect(findExecutable("claude", findExecutableDependencies)).toBe(
expect(findExecutableSync("claude", findExecutableDependencies)).toBe(
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
);
const powershellCall = findExecutableDependencies.execFileSync.mock.calls[0];
expect(powershellCall?.[0]).toBe("powershell");
expect(powershellCall?.[1]).toContain("-NoProfile");
expect(powershellCall?.[1]).toContain("-NonInteractive");
expect(powershellCall?.[1]).toContain(
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine"); $user = [Environment]::GetEnvironmentVariable("Path", "User"); if ($machine) { Write-Output $machine }; if ($user) { Write-Output $user }',
);
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");
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledOnce();
const call = findExecutableDependencies.execFileSync.mock.calls[0];
expect(call?.[0]).toBe("where.exe");
expect(call?.[1]).toEqual(["claude"]);
expect(call?.[2]?.encoding).toBe("utf8");
expect(call?.[2]?.windowsHide).toBe(true);
});
test("on Windows, preserves the first where.exe match", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((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,
findExecutableDependencies.execFileSync.mockReturnValue(
"C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n",
);
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", () => {
@@ -80,7 +54,7 @@ describe("findExecutable", () => {
"/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(
"which",
["codex"],
@@ -92,7 +66,7 @@ describe("findExecutable", () => {
findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(findExecutable("codex", findExecutableDependencies)).toBeNull();
expect(findExecutableSync("codex", findExecutableDependencies)).toBeNull();
expect(warnSpy).toHaveBeenCalledOnce();
warnSpy.mockRestore();
@@ -101,7 +75,7 @@ describe("findExecutable", () => {
test("returns direct paths when they exist", () => {
findExecutableDependencies.existsSync.mockReturnValue(true);
expect(findExecutable("/usr/local/bin/codex", findExecutableDependencies)).toBe(
expect(findExecutableSync("/usr/local/bin/codex", findExecutableDependencies)).toBe(
"/usr/local/bin/codex",
);
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");

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 { platform } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface FindExecutableDependencies {
execFileSync: typeof execFileSync;
existsSync: typeof existsSync;
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(
name: string,
output: string,
@@ -61,15 +38,13 @@ function resolveExecutableFromWhichOutput(
}
/**
* On Unix we use plain `which` — the daemon's process.env.PATH is enriched
* with the login shell environment at Electron desktop startup.
* On Unix we use `which`. On Windows we use `where.exe`.
*
* On Windows we augment the daemon PATH with machine/user registry PATH values
* and return the first `where.exe` match. Launch-time execution decides whether
* the resolved path needs `cmd.exe` semantics (for example npm shims under
* nvm4w such as `C:\nvm4w\nodejs\codex`).
* Both rely on the inherited process.env.PATH — on macOS/Linux, Electron
* enriches it at startup via inheritLoginShellEnv(); on Windows, Electron
* inherits the full user environment from Explorer.
*/
export function findExecutable(
export function findExecutableSync(
name: string,
dependencies?: FindExecutableDependencies,
): string | null {
@@ -91,21 +66,10 @@ export function findExecutable(
if (deps.platform() === "win32") {
try {
const inheritedPath = process.env["Path"] ?? process.env["PATH"] ?? "";
const resolvedPath = [
...inheritedPath.split(";"),
...resolveWindowsPathEntries(deps),
]
.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();
const out = deps.execFileSync("where.exe", [trimmed], {
encoding: "utf8",
windowsHide: true,
}).trim();
return (
out
.split(/\r?\n/)
@@ -128,8 +92,48 @@ export function findExecutable(
}
}
export function isCommandAvailable(command: string): boolean {
return findExecutable(command) !== null;
export function isCommandAvailableSync(command: string): boolean {
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;
}
/**

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}"`;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.50",
"version": "0.1.51-rc.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -53,6 +53,12 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
<html lang="en">
<head>
<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>
<body className="antialiased bg-background text-foreground">
{children}

View File

@@ -77,8 +77,8 @@ function UpdatesDocs() {
<section className="space-y-4">
<h2 className="text-xl font-medium">Mobile apps</h2>
<p className="text-white/60">
Mobile apps are not yet publicly available in the App Store or Play Store. We are working
on public store availability.
Mobile apps are available on the App Store and Play Store. Update through your respective
store. Store versions may lag behind the latest release due to review processes.
</p>
</section>
</div>

View File

@@ -15,7 +15,7 @@ export const Route = createFileRoute("/")({
function Home() {
return (
<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."
/>
);