mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
28 Commits
v0.1.50
...
v0.1.51-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4925b94743 | ||
|
|
f0b09d90fd | ||
|
|
bf91ea2cc9 | ||
|
|
f83a4d08da | ||
|
|
83f540f10a | ||
|
|
602b548745 | ||
|
|
d98d5457ed | ||
|
|
6bf8da8087 | ||
|
|
d39414064e | ||
|
|
ffcc35485d | ||
|
|
8ff94dc03e | ||
|
|
dace4f862f | ||
|
|
fc667d6312 | ||
|
|
aae4d9f8dd | ||
|
|
5eb8b300a3 | ||
|
|
6c9a832906 | ||
|
|
35430dab52 | ||
|
|
0eac4bc4b3 | ||
|
|
635de3d76a | ||
|
|
931d3ba81f | ||
|
|
6a4f439541 | ||
|
|
ac5e6df6c9 | ||
|
|
bf7d3c2775 | ||
|
|
5c93fbc955 | ||
|
|
5ac7b3f7c7 | ||
|
|
c742f17080 | ||
|
|
b4d6a5d6b8 | ||
|
|
43f01600ce |
43
.github/workflows/desktop-release.yml
vendored
43
.github/workflows/desktop-release.yml
vendored
@@ -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
|
||||
|
||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
38
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.51-rc.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
BIN
packages/app/assets/images/editor-apps/webstorm.png
Normal file
BIN
packages/app/assets/images/editor-apps/webstorm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -6,8 +6,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult } from "expo-camera";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { NameHostModal } from "@/components/name-host-modal";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
@@ -61,7 +59,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
position: "absolute",
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderColor: theme.colors.palette.blue[400],
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
cornerTL: {
|
||||
left: 0,
|
||||
@@ -148,33 +146,16 @@ export default function PairScanScreen() {
|
||||
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
||||
const daemons = useHosts();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
|
||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
const lastScannedRef = useRef<string | null>(null);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
} | null>(null);
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!pendingNameHost) return null;
|
||||
return (
|
||||
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||
pendingNameHost.hostname ??
|
||||
null
|
||||
);
|
||||
},
|
||||
[pendingNameHost],
|
||||
),
|
||||
);
|
||||
|
||||
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 +171,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 +190,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);
|
||||
@@ -224,7 +205,6 @@ export default function PairScanScreen() {
|
||||
|
||||
const handleScan = useCallback(
|
||||
async (result: BarcodeScanningResult) => {
|
||||
if (pendingNameHost) return;
|
||||
if (isPairing) return;
|
||||
const offerUrl = extractOfferUrlFromScan(result);
|
||||
if (!offerUrl) return;
|
||||
@@ -248,7 +228,7 @@ export default function PairScanScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const { client } = await connectToDaemon(
|
||||
const { client, hostname } = await connectToDaemon(
|
||||
{
|
||||
id: "probe",
|
||||
type: "relay",
|
||||
@@ -259,13 +239,7 @@ export default function PairScanScreen() {
|
||||
);
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
|
||||
const profile = await upsertDaemonFromOfferUrl(offerUrl);
|
||||
|
||||
if (isNewHost) {
|
||||
setPendingNameHost({ serverId: profile.serverId, hostname: null });
|
||||
return;
|
||||
}
|
||||
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
||||
|
||||
returnToSource(profile.serverId);
|
||||
} catch (error) {
|
||||
@@ -276,7 +250,7 @@ export default function PairScanScreen() {
|
||||
setIsPairing(false);
|
||||
}
|
||||
},
|
||||
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||
);
|
||||
|
||||
if (Platform.OS === "web") {
|
||||
@@ -307,25 +281,6 @@ export default function PairScanScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{pendingNameHost ? (
|
||||
<NameHostModal
|
||||
visible
|
||||
serverId={pendingNameHost.serverId}
|
||||
hostname={pendingNameHostname}
|
||||
onSkip={() => {
|
||||
const serverId = pendingNameHost.serverId;
|
||||
setPendingNameHost(null);
|
||||
returnToSource(serverId);
|
||||
}}
|
||||
onSave={(label) => {
|
||||
const serverId = pendingNameHost.serverId;
|
||||
void renameHost(serverId, label).finally(() => {
|
||||
setPendingNameHost(null);
|
||||
returnToSource(serverId);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||
<Text style={styles.headerTitle}>Scan QR</Text>
|
||||
<Pressable onPress={closeToSource}>
|
||||
@@ -359,7 +314,6 @@ export default function PairScanScreen() {
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
|
||||
{isPairing ? (
|
||||
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
||||
Pairing…
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -218,6 +218,7 @@ export function AddHostModal({
|
||||
const profile = await upsertDirectConnection({
|
||||
serverId,
|
||||
endpoint,
|
||||
label: hostname ?? undefined,
|
||||
});
|
||||
|
||||
onSaved?.({ profile, serverId, hostname, isNewHost });
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { submitAgentInput } from "@/components/agent-input-submit";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string;
|
||||
@@ -131,6 +132,8 @@ export function AgentInputArea({
|
||||
agentDirectoryStatus === "revalidating" ||
|
||||
agentDirectoryStatus === "error_after_ready");
|
||||
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
@@ -742,6 +745,7 @@ export function AgentInputArea({
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -87,6 +87,9 @@ export interface MessageInputProps {
|
||||
voiceAgentId?: string;
|
||||
/** When true and there's sendable content, calls onQueue instead of onSubmit */
|
||||
isAgentRunning?: boolean;
|
||||
/** Controls what the default send action (Enter, send button, dictation) does
|
||||
* when the agent is running. "interrupt" sends immediately, "queue" queues. */
|
||||
defaultSendBehavior?: "interrupt" | "queue";
|
||||
/** Callback for queue button when agent is running */
|
||||
onQueue?: (payload: MessagePayload) => void;
|
||||
/** Optional handler used when submit button is in loading state. */
|
||||
@@ -208,6 +211,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
voiceServerId,
|
||||
voiceAgentId,
|
||||
isAgentRunning = false,
|
||||
defaultSendBehavior = "interrupt",
|
||||
onQueue,
|
||||
onSubmitLoadingPress,
|
||||
onKeyPress: onKeyPressCallback,
|
||||
@@ -352,11 +356,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
|
||||
if (shouldAutoSend) {
|
||||
const imageAttachments = images.length > 0 ? images : undefined;
|
||||
onSubmit({
|
||||
text: nextValue,
|
||||
images: imageAttachments,
|
||||
forceSend: isAgentRunning || undefined,
|
||||
});
|
||||
// Respect send behavior setting: when "queue", dictation queues too.
|
||||
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
|
||||
onQueue({ text: nextValue, images: imageAttachments });
|
||||
onChangeText("");
|
||||
} else {
|
||||
onSubmit({
|
||||
text: nextValue,
|
||||
images: imageAttachments,
|
||||
forceSend: isAgentRunning || undefined,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
onChangeText(nextValue);
|
||||
}
|
||||
@@ -367,7 +377,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChangeText, onSubmit, images, isAgentRunning],
|
||||
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
|
||||
);
|
||||
|
||||
const handleDictationError = useCallback(
|
||||
@@ -578,6 +588,26 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
onHeightChange?.(MIN_INPUT_HEIGHT);
|
||||
}, [value, images, onQueue, onChangeText, onHeightChange]);
|
||||
|
||||
// Default send action: respects the sendBehavior setting.
|
||||
// When "interrupt" (default), primary action sends immediately (interrupts).
|
||||
// When "queue", primary action queues when agent is running.
|
||||
const handleDefaultSendAction = useCallback(() => {
|
||||
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
|
||||
handleQueueMessage();
|
||||
} else {
|
||||
handleSendMessage();
|
||||
}
|
||||
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
|
||||
|
||||
// Alternate send action: always the opposite of the default.
|
||||
const handleAlternateSendAction = useCallback(() => {
|
||||
if (defaultSendBehavior === "queue") {
|
||||
handleSendMessage(); // interrupt
|
||||
} else if (onQueue) {
|
||||
handleQueueMessage(); // queue
|
||||
}
|
||||
}, [defaultSendBehavior, handleSendMessage, handleQueueMessage, onQueue]);
|
||||
|
||||
// Web input height measurement
|
||||
function isTextAreaLike(v: unknown): v is TextAreaHandle {
|
||||
return typeof v === "object" && v !== null && "scrollHeight" in v;
|
||||
@@ -872,18 +902,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
// Shift+Enter: add newline (default behavior, don't intercept)
|
||||
if (shiftKey) return;
|
||||
|
||||
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running
|
||||
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action
|
||||
if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
|
||||
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
||||
event.preventDefault();
|
||||
handleQueueMessage();
|
||||
handleAlternateSendAction();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter: send (interrupts agent if running)
|
||||
// Enter: default send action (interrupt or queue, based on setting)
|
||||
if (isSubmitDisabled || isSubmitLoading || disabled) return;
|
||||
event.preventDefault();
|
||||
handleSendMessage();
|
||||
handleDefaultSendAction();
|
||||
}
|
||||
|
||||
const hasImages = images.length > 0;
|
||||
@@ -892,11 +922,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||
const isSendButtonDisabled =
|
||||
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
||||
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
|
||||
const submitAccessibilityLabel = canPressLoadingButton
|
||||
? "Interrupt agent"
|
||||
: isAgentRunning
|
||||
? "Send and interrupt"
|
||||
: "Send message";
|
||||
: defaultActionQueues
|
||||
? "Queue message"
|
||||
: isAgentRunning
|
||||
? "Send and interrupt"
|
||||
: "Send message";
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
@@ -1071,10 +1104,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{rightContent}
|
||||
{hasSendableContent && isAgentRunning && onQueue && (
|
||||
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleQueueMessage}
|
||||
onPress={handleAlternateSendAction}
|
||||
disabled={!isConnected || disabled}
|
||||
accessibilityLabel="Queue message"
|
||||
accessibilityRole="button"
|
||||
@@ -1099,7 +1132,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
{shouldShowSendButton && (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage}
|
||||
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
|
||||
disabled={isSendButtonDisabled}
|
||||
accessibilityLabel={submitAccessibilityLabel}
|
||||
accessibilityRole="button"
|
||||
@@ -1113,7 +1146,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Send</Text>
|
||||
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text>
|
||||
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
field: {
|
||||
marginTop: theme.spacing[3],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[3],
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
export interface NameHostModalProps {
|
||||
visible: boolean;
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
onSkip: () => void;
|
||||
onSave: (label: string) => void;
|
||||
}
|
||||
|
||||
export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: NameHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const [label, setLabel] = useState("");
|
||||
const hasEditedRef = useRef(false);
|
||||
|
||||
const suggested = (hostname?.trim() || serverId).trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setLabel(suggested);
|
||||
hasEditedRef.current = false;
|
||||
}, [suggested, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (hasEditedRef.current) return;
|
||||
if (!hostname) return;
|
||||
const trimmed = label.trim();
|
||||
if (trimmed.length === 0 || trimmed === serverId) {
|
||||
setLabel(hostname.trim());
|
||||
}
|
||||
}, [hostname, label, serverId, visible]);
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
hasEditedRef.current = true;
|
||||
setLabel(value);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) {
|
||||
onSkip();
|
||||
return;
|
||||
}
|
||||
onSave(trimmed);
|
||||
}, [label, onSave, onSkip]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Name this host"
|
||||
visible={visible}
|
||||
onClose={onSkip}
|
||||
testID="name-host-modal"
|
||||
>
|
||||
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Label</Text>
|
||||
<AdaptiveTextInput
|
||||
value={label}
|
||||
onChangeText={handleChange}
|
||||
placeholder={suggested}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSave}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button style={{ flex: 1 }} variant="secondary" onPress={onSkip} testID="name-host-skip">
|
||||
Skip
|
||||
</Button>
|
||||
<Button style={{ flex: 1 }} variant="default" onPress={handleSave} testID="name-host-save">
|
||||
Save
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export function PairLinkModal({
|
||||
await client.close().catch(() => undefined);
|
||||
|
||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
|
||||
const profile = await upsertDaemonFromOfferUrl(raw);
|
||||
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
|
||||
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1113,7 +1113,7 @@ function WorkspaceRowWithMenu({
|
||||
serverId: workspace.serverId,
|
||||
archivedWorkspaceId: workspace.workspaceId,
|
||||
workspaces: sessionWorkspaces.values(),
|
||||
}) as any,
|
||||
}),
|
||||
);
|
||||
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
|
||||
@@ -8,14 +8,11 @@ import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHostMutations,
|
||||
useHostRuntimeSnapshot,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
import { NameHostModal } from "./name-host-modal";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
@@ -237,45 +234,23 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const { renameHost } = useHostMutations();
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||
const [isDirectOpen, setIsDirectOpen] = useState(false);
|
||||
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
} | null>(null);
|
||||
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
|
||||
const hosts = useHosts();
|
||||
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!pendingNameHost) return null;
|
||||
return (
|
||||
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||
pendingNameHost.hostname ??
|
||||
null
|
||||
);
|
||||
},
|
||||
[pendingNameHost],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anyOnlineServerId) {
|
||||
return;
|
||||
}
|
||||
if (pendingNameHost) {
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(anyOnlineServerId) as any);
|
||||
}, [anyOnlineServerId, pendingNameHost, router]);
|
||||
router.replace(buildHostRootRoute(anyOnlineServerId));
|
||||
}, [anyOnlineServerId, router]);
|
||||
|
||||
const finishOnboarding = useCallback(
|
||||
(serverId: string) => {
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
},
|
||||
[router],
|
||||
);
|
||||
@@ -396,13 +371,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<AddHostModal
|
||||
visible={isDirectOpen}
|
||||
onClose={() => setIsDirectOpen(false)}
|
||||
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
||||
onSaved={({ profile, serverId }) => {
|
||||
onHostAdded?.(profile);
|
||||
setPendingRedirectServerId(serverId);
|
||||
if (isNewHost) {
|
||||
setPendingNameHost({ serverId, hostname });
|
||||
return;
|
||||
}
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
/>
|
||||
@@ -410,38 +380,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
<PairLinkModal
|
||||
visible={isPasteLinkOpen}
|
||||
onClose={() => setIsPasteLinkOpen(false)}
|
||||
onSaved={({ profile, serverId, hostname, isNewHost }) => {
|
||||
onSaved={({ profile, serverId }) => {
|
||||
onHostAdded?.(profile);
|
||||
setPendingRedirectServerId(serverId);
|
||||
if (isNewHost) {
|
||||
setPendingNameHost({ serverId, hostname });
|
||||
return;
|
||||
}
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
/>
|
||||
|
||||
{pendingNameHost && pendingRedirectServerId ? (
|
||||
<NameHostModal
|
||||
visible
|
||||
serverId={pendingNameHost.serverId}
|
||||
hostname={pendingNameHostname}
|
||||
onSkip={() => {
|
||||
const serverId = pendingRedirectServerId;
|
||||
setPendingNameHost(null);
|
||||
setPendingRedirectServerId(null);
|
||||
finishOnboarding(serverId);
|
||||
}}
|
||||
onSave={(label) => {
|
||||
const serverId = pendingRedirectServerId;
|
||||
void renameHost(pendingNameHost.serverId, label).finally(() => {
|
||||
setPendingNameHost(null);
|
||||
setPendingRedirectServerId(null);
|
||||
finishOnboarding(serverId);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
|
||||
import { useCallback, useState } from "react";
|
||||
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";
|
||||
import {
|
||||
@@ -12,7 +10,6 @@ import {
|
||||
RotateCw,
|
||||
Copy,
|
||||
FileText,
|
||||
Smartphone,
|
||||
Activity,
|
||||
} from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
@@ -23,17 +20,12 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
|
||||
import {
|
||||
getCliDaemonStatus,
|
||||
getDesktopDaemonLogs,
|
||||
getDesktopDaemonPairing,
|
||||
getDesktopDaemonStatus,
|
||||
restartDesktopDaemon,
|
||||
shouldUseDesktopDaemon,
|
||||
startDesktopDaemon,
|
||||
stopDesktopDaemon,
|
||||
type DesktopDaemonLogs,
|
||||
type DesktopDaemonStatus,
|
||||
type DesktopPairingOffer,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
|
||||
export interface LocalDaemonSectionProps {
|
||||
appVersion: string | null;
|
||||
@@ -44,48 +36,18 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
const { theme } = useUnistyles();
|
||||
const showSection = shouldUseDesktopDaemon();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null);
|
||||
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
|
||||
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
|
||||
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
|
||||
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);
|
||||
|
||||
const loadDaemonData = useCallback(() => {
|
||||
if (!showSection) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
|
||||
.then(([status, logs]) => {
|
||||
setDaemonStatus(status);
|
||||
setDaemonLogs(logs);
|
||||
setDaemonVersion(status.version);
|
||||
setStatusError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatusError(message);
|
||||
});
|
||||
}, [showSection]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!showSection) {
|
||||
return undefined;
|
||||
}
|
||||
void loadDaemonData();
|
||||
return undefined;
|
||||
}, [loadDaemonData, showSection]),
|
||||
);
|
||||
const daemonStatus = data?.status ?? null;
|
||||
const daemonLogs = data?.logs ?? null;
|
||||
const daemonVersion = daemonStatus?.version ?? null;
|
||||
|
||||
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
|
||||
const daemonStatusStateText =
|
||||
@@ -124,12 +86,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
|
||||
|
||||
void action()
|
||||
.then((status) => {
|
||||
setDaemonStatus(status);
|
||||
.then((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setStatusMessage(
|
||||
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
|
||||
);
|
||||
return loadDaemonData();
|
||||
refetch();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Settings] Failed to change desktop daemon state", error);
|
||||
@@ -144,7 +106,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
|
||||
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
|
||||
});
|
||||
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]);
|
||||
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, refetch, setStatus, showSection]);
|
||||
|
||||
const handleToggleDaemonManagement = useCallback(() => {
|
||||
if (isUpdatingDaemonManagement) {
|
||||
@@ -190,9 +152,14 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
: Promise.resolve(daemonStatus ?? null);
|
||||
|
||||
void stopPromise
|
||||
.then(() => updateSettings({ manageBuiltInDaemon: false }))
|
||||
.then(() => loadDaemonData())
|
||||
.then((newStatus) => {
|
||||
if (newStatus) {
|
||||
setStatus(newStatus);
|
||||
}
|
||||
return updateSettings({ manageBuiltInDaemon: false });
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
setStatusMessage("Built-in daemon paused and stopped.");
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -210,7 +177,8 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
}, [
|
||||
daemonStatus,
|
||||
isUpdatingDaemonManagement,
|
||||
loadDaemonData,
|
||||
refetch,
|
||||
setStatus,
|
||||
settings.manageBuiltInDaemon,
|
||||
updateSettings,
|
||||
]);
|
||||
@@ -238,46 +206,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 {
|
||||
@@ -325,154 +253,138 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
|
||||
Advanced settings
|
||||
</Button>
|
||||
</View>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
||||
</View>
|
||||
{isLoading ? (
|
||||
<View style={[settingsStyles.card, styles.loadingCard]}>
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
{showLifecycleControls ? (
|
||||
<>
|
||||
) : (
|
||||
<>
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Only the built-in desktop daemon is shown here.
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statusValueGroup}>
|
||||
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
|
||||
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{showLifecycleControls ? (
|
||||
<>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{isDaemonManagementPaused
|
||||
? "Paused. The built-in daemon stays stopped until you start it again."
|
||||
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={
|
||||
isDaemonManagementPaused ? (
|
||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
>
|
||||
{isUpdatingDaemonManagement
|
||||
? isDaemonManagementPaused
|
||||
? "Resuming..."
|
||||
: "Pausing..."
|
||||
: isDaemonManagementPaused
|
||||
? "Resume"
|
||||
: "Pause"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isRestartingDaemon
|
||||
? daemonStatus?.status === "running"
|
||||
? "Restarting..."
|
||||
: "Starting..."
|
||||
: daemonActionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
{isDaemonManagementPaused
|
||||
? "Paused. The built-in daemon stays stopped until you start it again."
|
||||
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
|
||||
{daemonLogs?.logPath ?? "Log path unavailable."}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{daemonLogs?.logPath ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleCopyLogPath}
|
||||
>
|
||||
Copy path
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenLogs}
|
||||
disabled={!daemonLogs}
|
||||
>
|
||||
Open logs
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Full status</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output.
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={
|
||||
isDaemonManagementPaused ? (
|
||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void handleOpenCliStatus()}
|
||||
disabled={isLoadingCliStatus}
|
||||
>
|
||||
{isUpdatingDaemonManagement
|
||||
? isDaemonManagementPaused
|
||||
? "Resuming..."
|
||||
: "Pausing..."
|
||||
: isDaemonManagementPaused
|
||||
? "Resume"
|
||||
: "Pause"}
|
||||
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isRestartingDaemon
|
||||
? daemonStatus?.status === "running"
|
||||
? "Restarting..."
|
||||
: "Starting..."
|
||||
: daemonActionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{daemonVersionMismatch ? (
|
||||
<View style={styles.warningCard}>
|
||||
<Text style={styles.warningText}>
|
||||
App and daemon versions don't match. Update both to the same version for the best
|
||||
experience.
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Log file</Text>
|
||||
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
|
||||
</View>
|
||||
<View style={styles.actionGroup}>
|
||||
{daemonLogs?.logPath ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleCopyLogPath}
|
||||
>
|
||||
Copy path
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleOpenLogs}
|
||||
disabled={!daemonLogs}
|
||||
>
|
||||
Open logs
|
||||
</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>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Runs `paseo daemon status` and shows the output.
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={() => void handleOpenCliStatus()}
|
||||
disabled={isLoadingCliStatus}
|
||||
>
|
||||
{isLoadingCliStatus ? "Loading..." : "View status"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{daemonVersionMismatch ? (
|
||||
<View style={styles.warningCard}>
|
||||
<Text style={styles.warningText}>
|
||||
App and daemon versions don't match. Update both to the same version for the best
|
||||
experience.
|
||||
</Text>
|
||||
</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>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<AdaptiveModalSheet
|
||||
visible={isLogsModalOpen}
|
||||
@@ -516,107 +428,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",
|
||||
@@ -624,6 +435,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
loadingCard: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[6],
|
||||
},
|
||||
statusValueGroup: {
|
||||
alignItems: "flex-end",
|
||||
gap: 2,
|
||||
@@ -659,40 +475,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,
|
||||
|
||||
187
packages/app/src/desktop/components/pair-device-section.tsx
Normal file
187
packages/app/src/desktop/components/pair-device-section.tsx
Normal 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,
|
||||
}));
|
||||
53
packages/app/src/desktop/hooks/use-daemon-status.ts
Normal file
53
packages/app/src/desktop/hooks/use-daemon-status.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getDesktopDaemonLogs,
|
||||
getDesktopDaemonStatus,
|
||||
shouldUseDesktopDaemon,
|
||||
type DesktopDaemonLogs,
|
||||
type DesktopDaemonStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const;
|
||||
|
||||
interface DaemonStatusData {
|
||||
status: DesktopDaemonStatus;
|
||||
logs: DesktopDaemonLogs;
|
||||
}
|
||||
|
||||
export function useDaemonStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
|
||||
const query = useQuery<DaemonStatusData>({
|
||||
queryKey: DAEMON_STATUS_QUERY_KEY,
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: "always",
|
||||
queryFn: async () => {
|
||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||
return { status, logs };
|
||||
},
|
||||
});
|
||||
|
||||
const setStatus = useCallback(
|
||||
(status: DesktopDaemonStatus) => {
|
||||
queryClient.setQueryData<DaemonStatusData>(DAEMON_STATUS_QUERY_KEY, (prev) =>
|
||||
prev ? { ...prev, status } : undefined,
|
||||
);
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: DAEMON_STATUS_QUERY_KEY });
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
data: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error instanceof Error ? query.error.message : null,
|
||||
setStatus,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -6,14 +6,18 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
|
||||
export interface AppSettings {
|
||||
theme: "dark" | "light" | "auto";
|
||||
manageBuiltInDaemon: boolean;
|
||||
sendBehavior: SendBehavior;
|
||||
}
|
||||
|
||||
export const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
theme: "auto",
|
||||
manageBuiltInDaemon: true,
|
||||
sendBehavior: "interrupt",
|
||||
};
|
||||
|
||||
export interface UseAppSettingsReturn {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
FetchAgentsEntry,
|
||||
FetchAgentsOptions,
|
||||
} from "@server/client/daemon-client";
|
||||
import type { ConnectionOffer } from "@server/shared/connection-offer";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
@@ -189,6 +190,17 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
|
||||
};
|
||||
}
|
||||
|
||||
function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
|
||||
return {
|
||||
v: 2,
|
||||
serverId: input?.serverId ?? "srv_offer",
|
||||
daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer",
|
||||
relay: {
|
||||
endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
latencyByConnectionId: Record<string, number | Error>,
|
||||
createdClients: FakeDaemonClient[],
|
||||
@@ -1295,4 +1307,53 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.label).toBe("mbp");
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
|
||||
it("keeps a custom host label when re-pairing with an advertised hostname", async () => {
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
||||
connectToDaemon: async ({ host }) => ({
|
||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
||||
serverId: host.serverId,
|
||||
hostname: host.label ?? null,
|
||||
}),
|
||||
getClientId: async () => "cid_test_runtime",
|
||||
},
|
||||
});
|
||||
|
||||
await store.upsertRelayConnection({
|
||||
serverId: "srv_offer",
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pk_test_offer",
|
||||
label: "Custom name",
|
||||
});
|
||||
|
||||
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
|
||||
|
||||
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
|
||||
expect(pairedHost?.label).toBe("Custom name");
|
||||
|
||||
store.syncHosts([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1287,15 +1287,22 @@ export class HostRuntimeStore {
|
||||
});
|
||||
}
|
||||
|
||||
async upsertConnectionFromOffer(offer: ConnectionOffer): Promise<HostProfile> {
|
||||
async upsertConnectionFromOffer(
|
||||
offer: ConnectionOffer,
|
||||
label?: string,
|
||||
): Promise<HostProfile> {
|
||||
return this.upsertRelayConnection({
|
||||
serverId: offer.serverId,
|
||||
relayEndpoint: offer.relay.endpoint,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
async upsertConnectionFromOfferUrl(offerUrlOrFragment: string): Promise<HostProfile> {
|
||||
async upsertConnectionFromOfferUrl(
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
): Promise<HostProfile> {
|
||||
const marker = "#offer=";
|
||||
const idx = offerUrlOrFragment.indexOf(marker);
|
||||
if (idx === -1) {
|
||||
@@ -1307,7 +1314,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
const payload = decodeOfferFragmentPayload(encoded);
|
||||
const offer = ConnectionOfferSchema.parse(payload);
|
||||
return this.upsertConnectionFromOffer(offer);
|
||||
return this.upsertConnectionFromOffer(offer, label);
|
||||
}
|
||||
|
||||
async addConnectionFromListenAndWaitForOnline(input: {
|
||||
@@ -1956,8 +1963,11 @@ export interface HostMutations {
|
||||
daemonPublicKeyB64: string;
|
||||
label?: string;
|
||||
}) => Promise<HostProfile>;
|
||||
upsertConnectionFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>;
|
||||
upsertConnectionFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
|
||||
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
|
||||
upsertConnectionFromOfferUrl: (
|
||||
offerUrlOrFragment: string,
|
||||
label?: string,
|
||||
) => Promise<HostProfile>;
|
||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||
removeHost: (serverId: string) => Promise<void>;
|
||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||
@@ -1969,8 +1979,8 @@ export function useHostMutations(): HostMutations {
|
||||
() => ({
|
||||
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
|
||||
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
|
||||
upsertConnectionFromOffer: (offer) => store.upsertConnectionFromOffer(offer),
|
||||
upsertConnectionFromOfferUrl: (url) => store.upsertConnectionFromOfferUrl(url),
|
||||
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
|
||||
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
|
||||
renameHost: (serverId, label) => store.renameHost(serverId, label),
|
||||
removeHost: (serverId) => store.removeHost(serverId),
|
||||
removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,15 +15,15 @@ import {
|
||||
RotateCw,
|
||||
Trash2,
|
||||
Server,
|
||||
Palette,
|
||||
Keyboard,
|
||||
Stethoscope,
|
||||
Info,
|
||||
Shield,
|
||||
Puzzle,
|
||||
Blocks,
|
||||
Smartphone,
|
||||
} from "lucide-react-native";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings";
|
||||
import type { HostProfile, HostConnection } from "@/types/host-connection";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
@@ -41,7 +41,6 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal";
|
||||
import { AddHostModal } from "@/components/add-host-modal";
|
||||
import { PairLinkModal } from "@/components/pair-link-modal";
|
||||
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
|
||||
import { NameHostModal } from "@/components/name-host-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||
import {
|
||||
@@ -54,6 +53,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";
|
||||
@@ -75,14 +75,15 @@ import { StatusBadge } from "@/components/ui/status-badge";
|
||||
|
||||
type SettingsSectionId =
|
||||
| "hosts"
|
||||
| "appearance"
|
||||
| "general"
|
||||
| "shortcuts"
|
||||
| "integrations"
|
||||
| "providers"
|
||||
| "diagnostics"
|
||||
| "about"
|
||||
| "permissions"
|
||||
| "daemon";
|
||||
| "daemon"
|
||||
| "pair-device";
|
||||
|
||||
interface SettingsSectionDef {
|
||||
id: SettingsSectionId;
|
||||
@@ -93,20 +94,21 @@ interface SettingsSectionDef {
|
||||
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
|
||||
const sections: SettingsSectionDef[] = [
|
||||
{ id: "hosts", label: "Hosts", icon: Server },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
||||
{ id: "general", label: "General", icon: Settings },
|
||||
{ id: "permissions", label: "Permissions", icon: Shield },
|
||||
];
|
||||
|
||||
if (context.isDesktopApp) {
|
||||
sections.push(
|
||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
||||
{ id: "integrations", label: "Integrations", icon: Puzzle },
|
||||
{ id: "pair-device", label: "Pair device", icon: Smartphone },
|
||||
{ id: "daemon", label: "Daemon", icon: Settings },
|
||||
{ id: "providers", label: "Providers", icon: Blocks },
|
||||
);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
{ id: "providers", label: "Providers", icon: Blocks },
|
||||
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
);
|
||||
@@ -200,10 +202,6 @@ interface HostsSectionProps {
|
||||
goBackToAddConnectionMethods: () => void;
|
||||
setIsDirectHostVisible: (visible: boolean) => void;
|
||||
setIsPasteLinkVisible: (visible: boolean) => void;
|
||||
pendingNameHost: { serverId: string; hostname: string | null } | null;
|
||||
setPendingNameHost: (host: { serverId: string; hostname: string | null } | null) => void;
|
||||
pendingNameHostname: string | null;
|
||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||
pendingRemoveHost: HostProfile | null;
|
||||
setPendingRemoveHost: (host: HostProfile | null) => void;
|
||||
isRemovingHost: boolean;
|
||||
@@ -286,38 +284,14 @@ function HostsSection(props: HostsSectionProps) {
|
||||
visible={props.isDirectHostVisible}
|
||||
onClose={props.closeAddConnectionFlow}
|
||||
onCancel={props.goBackToAddConnectionMethods}
|
||||
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||
if (isNewHost) {
|
||||
props.setPendingNameHost({ serverId, hostname });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<PairLinkModal
|
||||
visible={props.isPasteLinkVisible}
|
||||
onClose={props.closeAddConnectionFlow}
|
||||
onCancel={props.goBackToAddConnectionMethods}
|
||||
onSaved={({ serverId, hostname, isNewHost }) => {
|
||||
if (isNewHost) {
|
||||
props.setPendingNameHost({ serverId, hostname });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{props.pendingNameHost ? (
|
||||
<NameHostModal
|
||||
visible
|
||||
serverId={props.pendingNameHost.serverId}
|
||||
hostname={props.pendingNameHostname}
|
||||
onSkip={() => props.setPendingNameHost(null)}
|
||||
onSave={(label) => {
|
||||
void props.renameHost(props.pendingNameHost!.serverId, label).finally(() => {
|
||||
props.setPendingNameHost(null);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{props.pendingRemoveHost ? (
|
||||
<AdaptiveModalSheet
|
||||
title="Remove host"
|
||||
@@ -382,15 +356,20 @@ function HostsSection(props: HostsSectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface AppearanceSectionProps {
|
||||
interface GeneralSectionProps {
|
||||
settings: AppSettings;
|
||||
handleThemeChange: (theme: AppSettings["theme"]) => void;
|
||||
handleSendBehaviorChange: (behavior: SendBehavior) => void;
|
||||
}
|
||||
|
||||
function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) {
|
||||
function GeneralSection({
|
||||
settings,
|
||||
handleThemeChange,
|
||||
handleSendBehaviorChange,
|
||||
}: GeneralSectionProps) {
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Appearance</Text>
|
||||
<Text style={settingsStyles.sectionTitle}>General</Text>
|
||||
<View style={[settingsStyles.card, styles.audioCard]}>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
@@ -420,6 +399,23 @@ function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionPro
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.audioRow}>
|
||||
<View style={styles.audioRowContent}>
|
||||
<Text style={styles.audioRowTitle}>Default send</Text>
|
||||
<Text style={styles.audioRowSubtitle}>
|
||||
What happens when you press Enter while the agent is running
|
||||
</Text>
|
||||
</View>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={settings.sendBehavior}
|
||||
onValueChange={handleSendBehaviorChange}
|
||||
options={[
|
||||
{ value: "interrupt", label: "Interrupt" },
|
||||
{ value: "queue", label: "Queue" },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -610,7 +606,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
|
||||
interface SettingsSectionContentProps {
|
||||
sectionId: SettingsSectionId;
|
||||
hostsProps: HostsSectionProps;
|
||||
appearanceProps: AppearanceSectionProps;
|
||||
generalProps: GeneralSectionProps;
|
||||
providersProps: ProvidersSectionProps;
|
||||
diagnosticsProps: DiagnosticsSectionProps;
|
||||
aboutProps: AboutSectionProps;
|
||||
@@ -622,7 +618,7 @@ interface SettingsSectionContentProps {
|
||||
function SettingsSectionContent({
|
||||
sectionId,
|
||||
hostsProps,
|
||||
appearanceProps,
|
||||
generalProps,
|
||||
providersProps,
|
||||
diagnosticsProps,
|
||||
aboutProps,
|
||||
@@ -633,8 +629,8 @@ function SettingsSectionContent({
|
||||
switch (sectionId) {
|
||||
case "hosts":
|
||||
return <HostsSection {...hostsProps} />;
|
||||
case "appearance":
|
||||
return <AppearanceSection {...appearanceProps} />;
|
||||
case "general":
|
||||
return <GeneralSection {...generalProps} />;
|
||||
case "shortcuts":
|
||||
return <KeyboardShortcutsSection />;
|
||||
case "providers":
|
||||
@@ -647,6 +643,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} />
|
||||
@@ -857,10 +855,6 @@ export default function SettingsScreen() {
|
||||
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
|
||||
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
|
||||
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
|
||||
const [pendingNameHost, setPendingNameHost] = useState<{
|
||||
serverId: string;
|
||||
hostname: string | null;
|
||||
} | null>(null);
|
||||
const [pendingRemoveHost, setPendingRemoveHost] = useState<HostProfile | null>(null);
|
||||
const [isRemovingHost, setIsRemovingHost] = useState(false);
|
||||
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
|
||||
@@ -878,19 +872,6 @@ export default function SettingsScreen() {
|
||||
const editingDaemonLive = editingServerId
|
||||
? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null)
|
||||
: null;
|
||||
const pendingNameHostname = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!pendingNameHost) return null;
|
||||
return (
|
||||
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
|
||||
pendingNameHost.hostname ??
|
||||
null
|
||||
);
|
||||
},
|
||||
[pendingNameHost],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -997,6 +978,13 @@ export default function SettingsScreen() {
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handleSendBehaviorChange = useCallback(
|
||||
(behavior: SendBehavior) => {
|
||||
void updateSettings({ sendBehavior: behavior });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
|
||||
const handlePlaybackTest = useCallback(async () => {
|
||||
if (!voiceAudioEngine || isPlaybackTestRunning) {
|
||||
return;
|
||||
@@ -1043,10 +1031,6 @@ export default function SettingsScreen() {
|
||||
goBackToAddConnectionMethods,
|
||||
setIsDirectHostVisible,
|
||||
setIsPasteLinkVisible,
|
||||
pendingNameHost,
|
||||
setPendingNameHost,
|
||||
pendingNameHostname,
|
||||
renameHost,
|
||||
pendingRemoveHost,
|
||||
setPendingRemoveHost,
|
||||
isRemovingHost,
|
||||
@@ -1063,9 +1047,10 @@ export default function SettingsScreen() {
|
||||
isMountedRef,
|
||||
};
|
||||
|
||||
const appearanceProps: AppearanceSectionProps = {
|
||||
const generalProps: GeneralSectionProps = {
|
||||
settings,
|
||||
handleThemeChange,
|
||||
handleSendBehaviorChange,
|
||||
};
|
||||
|
||||
const diagnosticsProps: DiagnosticsSectionProps = {
|
||||
@@ -1086,7 +1071,7 @@ export default function SettingsScreen() {
|
||||
|
||||
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
|
||||
hostsProps,
|
||||
appearanceProps,
|
||||
generalProps,
|
||||
providersProps,
|
||||
diagnosticsProps,
|
||||
aboutProps,
|
||||
@@ -1895,6 +1880,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
audioRowSubtitle: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
providerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 || ".");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
72
packages/cli/tests/e2e/opencode-invalid-model.test.ts
Normal file
72
packages/cli/tests/e2e/opencode-invalid-model.test.ts
Normal 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();
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.51-rc.1",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -415,10 +415,39 @@ describe("transformPiModels", () => {
|
||||
});
|
||||
|
||||
describe("ACPAgentSession slash commands", () => {
|
||||
test("caches ACP available commands for listCommands", async () => {
|
||||
test("returns immediately for ACP sessions that do not wait for async command discovery", async () => {
|
||||
const session = createSession();
|
||||
|
||||
expect(await session.listCommands()).toEqual([]);
|
||||
await expect(session.listCommands()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("waits for async available_commands_update when enabled", async () => {
|
||||
const session = new ACPAgentSession(
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
},
|
||||
{
|
||||
provider: "pi",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["pi-acp"],
|
||||
defaultModes: [],
|
||||
modelTransformer: transformPiModels,
|
||||
sessionResponseTransformer: transformPiSessionResponse,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: 1500,
|
||||
},
|
||||
);
|
||||
|
||||
const listCommandsPromise = session.listCommands();
|
||||
|
||||
(session as any).translateSessionUpdate({
|
||||
sessionUpdate: "available_commands_update",
|
||||
@@ -434,6 +463,19 @@ describe("ACPAgentSession slash commands", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(await listCommandsPromise).toEqual([
|
||||
{
|
||||
name: "research_codebase",
|
||||
description: "Search the workspace for relevant files",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "create_plan",
|
||||
description: "Draft a plan for the requested work",
|
||||
argumentHint: "",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await session.listCommands()).toEqual([
|
||||
{
|
||||
name: "research_codebase",
|
||||
|
||||
@@ -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,
|
||||
@@ -125,6 +125,8 @@ type ACPAgentClientOptions = {
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
};
|
||||
|
||||
type ACPAgentSessionOptions = {
|
||||
@@ -144,6 +146,8 @@ type ACPAgentSessionOptions = {
|
||||
capabilities: AgentCapabilityFlags;
|
||||
handle?: AgentPersistenceHandle;
|
||||
launchEnv?: Record<string, string>;
|
||||
waitForInitialCommands?: boolean;
|
||||
initialCommandsWaitTimeoutMs?: number;
|
||||
};
|
||||
|
||||
type SpawnedACPProcess = {
|
||||
@@ -302,6 +306,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
sessionId: string,
|
||||
thinkingOptionId: string,
|
||||
) => Promise<void>;
|
||||
private readonly waitForInitialCommands: boolean;
|
||||
private readonly initialCommandsWaitTimeoutMs: number;
|
||||
|
||||
constructor(options: ACPAgentClientOptions) {
|
||||
this.provider = options.provider;
|
||||
@@ -314,6 +320,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
|
||||
this.thinkingOptionWriter = options.thinkingOptionWriter;
|
||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -335,6 +343,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
thinkingOptionWriter: this.thinkingOptionWriter,
|
||||
capabilities: this.capabilities,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
},
|
||||
);
|
||||
await session.initializeNewSession();
|
||||
@@ -375,6 +385,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
capabilities: this.capabilities,
|
||||
handle,
|
||||
launchEnv: launchContext?.env,
|
||||
waitForInitialCommands: this.waitForInitialCommands,
|
||||
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
|
||||
});
|
||||
await session.initializeResumedSession();
|
||||
return session;
|
||||
@@ -466,7 +478,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
this.resolveLaunchCommand();
|
||||
await this.resolveLaunchCommand();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -476,16 +488,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 +563,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`);
|
||||
}
|
||||
@@ -618,6 +629,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private lastActivityAt: string | null = null;
|
||||
private configOptions: SessionConfigOption[] = [];
|
||||
private cachedCommands: AgentSlashCommand[] = [];
|
||||
private commandsReadyDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
|
||||
private commandsReadySettled = false;
|
||||
private waitForInitialCommands: boolean;
|
||||
private initialCommandsWaitTimeoutMs: number;
|
||||
private currentTurnUsage: AgentUsage | undefined;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private closed = false;
|
||||
@@ -646,6 +661,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.currentModel = config.model ?? null;
|
||||
this.thinkingOptionId = config.thinkingOptionId ?? null;
|
||||
this.currentTitle = config.title ?? null;
|
||||
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
|
||||
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
@@ -877,7 +894,59 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
private ensureCommandsReadyDeferred(): void {
|
||||
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
this.commandsReadyDeferred = { promise, resolve };
|
||||
}
|
||||
|
||||
private settleCommandsReady(): void {
|
||||
if (this.commandsReadySettled) {
|
||||
return;
|
||||
}
|
||||
this.commandsReadySettled = true;
|
||||
this.commandsReadyDeferred?.resolve();
|
||||
this.commandsReadyDeferred = null;
|
||||
}
|
||||
|
||||
private async waitForCommandsReady(): Promise<void> {
|
||||
const deferred = this.commandsReadyDeferred;
|
||||
if (!deferred) {
|
||||
return;
|
||||
}
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
deferred.promise,
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
if (this.cachedCommands.length > 0) {
|
||||
return this.cachedCommands;
|
||||
}
|
||||
if (!this.waitForInitialCommands || this.closed) {
|
||||
return this.cachedCommands;
|
||||
}
|
||||
|
||||
this.ensureCommandsReadyDeferred();
|
||||
await this.waitForCommandsReady();
|
||||
this.settleCommandsReady();
|
||||
return this.cachedCommands;
|
||||
}
|
||||
|
||||
@@ -1047,6 +1116,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
this.closed = true;
|
||||
|
||||
this.settleCommandsReady();
|
||||
|
||||
for (const pending of this.pendingPermissions.values()) {
|
||||
pending.resolve({ outcome: { outcome: "cancelled" } });
|
||||
}
|
||||
@@ -1172,13 +1243,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 +1271,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 +1315,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 +1325,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) => {
|
||||
@@ -1395,6 +1464,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
description: command.description,
|
||||
argumentHint: "",
|
||||
}));
|
||||
this.settleCommandsReady();
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
]),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { isCommandAvailableSync } from "../../../utils/executable.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||
|
||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
return (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Real e2e tests for OpenCode error handling.
|
||||
*
|
||||
* Validates that OpenCode surfaces errors properly instead of hanging forever
|
||||
* when models are invalid, auth fails, or provider API calls fail.
|
||||
*/
|
||||
describe("opencode agent error handling (real)", () => {
|
||||
test.runIf(isCommandAvailableSync("opencode"))(
|
||||
"surfaces error for the exact opencode/<bad-model> path used by paseo run",
|
||||
async () => {
|
||||
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: process.cwd(),
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
try {
|
||||
await session.setModel("opencode/adklasldkdas");
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamSession(session, "hello")) {
|
||||
events.push(event);
|
||||
if (isTerminalEvent(event)) break;
|
||||
}
|
||||
|
||||
const terminal = events.find(isTerminalEvent);
|
||||
expect(terminal).toBeDefined();
|
||||
expect(terminal!.type).toBe("turn_failed");
|
||||
} finally {
|
||||
await session.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
45_000,
|
||||
);
|
||||
|
||||
test.runIf(isCommandAvailableSync("opencode"))(
|
||||
"surfaces error for unknown provider model (fast path)",
|
||||
async () => {
|
||||
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: process.cwd(),
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
try {
|
||||
await session.setModel("bogus-provider/totally-fake-model-12345");
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamSession(session, "Say hello")) {
|
||||
events.push(event);
|
||||
if (isTerminalEvent(event)) break;
|
||||
}
|
||||
|
||||
const terminal = events.find(isTerminalEvent);
|
||||
expect(terminal).toBeDefined();
|
||||
expect(terminal!.type).toBe("turn_failed");
|
||||
} finally {
|
||||
await session.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
test.runIf(isCommandAvailableSync("opencode"))(
|
||||
"sequential sessions: second session works after first errors",
|
||||
async () => {
|
||||
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
|
||||
|
||||
// Session 1: bogus model, will error quickly
|
||||
const s1 = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: process.cwd(),
|
||||
modeId: "build",
|
||||
});
|
||||
await s1.setModel("bogus-provider/fake-model-12345");
|
||||
for await (const event of streamSession(s1, "Say hello")) {
|
||||
if (isTerminalEvent(event)) break;
|
||||
}
|
||||
await s1.close();
|
||||
|
||||
// Session 2: different bogus model, should also work
|
||||
const s2 = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: process.cwd(),
|
||||
modeId: "build",
|
||||
});
|
||||
await s2.setModel("bogus-provider/fake-model-67890");
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamSession(s2, "Say hello")) {
|
||||
events.push(event);
|
||||
if (isTerminalEvent(event)) break;
|
||||
}
|
||||
await s2.close().catch(() => undefined);
|
||||
|
||||
const terminal = events.find(isTerminalEvent);
|
||||
expect(terminal).toBeDefined();
|
||||
expect(terminal!.type).toBe("turn_failed");
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
test.runIf(isCommandAvailableSync("opencode"))(
|
||||
"surfaces error for known provider with nonexistent model (retry path)",
|
||||
async () => {
|
||||
// When the provider is recognized (anthropic) but the model doesn't exist,
|
||||
// OpenCode retries before surfacing the error. This must not hang.
|
||||
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: process.cwd(),
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
try {
|
||||
await session.setModel("anthropic/claude-nonexistent-99");
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const start = Date.now();
|
||||
for await (const event of streamSession(session, "Say hello")) {
|
||||
events.push(event);
|
||||
if (isTerminalEvent(event)) break;
|
||||
}
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
const terminal = events.find(isTerminalEvent);
|
||||
expect(terminal).toBeDefined();
|
||||
expect(elapsed).toBeLessThan(30_000);
|
||||
console.log(
|
||||
`[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`,
|
||||
);
|
||||
} finally {
|
||||
await session.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
45_000,
|
||||
);
|
||||
|
||||
test.runIf(isCommandAvailableSync("opencode"))(
|
||||
"surfaces fatal retry status from zai/glm-5.1 instead of hanging forever",
|
||||
async () => {
|
||||
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: process.cwd(),
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
try {
|
||||
await session.setModel("zai/glm-5.1");
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamSession(session, "Say hello")) {
|
||||
events.push(event);
|
||||
if (isTerminalEvent(event)) break;
|
||||
}
|
||||
|
||||
const terminal = events.find(isTerminalEvent);
|
||||
expect(terminal).toBeDefined();
|
||||
expect(terminal!.type).toBe("turn_failed");
|
||||
expect(
|
||||
(terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase(),
|
||||
).toMatch(/insufficient balance|resource package|recharge/);
|
||||
} finally {
|
||||
await session.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
45_000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@opencode-ai/sdk/v2/client", () => ({
|
||||
createOpencodeClient: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js";
|
||||
|
||||
describe("OpenCodeAgentClient.listModels timeout", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const providerList = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
data: {
|
||||
connected: ["zai"],
|
||||
all: [
|
||||
{
|
||||
id: "zai",
|
||||
name: "Z.AI",
|
||||
models: {
|
||||
"glm-5.1": {
|
||||
name: "GLM 5.1",
|
||||
limit: { context: 128_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}, 15_000);
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||
provider: {
|
||||
list: providerList,
|
||||
},
|
||||
} as never);
|
||||
|
||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||
ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }),
|
||||
} as never);
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
const modelsPromise = client.listModels();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
await expect(modelsPromise).resolves.toMatchObject([
|
||||
{
|
||||
provider: "opencode",
|
||||
id: "zai/glm-5.1",
|
||||
label: "GLM 5.1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
import { 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) {
|
||||
@@ -314,6 +315,23 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
||||
});
|
||||
|
||||
describe("OpenCode adapter context-window normalization", () => {
|
||||
test("builds OpenCode file parts for image prompt blocks", () => {
|
||||
expect(
|
||||
__openCodeInternals.buildOpenCodePromptParts([
|
||||
{ type: "text", text: "Describe this image." },
|
||||
{ type: "image", mimeType: "image/png", data: "YWJjMTIz" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ type: "text", text: "Describe this image." },
|
||||
{
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "attachment-1.png",
|
||||
url: "data:image/png;base64,YWJjMTIz",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("preserves provider catalog context limit in model metadata", () => {
|
||||
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
|
||||
{ id: "openai", name: "OpenAI" },
|
||||
@@ -442,7 +460,7 @@ describe("OpenCode adapter context-window normalization", () => {
|
||||
modelID: "gpt-5",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenCodeEvent,
|
||||
{
|
||||
sessionId: "session-1",
|
||||
messageRoles: new Map(),
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
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 FilePartInput as OpenCodeFilePartInput,
|
||||
type OpencodeClient,
|
||||
type Part as OpenCodePart,
|
||||
type TextPartInput as OpenCodeTextPartInput,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
import net from "node:net";
|
||||
import type { Logger } from "pino";
|
||||
@@ -12,7 +17,6 @@ import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentPermissionRequest,
|
||||
@@ -40,11 +44,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 +95,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 +188,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 +232,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 +261,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 +427,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 +469,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;
|
||||
@@ -477,7 +496,66 @@ function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
|
||||
].some((value) => typeof value === "number" && Number.isFinite(value));
|
||||
}
|
||||
|
||||
function getOpenCodeAttachmentExtension(mimeType: string): string {
|
||||
switch (mimeType) {
|
||||
case "image/png":
|
||||
return "png";
|
||||
case "image/jpeg":
|
||||
return "jpg";
|
||||
case "image/webp":
|
||||
return "webp";
|
||||
case "image/gif":
|
||||
return "gif";
|
||||
case "image/svg+xml":
|
||||
return "svg";
|
||||
default:
|
||||
return "bin";
|
||||
}
|
||||
}
|
||||
|
||||
function toOpenCodeDataUrl(mimeType: string, data: string): { mimeType: string; url: string } {
|
||||
const match = data.match(/^data:([^;,]+);base64,(.+)$/);
|
||||
if (match) {
|
||||
return {
|
||||
mimeType: match[1] ?? mimeType,
|
||||
url: data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
mimeType,
|
||||
url: `data:${mimeType};base64,${data}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenCodePromptParts(
|
||||
prompt: AgentPromptInput,
|
||||
): Array<OpenCodeTextPartInput | OpenCodeFilePartInput> {
|
||||
if (typeof prompt === "string") {
|
||||
return [{ type: "text", text: prompt }];
|
||||
}
|
||||
let attachmentOrdinal = 0;
|
||||
const output: Array<OpenCodeTextPartInput | OpenCodeFilePartInput> = [];
|
||||
for (const part of prompt) {
|
||||
if (part.type === "text") {
|
||||
output.push({ type: "text", text: part.text });
|
||||
continue;
|
||||
}
|
||||
attachmentOrdinal += 1;
|
||||
const normalized = toOpenCodeDataUrl(part.mimeType, part.data);
|
||||
output.push({
|
||||
type: "file",
|
||||
mime: normalized.mimeType,
|
||||
filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension(
|
||||
normalized.mimeType,
|
||||
)}`,
|
||||
url: normalized.url,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export const __openCodeInternals = {
|
||||
buildOpenCodePromptParts,
|
||||
buildOpenCodeModelContextWindowLookup,
|
||||
buildOpenCodeModelDefinition,
|
||||
buildOpenCodeModelLookupKey,
|
||||
@@ -485,7 +563,7 @@ export const __openCodeInternals = {
|
||||
hasNormalizedOpenCodeUsage,
|
||||
mergeOpenCodeStepFinishUsage,
|
||||
parseOpenCodeModelLookupKey,
|
||||
resolveOpenCodeModelLookupKeyFromAssistantMessageInfo,
|
||||
resolveOpenCodeModelLookupKeyFromAssistantMessage,
|
||||
resolveOpenCodeSelectedModelContextWindow,
|
||||
};
|
||||
|
||||
@@ -564,19 +642,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 +808,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 +923,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 +965,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 +1038,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 +1060,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 +1094,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 +1142,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 +1244,7 @@ export function translateOpenCodeEvent(
|
||||
type: "permission_requested",
|
||||
provider: "opencode",
|
||||
request: {
|
||||
id: requestId,
|
||||
id: event.properties.id,
|
||||
provider: "opencode",
|
||||
name: "question",
|
||||
kind: "question",
|
||||
@@ -1272,7 +1252,7 @@ export function translateOpenCodeEvent(
|
||||
input: { questions },
|
||||
metadata: {
|
||||
source: "opencode_question",
|
||||
...(readOpenCodeRecord(props.tool) ?? {}),
|
||||
...(event.properties.tool ?? {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1280,8 +1260,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 +1273,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 +1344,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,
|
||||
@@ -1494,7 +1497,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.accumulatedUsage =
|
||||
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
||||
|
||||
const parts = this.buildPromptParts(prompt);
|
||||
const parts = buildOpenCodePromptParts(prompt);
|
||||
const model = this.parseModel(this.config.model);
|
||||
const thinkingOptionId = this.config.thinkingOptionId;
|
||||
const effectiveVariant = thinkingOptionId ?? undefined;
|
||||
@@ -1506,8 +1509,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 +1530,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 +1559,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 +1596,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 +1633,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 +1685,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 +1743,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 +1757,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",
|
||||
@@ -1894,15 +1923,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.activeForegroundTurnId = null;
|
||||
}
|
||||
|
||||
private buildPromptParts(prompt: AgentPromptInput): Array<{ type: "text"; text: string }> {
|
||||
if (typeof prompt === "string") {
|
||||
return [{ type: "text", text: prompt }];
|
||||
}
|
||||
return prompt
|
||||
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
||||
.map((p) => ({ type: "text", text: p.text }));
|
||||
}
|
||||
|
||||
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith("/") || trimmed.length <= 1) {
|
||||
@@ -2020,7 +2040,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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -298,6 +298,8 @@ export class PiACPAgentClient extends ACPAgentClient {
|
||||
await connection.setSessionMode({ sessionId, modeId: thinkingOptionId });
|
||||
},
|
||||
capabilities: PI_CAPABILITIES,
|
||||
waitForInitialCommands: true,
|
||||
initialCommandsWaitTimeoutMs: 1500,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -326,7 +328,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 +342,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";
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { isProviderAvailable } from "./agent-configs.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-init-prompt-"));
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<{
|
||||
client: DaemonClient;
|
||||
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
|
||||
}> {
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { opencode: new OpenCodeAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-init-prompt" } });
|
||||
return { client, daemon };
|
||||
}
|
||||
|
||||
describe("daemon E2E (real opencode) - initial prompt wait", () => {
|
||||
test.runIf(isProviderAvailable("opencode"))(
|
||||
"waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const { client, daemon } = await createHarness();
|
||||
|
||||
try {
|
||||
const models = await client.listProviderModels("opencode");
|
||||
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode initial prompt wait regression",
|
||||
model: "opencode/big-pickle",
|
||||
initialPrompt: "Reply with exactly: BIG_PICKLE_OK",
|
||||
});
|
||||
|
||||
const finish = await client.waitForFinish(agent.id, 60_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.lastMessage).toContain("BIG_PICKLE_OK");
|
||||
|
||||
const snapshot = await client.fetchAgent(agent.id);
|
||||
expect(snapshot.agent?.status).toBe("idle");
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
const assistantMessages = timeline.entries.filter(
|
||||
(entry) => entry.item.type === "assistant_message",
|
||||
);
|
||||
|
||||
expect(assistantMessages.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
90_000,
|
||||
);
|
||||
|
||||
test.runIf(isProviderAvailable("opencode"))(
|
||||
"waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const { client, daemon } = await createHarness();
|
||||
|
||||
try {
|
||||
const models = await client.listProviderModels("opencode");
|
||||
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode zai fatal retry regression",
|
||||
model: "zai/glm-5.1",
|
||||
initialPrompt: "Reply with exactly: GLM_51_OK",
|
||||
});
|
||||
|
||||
const finish = await client.waitForFinish(agent.id, 60_000);
|
||||
expect(finish.status).toBe("error");
|
||||
expect((finish.error ?? "").toLowerCase()).toMatch(
|
||||
/insufficient balance|resource package|recharge/,
|
||||
);
|
||||
|
||||
const snapshot = await client.fetchAgent(agent.id);
|
||||
expect(snapshot.agent?.status).toBe("error");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
90_000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { isProviderAvailable } from "./agent-configs.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-invalid-model-"));
|
||||
}
|
||||
|
||||
async function createHarness(): Promise<{
|
||||
client: DaemonClient;
|
||||
daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>>;
|
||||
}> {
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { opencode: new OpenCodeAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-model" } });
|
||||
return { client, daemon };
|
||||
}
|
||||
|
||||
describe("daemon E2E (real opencode) - invalid model handling", () => {
|
||||
test.runIf(isProviderAvailable("opencode"))(
|
||||
"initial prompt with a nonexistent OpenCode model fails instead of hanging forever",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const { client, daemon } = await createHarness();
|
||||
|
||||
try {
|
||||
const agent = await client.createAgent({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
title: "OpenCode invalid model regression",
|
||||
model: "opencode/adklasldkdas",
|
||||
initialPrompt: "hello",
|
||||
});
|
||||
|
||||
const finish = await client.waitForFinish(agent.id, 30_000);
|
||||
expect(finish.status).toBe("error");
|
||||
expect(finish.error).toBeTruthy();
|
||||
|
||||
const snapshot = await client.fetchAgent(agent.id);
|
||||
expect(snapshot.agent?.status).toBe("error");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
@@ -2,10 +2,10 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
3
packages/server/src/shared/literal-union.ts
Normal file
3
packages/server/src/shared/literal-union.ts
Normal 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>);
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
42
packages/server/src/utils/spawn.ts
Normal file
42
packages/server/src/utils/spawn.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
spawn,
|
||||
type ChildProcess,
|
||||
type SpawnOptions,
|
||||
} from "node:child_process";
|
||||
|
||||
/**
|
||||
* Platform-aware spawn that centralizes Windows shell and quoting concerns.
|
||||
*
|
||||
* On Windows:
|
||||
* - Enables `shell: true` (routes through cmd.exe) unless the caller explicitly sets `shell`
|
||||
* - Quotes the command and arguments so paths with spaces survive cmd.exe parsing
|
||||
* - Always sets `windowsHide: true` to prevent console window flashes
|
||||
*
|
||||
* On other platforms the call is passed through to `spawn` unchanged (with `windowsHide: true`).
|
||||
*/
|
||||
export function spawnProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: SpawnOptions,
|
||||
): ChildProcess {
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
const resolvedCommand = isWindows ? quoteForCmd(command) : command;
|
||||
const resolvedArgs = isWindows ? args.map(quoteForCmd) : args;
|
||||
|
||||
return spawn(resolvedCommand, resolvedArgs, {
|
||||
...options,
|
||||
shell: options?.shell ?? isWindows,
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a string for cmd.exe if it contains spaces and isn't already quoted.
|
||||
* No-op for strings without spaces or strings that are already double-quoted.
|
||||
*/
|
||||
function quoteForCmd(value: string): string {
|
||||
if (!value.includes(" ")) return value;
|
||||
if (value.startsWith('"') && value.endsWith('"')) return value;
|
||||
return `"${value}"`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.51-rc.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user