Compare commits

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

Platform jobs now depend on create-release and just upload assets to
the existing release. Single-platform retry tags (desktop-macos-v*,
desktop-linux-v*, desktop-windows-v*) skip the create-release job
since the release already exists from the original build.
2026-04-08 19:37:30 +07:00
github-actions[bot] 6c9a832906 fix: update lockfile signatures and Nix hash 2026-04-08 12:20:15 +00:00
52 changed files with 1657 additions and 916 deletions
+40 -3
View File
@@ -35,8 +35,43 @@ env:
DESKTOP_PACKAGE_PATH: 'packages/desktop'
jobs:
create-release:
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all') }}
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
- name: Resolve release metadata
shell: bash
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
- name: Create GitHub release
if: env.IS_SMOKE_TAG != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
echo "Release $RELEASE_TAG already exists, skipping creation"
else
prerelease_flag=""
if [[ "$IS_PRERELEASE" == "true" ]]; then
prerelease_flag="--prerelease"
fi
gh release create "$RELEASE_TAG" \
--repo "${{ github.repository }}" \
--title "Paseo $RELEASE_TAG" \
--generate-notes \
$prerelease_flag
fi
publish-macos:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v'))) }}
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
strategy:
fail-fast: false
matrix:
@@ -219,7 +254,8 @@ jobs:
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
publish-linux:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v'))) }}
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
permissions:
contents: write
packages: read
@@ -285,7 +321,8 @@ jobs:
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
publish-windows:
if: ${{ (github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v'))) }}
needs: [create-release]
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }}
permissions:
contents: write
packages: read
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-QFe3aBiYlt6rhYmMmBzwjph/gDHe90kZYrlin3qIpRo=";
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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -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]}
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Image, Text, View } from "react-native";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import * as QRCode from "qrcode";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
@@ -12,7 +11,6 @@ import {
RotateCw,
Copy,
FileText,
Smartphone,
Activity,
} from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
@@ -24,7 +22,6 @@ import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
getDesktopDaemonLogs,
getDesktopDaemonPairing,
getDesktopDaemonStatus,
restartDesktopDaemon,
shouldUseDesktopDaemon,
@@ -32,7 +29,6 @@ import {
stopDesktopDaemon,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
type DesktopPairingOffer,
} from "@/desktop/daemon/desktop-daemon";
export interface LocalDaemonSectionProps {
@@ -52,10 +48,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [isPairingModalOpen, setIsPairingModalOpen] = useState(false);
const [isLoadingPairing, setIsLoadingPairing] = useState(false);
const [pairingOffer, setPairingOffer] = useState<DesktopPairingOffer | null>(null);
const [pairingStatusMessage, setPairingStatusMessage] = useState<string | null>(null);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
@@ -238,46 +230,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
setIsLogsModalOpen(true);
}, [daemonLogs]);
const handleOpenPairingModal = useCallback(() => {
if (isLoadingPairing) {
return;
}
setIsPairingModalOpen(true);
setIsLoadingPairing(true);
setPairingStatusMessage(null);
void getDesktopDaemonPairing()
.then((pairing) => {
setPairingOffer(pairing);
if (!pairing.relayEnabled || !pairing.url) {
setPairingStatusMessage("Relay pairing is not available.");
}
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setPairingOffer(null);
setPairingStatusMessage(`Unable to load pairing offer: ${message}`);
})
.finally(() => {
setIsLoadingPairing(false);
});
}, [isLoadingPairing]);
const handleCopyPairingLink = useCallback(() => {
if (!pairingOffer?.url) {
return;
}
void Clipboard.setStringAsync(pairingOffer.url)
.then(() => {
Alert.alert("Copied", "Pairing link copied.");
})
.catch((error) => {
console.error("[Settings] Failed to copy pairing link", error);
Alert.alert("Error", "Unable to copy pairing link.");
});
}, [pairingOffer?.url]);
const handleOpenCliStatus = useCallback(async () => {
setIsLoadingCliStatus(true);
try {
@@ -418,20 +370,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</Button>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Pair device</Text>
<Text style={settingsStyles.rowHint}>Connect your phone to this computer.</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Smartphone size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenPairingModal}
>
Pair device
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
@@ -460,20 +398,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
</View>
) : null}
<AdaptiveModalSheet
visible={isPairingModalOpen}
onClose={() => setIsPairingModalOpen(false)}
title="Pair device"
testID="managed-daemon-pairing-dialog"
>
<PairingOfferDialogContent
isLoading={isLoadingPairing}
pairingOffer={pairingOffer}
statusMessage={pairingStatusMessage}
onCopyLink={handleCopyPairingLink}
/>
</AdaptiveModalSheet>
<AdaptiveModalSheet
visible={isLogsModalOpen}
onClose={() => setIsLogsModalOpen(false)}
@@ -516,107 +440,6 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const ADVANCED_DAEMON_SETTINGS_URL = "https://paseo.sh/docs/configuration";
function PairingOfferDialogContent(input: {
isLoading: boolean;
pairingOffer: DesktopPairingOffer | null;
statusMessage: string | null;
onCopyLink: () => void;
}) {
const { isLoading, pairingOffer, statusMessage, onCopyLink } = input;
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [qrError, setQrError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!pairingOffer?.url) {
setQrDataUrl(null);
setQrError(null);
return () => {
cancelled = true;
};
}
setQrError(null);
setQrDataUrl(null);
void QRCode.toDataURL(pairingOffer.url, {
errorCorrectionLevel: "M",
margin: 1,
width: 480,
})
.then((dataUrl) => {
if (cancelled) {
return;
}
setQrDataUrl(dataUrl);
})
.catch((error) => {
if (cancelled) {
return;
}
setQrError(error instanceof Error ? error.message : String(error));
});
return () => {
cancelled = true;
};
}, [pairingOffer?.url]);
if (isLoading) {
return (
<View style={styles.pairingState}>
<ActivityIndicator size="small" />
<Text style={settingsStyles.rowHint}>Loading pairing offer…</Text>
</View>
);
}
if (statusMessage) {
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>{statusMessage}</Text>
</View>
);
}
if (!pairingOffer?.url) {
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>Pairing offer unavailable.</Text>
</View>
);
}
return (
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>
Scan this QR code in Paseo, or copy the pairing link below.
</Text>
<View style={styles.qrCard}>
{qrDataUrl ? (
<Image source={{ uri: qrDataUrl }} style={styles.qrImage} resizeMode="contain" />
) : qrError ? (
<Text style={settingsStyles.rowHint}>QR unavailable: {qrError}</Text>
) : (
<ActivityIndicator size="small" />
)}
</View>
<View style={styles.linkSection}>
<Text style={styles.linkLabel}>Pairing link</Text>
<Text style={styles.linkText} selectable>
{pairingOffer.url}
</Text>
</View>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={onCopyLink}>
Copy link
</Button>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
actionGroup: {
flexDirection: "row",
@@ -659,40 +482,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
pairingState: {
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[6],
},
qrCard: {
alignItems: "center",
justifyContent: "center",
width: "100%",
aspectRatio: 1,
alignSelf: "stretch",
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
qrImage: {
width: "100%",
height: "100%",
},
linkSection: {
gap: theme.spacing[2],
},
linkLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
linkText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 18,
},
logOutput: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
@@ -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,
}));
@@ -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();
});
+7 -1
View File
@@ -22,6 +22,7 @@ import {
Shield,
Puzzle,
Blocks,
Smartphone,
} from "lucide-react-native";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import type { HostProfile, HostConnection } from "@/types/host-connection";
@@ -54,6 +55,7 @@ import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-mod
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
import { isElectronRuntime } from "@/desktop/host";
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -82,7 +84,8 @@ type SettingsSectionId =
| "diagnostics"
| "about"
| "permissions"
| "daemon";
| "daemon"
| "pair-device";
interface SettingsSectionDef {
id: SettingsSectionId;
@@ -101,6 +104,7 @@ function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectio
if (context.isDesktopApp) {
sections.push(
{ id: "integrations", label: "Integrations", icon: Puzzle },
{ id: "pair-device", label: "Pair device", icon: Smartphone },
{ id: "daemon", label: "Daemon", icon: Settings },
);
}
@@ -647,6 +651,8 @@ function SettingsSectionContent({
return isDesktopApp ? <IntegrationsSection /> : null;
case "permissions":
return isDesktopApp ? <DesktopPermissionsSection /> : null;
case "pair-device":
return isDesktopApp ? <PairDeviceSection /> : null;
case "daemon":
return isDesktopApp ? (
<LocalDaemonSection appVersion={appVersion} showLifecycleControls={isLocalDaemon} />
+19 -15
View File
@@ -1,12 +1,14 @@
import type { Command } from "commander";
import { execFileSync } from "node:child_process";
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { promisify } from "node:util";
import {
getOrCreateServerId,
findExecutable,
quoteWindowsCommand,
applyProviderEnv,
} from "@getpaseo/server";
const execFileAsync = promisify(execFile);
import { tryConnectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
@@ -171,31 +173,33 @@ const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: "OpenCode", binary: "opencode" },
];
function checkProviderBinary(binary: string): { path: string | null; version: string | null } {
const binaryPath = findExecutable(binary);
async function checkProviderBinary(binary: string): Promise<{ path: string | null; version: string | null }> {
const binaryPath = await findExecutable(binary);
if (!binaryPath) {
return { path: null, version: null };
}
const env = applyProviderEnv(process.env);
try {
const output = execFileSync(quoteWindowsCommand(binaryPath), ["--version"], {
const { stdout } = await execFileAsync(binaryPath, ["--version"], {
encoding: "utf8",
timeout: 5000,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: process.platform === "win32",
}).trim();
return { path: binaryPath, version: output || null };
windowsHide: true,
});
return { path: binaryPath, version: stdout.trim() || null };
} catch {
return { path: binaryPath, version: null };
}
}
function checkProviderBinaries(): ProviderBinaryStatus[] {
return PROVIDER_BINARIES.map(({ label, binary }) => {
const result = checkProviderBinary(binary);
return { label, ...result };
});
async function checkProviderBinaries(): Promise<ProviderBinaryStatus[]> {
const results = await Promise.all(
PROVIDER_BINARIES.map(async ({ label, binary }) => {
const result = await checkProviderBinary(binary);
return { label, ...result };
}),
);
return results;
}
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
@@ -295,7 +299,7 @@ export async function runStatusCommand(
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`);
}
const providers = checkProviderBinaries();
const providers = await checkProviderBinaries();
const daemonStatus: DaemonStatus = {
serverId,
@@ -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();
+7 -11
View File
@@ -1,9 +1,9 @@
import { spawn, type ChildProcess } from "node:child_process";
import { type ChildProcess } from "node:child_process";
import { readFileSync } from "node:fs";
import 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,
+6 -4
View File
@@ -1,5 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { spawn, spawnSync } from "node:child_process";
import { spawnSync } from "node:child_process";
import { spawnProcess } from "@getpaseo/server";
import { createRequire } from "node:module";
import path from "node:path";
import { app } from "electron";
@@ -234,6 +235,7 @@ export function runCliPassthroughCommand(args: string[]): number {
const result = spawnSync(invocation.command, invocation.args, {
env: invocation.env,
stdio: "inherit",
windowsHide: true,
});
if (result.error) {
throw result.error;
@@ -252,7 +254,7 @@ function spawnAsync(
options: { env: NodeJS.ProcessEnv },
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
const child = spawnProcess(command, args, {
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -260,10 +262,10 @@ function spawnAsync(
let stdout = "";
let stderr = "";
child.stdout.on("data", (data: Buffer) => {
child.stdout!.on("data", (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on("data", (data: Buffer) => {
child.stderr!.on("data", (data: Buffer) => {
stderr += data.toString();
});
@@ -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);
@@ -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
@@ -441,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;
@@ -455,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));
@@ -1,5 +1,4 @@
import {
spawn,
type ChildProcess,
type ChildProcessWithoutNullStreams,
} from "node:child_process";
@@ -89,6 +88,7 @@ import {
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -466,7 +466,7 @@ export class ACPAgentClient implements AgentClient {
async isAvailable(): Promise<boolean> {
try {
this.resolveLaunchCommand();
await this.resolveLaunchCommand();
return true;
} catch {
return false;
@@ -476,16 +476,15 @@ export class ACPAgentClient implements AgentClient {
protected async spawnProcess(
launchEnv?: Record<string, string>,
): Promise<SpawnedACPProcess> {
const { command, args } = this.resolveLaunchCommand();
const child = spawn(command, args, {
const { command, args } = await this.resolveLaunchCommand();
const child = spawnProcess(command, args, {
cwd: process.cwd(),
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...(launchEnv ?? {}),
},
shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"],
});
}) as ChildProcessWithoutNullStreams;
const stderrChunks: string[] = [];
child.stderr.on("data", (chunk: Buffer | string) => {
@@ -552,9 +551,9 @@ export class ACPAgentClient implements AgentClient {
}
}
protected resolveLaunchCommand(): { command: string; args: string[] } {
const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => {
const resolved = findExecutable(this.defaultCommand[0]);
protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> {
const resolved = await findExecutable(this.defaultCommand[0]);
const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => {
if (!resolved) {
throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`);
}
@@ -1172,13 +1171,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
async createTerminal(params: CreateTerminalRequest): Promise<{ terminalId: string }> {
const terminalId = randomUUID();
const env = Object.fromEntries((params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]));
const child = spawn(params.command, params.args ?? [], {
const child = spawnProcess(params.command, params.args ?? [], {
cwd: params.cwd ?? this.config.cwd,
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...env,
},
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
});
@@ -1201,8 +1199,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
rejectExit,
};
child.stdout.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
child.stderr.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
child.stdout!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
child.stderr!.on("data", (chunk: Buffer | string) => appendTerminalOutput(entry, chunk.toString()));
child.once("error", (error) => rejectExit(error instanceof Error ? error : new Error(String(error))));
child.once("exit", (code, signal) => {
const exit = { exitCode: code, signal };
@@ -1245,8 +1243,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private async spawnProcess(): Promise<SpawnedACPProcess> {
const prefix = resolveProviderCommandPrefix(this.runtimeSettings?.command, () => {
const resolved = findExecutable(this.defaultCommand[0]);
const resolved = await findExecutable(this.defaultCommand[0]);
const prefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, () => {
if (!resolved) {
throw new Error(`${this.provider} command '${this.defaultCommand[0]}' not found`);
}
@@ -1255,15 +1253,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const command = prefix.command;
const args = [...prefix.args, ...this.defaultCommand.slice(1)];
const child = spawn(command, args, {
const child = spawnProcess(command, args, {
cwd: this.config.cwd,
env: {
...applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
...(this.launchEnv ?? {}),
},
shell: process.platform === "win32",
stdio: ["pipe", "pipe", "pipe"],
});
}) as ChildProcessWithoutNullStreams;
const stderrChunks: string[] = [];
child.stderr.on("data", (chunk: Buffer | string) => {
@@ -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",
@@ -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;
}
@@ -2017,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 });
@@ -2054,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
@@ -2073,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,
@@ -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({
@@ -623,17 +623,13 @@ describe("Codex app-server provider", () => {
});
});
test("approving a synthetic Codex plan permission disables plan and fast mode and starts implementation", async () => {
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" },
});
@@ -653,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",
});
@@ -664,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({
@@ -677,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;
}
@@ -3997,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(
@@ -4051,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 {
@@ -4121,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 {
@@ -4213,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) {
@@ -442,7 +443,7 @@ describe("OpenCode adapter context-window normalization", () => {
modelID: "gpt-5",
},
},
},
} as OpenCodeEvent,
{
sessionId: "session-1",
messageRoles: new Map(),
@@ -1,8 +1,11 @@
import { spawn, type ChildProcess } from "node:child_process";
import type { ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent,
type OpencodeClient,
type Part as OpenCodePart,
} from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import type { Logger } from "pino";
@@ -12,7 +15,6 @@ import type {
AgentCapabilityFlags,
AgentClient,
AgentLaunchContext,
AgentMetadata,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
@@ -40,11 +42,8 @@ import {
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import {
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "../../../utils/executable.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import {
formatDiagnosticStatus,
@@ -94,6 +93,19 @@ type OpenCodeMcpConfig =
};
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [
"insufficient balance",
"no resource package",
"please recharge",
"invalid api key",
"unauthorized",
"authentication",
"model not found",
"unknown model",
"does not exist",
"unsupported model",
] as const;
const OpencodeToolStateSchema = z
.object({
@@ -174,8 +186,8 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch
}),
);
function resolveOpenCodeBinary(): string {
const found = findExecutable("opencode");
async function resolveOpenCodeBinary(): Promise<string> {
const found = await findExecutable("opencode");
if (found) {
return found;
}
@@ -218,6 +230,14 @@ function normalizeTurnFailureError(error: unknown): string {
return normalized.length > 0 ? normalized : "Unknown error";
}
function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean {
const normalized = typeof message === "string" ? message.trim().toLowerCase() : "";
if (!normalized) {
return false;
}
return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token));
}
function isAlreadyPresentMcpError(error: unknown): boolean {
const normalized = stringifyUnknownError(error).toLowerCase();
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
@@ -239,14 +259,15 @@ async function findAvailablePort(): Promise<number> {
});
}
function resolvePartDedupeKey(part: AgentMetadata, partType: "text" | "reasoning"): string | null {
const partId = part.id;
if (typeof partId === "string" && partId.trim().length > 0) {
return `${partType}:${partId}`;
function resolvePartDedupeKey(
part: { id: string; messageID: string },
partType: "text" | "reasoning",
): string | null {
if (part.id.trim().length > 0) {
return `${partType}:${part.id}`;
}
const messageId = part.messageID;
if (typeof messageId === "string" && messageId.trim().length > 0) {
return `${partType}:message:${messageId}`;
if (part.messageID.trim().length > 0) {
return `${partType}:message:${part.messageID}`;
}
return null;
}
@@ -404,15 +425,11 @@ function buildOpenCodeModelContextWindowLookup(providers: {
return lookup;
}
function resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(
info: AgentMetadata | undefined,
function resolveOpenCodeModelLookupKeyFromAssistantMessage(
info: OpenCodeAssistantMessage,
): string | undefined {
if (!info || info.role !== "assistant") {
return undefined;
}
const providerId = readNonEmptyString(info.providerID);
const modelId = readNonEmptyString(info.modelID);
const providerId = info.providerID;
const modelId = info.modelID;
if (!providerId || !modelId) {
return undefined;
}
@@ -485,7 +502,7 @@ export const __openCodeInternals = {
hasNormalizedOpenCodeUsage,
mergeOpenCodeStepFinishUsage,
parseOpenCodeModelLookupKey,
resolveOpenCodeModelLookupKeyFromAssistantMessageInfo,
resolveOpenCodeModelLookupKeyFromAssistantMessage,
resolveOpenCodeSelectedModelContextWindow,
};
@@ -564,19 +581,16 @@ export class OpenCodeServerManager {
private async startServer(): Promise<{ port: number; url: string }> {
this.port = await findAvailablePort();
const url = `http://127.0.0.1:${this.port}`;
const launchPrefix = resolveProviderCommandPrefix(
const launchPrefix = await resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary,
);
return new Promise((resolve, reject) => {
this.server = spawn(
quoteWindowsCommand(launchPrefix.command),
[...launchPrefix.args, "serve", "--port", String(this.port)].map((argument) =>
quoteWindowsArgument(argument),
),
this.server = spawnProcess(
launchPrefix.command,
[...launchPrefix.args, "serve", "--port", String(this.port)],
{
shell: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
env: applyProviderEnv(process.env, this.runtimeSettings),
},
@@ -733,16 +747,17 @@ export class OpenCodeAgentClient implements AgentClient {
directory: options?.cwd ?? process.cwd(),
});
// Set a timeout for the API call to fail fast if OpenCode isn't responding
// Background model discovery can be legitimately slow while OpenCode refreshes
// provider state, so allow longer than turn execution paths.
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() =>
reject(
new Error(
"OpenCode provider.list timed out after 10s - server may not be authenticated or connected to any providers",
`OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`,
),
),
10_000,
OPENCODE_PROVIDER_LIST_TIMEOUT_MS,
);
});
@@ -847,7 +862,7 @@ export class OpenCodeAgentClient implements AgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const available = await this.isAvailable();
const resolvedBinary = findExecutable("opencode");
const resolvedBinary = await findExecutable("opencode");
let serverStatus = "Not running";
let modelsValue = "Not checked";
let status = formatDiagnosticStatus(available);
@@ -889,7 +904,7 @@ export class OpenCodeAgentClient implements AgentClient {
label: "Binary",
value: resolvedBinary ?? "not found",
},
{ label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" },
{ label: "Version", value: resolvedBinary ? await resolveBinaryVersion(resolvedBinary) : "unknown" },
{ label: "Server", value: serverStatus },
{ label: "Models", value: modelsValue },
{ label: "Status", value: status },
@@ -962,51 +977,18 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function normalizeQuestionOptions(
value: unknown,
): Array<{ label: string; description?: string }> | null {
if (!Array.isArray(value)) {
return null;
}
const options: Array<{ label: string; description?: string }> = [];
for (const item of value) {
if (typeof item === "string" && item.trim().length > 0) {
options.push({ label: item.trim() });
continue;
}
const record = readOpenCodeRecord(item);
const label = readNonEmptyString(record?.label);
if (!label) {
continue;
}
const description = readNonEmptyString(record?.description);
options.push(description ? { label, description } : { label });
}
return options;
}
export function translateOpenCodeEvent(
event: unknown,
event: OpenCodeEvent,
state: OpenCodeEventTranslationState,
): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
if (!event || typeof event !== "object") {
return events;
}
const e = event as { type?: string; properties?: AgentMetadata };
const type = e.type;
const props = e.properties ?? {};
switch (type) {
switch (event.type) {
case "session.created":
case "session.updated": {
const sessionId = props.id as string | undefined;
if (sessionId === state.sessionId) {
if (event.properties.info.id === state.sessionId) {
events.push({
type: "thread_started",
sessionId: state.sessionId,
@@ -1017,35 +999,28 @@ export function translateOpenCodeEvent(
}
case "message.updated": {
const info = props.info as AgentMetadata | undefined;
if (!info) {
const info = event.properties.info;
if (info.sessionID !== state.sessionId) {
break;
}
const messageId = info.id as string | undefined;
const messageSessionId = info.sessionID as string | undefined;
const role = info.role as OpenCodeMessageRole | undefined;
if (messageId && messageSessionId === state.sessionId && role) {
state.messageRoles.set(messageId, role);
if (role === "assistant") {
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessageInfo(info);
if (modelLookupKey) {
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
if (contextWindowMaxTokens !== undefined) {
state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
}
state.messageRoles.set(info.id, info.role);
if (info.role === "assistant") {
const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info);
if (modelLookupKey) {
const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey);
if (contextWindowMaxTokens !== undefined) {
state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens);
}
}
if (
role === "assistant" &&
!state.emittedStructuredMessageIds.has(messageId) &&
typeof info.time === "object" &&
info.time !== null &&
"completed" in info.time
!state.emittedStructuredMessageIds.has(info.id) &&
info.time?.completed !== undefined
) {
const text = stringifyStructuredAssistantMessage(info.structured);
if (text) {
state.emittedStructuredMessageIds.add(messageId);
state.emittedStructuredMessageIds.add(info.id);
events.push({
type: "timeline",
provider: "opencode",
@@ -1058,82 +1033,46 @@ export function translateOpenCodeEvent(
}
case "message.part.updated": {
const part = props.part as AgentMetadata | undefined;
const delta = props.delta as string | undefined;
if (!part) {
const part = event.properties.part;
if (part.sessionID !== state.sessionId) {
break;
}
const partSessionId = part.sessionID as string | undefined;
if (partSessionId !== state.sessionId) {
break;
}
const messageRole = state.messageRoles.get(part.messageID);
state.partTypes.set(part.id, part.type);
const partId = part.id as string | undefined;
const messageId = part.messageID as string | undefined;
const messageRole = messageId ? state.messageRoles.get(messageId) : undefined;
const partType = part.type as string | undefined;
const partTime = part.time as { start?: number; end?: number } | undefined;
if (partId && partType) {
state.partTypes.set(partId, partType);
}
if (partType === "text") {
if (part.type === "text") {
const partKey = resolvePartDedupeKey(part, "text");
if (messageRole === "user") {
break;
}
if (!messageRole && !delta) {
break;
}
if (delta) {
if (partKey) {
state.streamedPartKeys.add(partKey);
}
events.push({
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text: delta },
});
} else if (partTime?.end) {
if (part.time?.end) {
if (partKey && state.streamedPartKeys.delete(partKey)) {
break;
}
const text = part.text as string | undefined;
if (text) {
if (part.text) {
events.push({
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text },
item: { type: "assistant_message", text: part.text },
});
}
}
} else if (partType === "reasoning") {
} else if (part.type === "reasoning") {
const partKey = resolvePartDedupeKey(part, "reasoning");
if (delta) {
if (partKey) {
state.streamedPartKeys.add(partKey);
}
events.push({
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text: delta },
});
} else if (partTime?.end) {
if (part.time.end) {
if (partKey && state.streamedPartKeys.delete(partKey)) {
break;
}
const text = part.text as string | undefined;
if (text) {
if (part.text) {
events.push({
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text },
item: { type: "reasoning", text: part.text },
});
}
}
} else if (partType === "tool") {
} else if (part.type === "tool") {
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (parsedToolPart.success && parsedToolPart.data) {
events.push({
@@ -1142,7 +1081,7 @@ 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({
@@ -1156,111 +1095,84 @@ export function translateOpenCodeEvent(
}
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) {
@@ -1271,7 +1183,7 @@ export function translateOpenCodeEvent(
type: "permission_requested",
provider: "opencode",
request: {
id: requestId,
id: event.properties.id,
provider: "opencode",
name: "question",
kind: "question",
@@ -1279,7 +1191,7 @@ export function translateOpenCodeEvent(
input: { questions },
metadata: {
source: "opencode_question",
...(readOpenCodeRecord(props.tool) ?? {}),
...(event.properties.tool ?? {}),
},
},
});
@@ -1287,8 +1199,7 @@ export function translateOpenCodeEvent(
}
case "session.idle": {
const sessionId = props.sessionID as string | undefined;
if (sessionId === state.sessionId) {
if (event.properties.sessionID === state.sessionId) {
state.streamedPartKeys.clear();
state.partTypes.clear();
events.push({
@@ -1301,18 +1212,43 @@ export function translateOpenCodeEvent(
}
case "session.error": {
const sessionId = props.sessionID as string | undefined;
if (sessionId === state.sessionId) {
if (event.properties.sessionID === state.sessionId) {
state.streamedPartKeys.clear();
state.partTypes.clear();
events.push({
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(props.error),
error: normalizeTurnFailureError(event.properties.error),
});
}
break;
}
case "session.status": {
if (event.properties.sessionID !== state.sessionId) {
break;
}
const { status } = event.properties;
if (status.type === "idle") {
state.streamedPartKeys.clear();
state.partTypes.clear();
events.push({
type: "turn_completed",
provider: "opencode",
usage: undefined,
});
} else if (status.type === "retry" && isFatalOpenCodeRetryMessage(status.message)) {
state.streamedPartKeys.clear();
state.partTypes.clear();
events.push({
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(status.message),
});
}
// "retry" and "busy" are transient — no terminal event.
break;
}
}
return events;
@@ -1347,7 +1283,6 @@ class OpenCodeAgentSession implements AgentSession {
private activeForegroundTurnId: string | null = null;
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
private selectedModelContextWindowMaxTokens: number | undefined;
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
@@ -1513,8 +1448,12 @@ class OpenCodeAgentSession implements AgentSession {
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
if (slashCommand) {
// command() blocks until completion, but events stream via SSE in the
// background — fire without awaiting so the event stream isn't starved.
// command() blocks until the server finishes processing. OpenCode's SSE
// endpoint does NOT replay past events, so if the command completes before
// our SSE reader connects, we miss `session.idle` and the turn hangs.
// Handle both success and error in the response handler as a fallback —
// finishForegroundTurn's guard prevents duplicate terminal events if the
// SSE stream already delivered the event.
void this.client.session.command({
sessionID: this.sessionId,
directory: this.config.cwd,
@@ -1530,10 +1469,20 @@ class OpenCodeAgentSession implements AgentSession {
{ type: "turn_failed", provider: "opencode", error: errorMsg },
turnId,
);
} else {
this.finishForegroundTurn(
{ type: "turn_completed", provider: "opencode", usage: undefined },
turnId,
);
}
}).catch((err) => {
this.finishForegroundTurn(
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
turnId,
);
});
} else {
const promptResponse = await this.client.session.promptAsync({
void this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
parts,
@@ -1549,17 +1498,27 @@ class OpenCodeAgentSession implements AgentSession {
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
}).then((promptResponse) => {
if (promptResponse.error) {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(promptResponse.error),
},
turnId,
);
}
}).catch((error) => {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: normalizeTurnFailureError(error),
},
turnId,
);
});
if (promptResponse.error) {
const errorMsg = normalizeTurnFailureError(promptResponse.error);
this.notifySubscribers({
type: "turn_failed",
provider: "opencode",
error: errorMsg,
});
throw new Error(errorMsg);
}
}
return { turnId };
@@ -1576,12 +1535,13 @@ class OpenCodeAgentSession implements AgentSession {
turnId: string,
turnAbortController: AbortController,
): Promise<void> {
const eventsResult = await this.client.event.subscribe({
directory: this.config.cwd,
});
try {
for await (const event of eventsResult.stream) {
const result = await this.client.event.subscribe(
{ directory: this.config.cwd },
{ signal: turnAbortController.signal, sseMaxRetryAttempts: 0 },
);
for await (const event of result.stream) {
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
break;
}
@@ -1612,6 +1572,17 @@ class OpenCodeAgentSession implements AgentSession {
this.notifySubscribers(e, turnId);
}
}
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: "OpenCode event stream ended before the turn reached a terminal state",
},
turnId,
);
}
} catch (error) {
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.finishForegroundTurn(
@@ -1653,6 +1624,9 @@ class OpenCodeAgentSession implements AgentSession {
this.runningToolCalls.clear();
}
this.activeForegroundTurnId = null;
// Abort the SSE connection so the SDK tears down the underlying fetch.
this.abortController?.abort();
this.abortController = null;
this.notifySubscribers(event, turnId);
}
@@ -1708,16 +1682,12 @@ class OpenCodeAgentSession implements AgentSession {
return;
}
const messages = response.data;
for (const message of messages) {
const { info, parts } = message;
const role = info.role as "user" | "assistant";
if (role === "user") {
// Extract user message text from parts
const textParts = parts.filter((p) => (p as { type?: string }).type === "text");
const text = textParts.map((p) => (p as { text?: string }).text ?? "").join("");
for (const { info, parts } of response.data) {
if (info.role === "user") {
const text = parts
.filter((p): p is Extract<OpenCodePart, { type: "text" }> => p.type === "text")
.map((p) => p.text)
.join("");
if (text) {
yield {
@@ -1726,49 +1696,40 @@ class OpenCodeAgentSession implements AgentSession {
item: { type: "user_message", text },
};
}
} else if (role === "assistant") {
} else {
let emittedAssistantText = false;
// Process each part
for (const part of parts) {
const partType = (part as { type?: string }).type;
if (partType === "text") {
const text = (part as { text?: string }).text;
if (text) {
if (part.type === "text") {
if (part.text) {
emittedAssistantText = true;
yield {
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text },
item: { type: "assistant_message", text: part.text },
};
}
} else if (partType === "reasoning") {
const text = (part as { text?: string }).text;
if (text) {
} else if (part.type === "reasoning") {
if (part.text) {
yield {
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text },
item: { type: "reasoning", text: part.text },
};
}
} else if (partType === "tool") {
} else if (part.type === "tool") {
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (parsedToolPart.success) {
if (parsedToolPart.data) {
yield {
type: "timeline",
provider: "opencode",
item: parsedToolPart.data,
};
}
if (parsedToolPart.success && parsedToolPart.data) {
yield {
type: "timeline",
provider: "opencode",
item: parsedToolPart.data,
};
}
}
}
if (!emittedAssistantText) {
const text = stringifyStructuredAssistantMessage(
(info as { structured?: unknown }).structured,
);
const text = stringifyStructuredAssistantMessage(info.structured);
if (text) {
yield {
type: "timeline",
@@ -2027,7 +1988,7 @@ class OpenCodeAgentSession implements AgentSession {
);
}
private translateEvent(event: unknown): AgentStreamEvent[] {
private translateEvent(event: OpenCodeEvent): AgentStreamEvent[] {
const translated = translateOpenCodeEvent(event, {
sessionId: this.sessionId,
messageRoles: this.messageRoles,
@@ -326,7 +326,7 @@ export class PiACPAgentClient extends ACPAgentClient {
if (!existsSync(resolvedPiAcpPath)) {
return false;
}
if (!isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi")) {
if (!(await isCommandAvailable(process.env.PI_ACP_PI_COMMAND ?? "pi"))) {
return false;
}
return (
@@ -340,8 +340,8 @@ export class PiACPAgentClient extends ACPAgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const piCommand = process.env.PI_ACP_PI_COMMAND ?? "pi";
const piCliPath = findExecutable(piCommand);
const piVersion = piCliPath ? resolveBinaryVersion(piCliPath) : "unknown";
const piCliPath = await findExecutable(piCommand);
const piVersion = piCliPath ? await resolveBinaryVersion(piCliPath) : "unknown";
const authConfigPath = join(homedir(), ".pi", "agent", "auth.json");
const available = await this.isAvailable();
let modelsValue = "Not checked";
@@ -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");
});
});
+26 -22
View File
@@ -1,7 +1,11 @@
import { spawn, type ChildProcess } from "node:child_process";
import { existsSync } from "node:fs";
import { posix, win32 } from "node:path";
import type { EditorTargetDescriptorPayload, EditorTargetId } from "../shared/messages.js";
import type {
EditorTargetDescriptorPayload,
EditorTargetId,
KnownEditorTargetId,
} from "../shared/messages.js";
import {
findExecutable,
quoteWindowsArgument,
@@ -9,7 +13,7 @@ import {
} from "../utils/executable.js";
type EditorTargetDefinition = {
id: EditorTargetId;
id: KnownEditorTargetId;
label: string;
command: string;
platforms?: readonly NodeJS.Platform[];
@@ -18,7 +22,7 @@ type EditorTargetDefinition = {
type ListAvailableEditorTargetsDependencies = {
platform?: NodeJS.Platform;
findExecutable?: (command: string) => string | null;
findExecutable?: (command: string) => string | null | Promise<string | null>;
};
type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
@@ -29,6 +33,7 @@ type OpenInEditorTargetDependencies = ListAvailableEditorTargetsDependencies & {
const EDITOR_TARGETS: readonly EditorTargetDefinition[] = [
{ id: "cursor", label: "Cursor", command: "cursor" },
{ id: "vscode", label: "VS Code", command: "code" },
{ id: "webstorm", label: "WebStorm", command: "webstorm" },
{ id: "zed", label: "Zed", command: "zed" },
{ id: "finder", label: "Finder", command: "open", platforms: ["darwin"] },
{ id: "explorer", label: "Explorer", command: "explorer", platforms: ["win32"] },
@@ -65,28 +70,27 @@ function resolveEditorTargetDefinition(editorId: EditorTargetId): EditorTargetDe
return target;
}
export function listAvailableEditorTargets(
export async function listAvailableEditorTargets(
dependencies: ListAvailableEditorTargetsDependencies = {},
): EditorTargetDescriptorPayload[] {
): Promise<EditorTargetDescriptorPayload[]> {
const platform = dependencies.platform ?? process.platform;
const findExecutableFn = dependencies.findExecutable ?? findExecutable;
return EDITOR_TARGETS.flatMap((target) => {
const results: EditorTargetDescriptorPayload[] = [];
for (const target of EDITOR_TARGETS) {
if (!isTargetSupportedOnPlatform(target, platform)) {
return [];
continue;
}
const executable = findExecutableFn(target.command);
const executable = await findExecutableFn(target.command);
if (!executable) {
return [];
continue;
}
return [
{
id: target.id,
label: target.label,
},
];
});
results.push({
id: target.id,
label: target.label,
});
}
return results;
}
type Launch = {
@@ -94,17 +98,17 @@ type Launch = {
args: string[];
};
function resolveEditorLaunch(input: {
async function resolveEditorLaunch(input: {
editorId: EditorTargetId;
path: string;
platform: NodeJS.Platform;
findExecutableFn: typeof findExecutable;
}): Launch {
findExecutableFn: (command: string) => string | null | Promise<string | null>;
}): Promise<Launch> {
const target = resolveEditorTargetDefinition(input.editorId);
if (!isTargetSupportedOnPlatform(target, input.platform)) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
const executable = input.findExecutableFn(target.command);
const executable = await input.findExecutableFn(target.command);
if (!executable) {
throw new Error(`Editor target unavailable: ${target.label}`);
}
@@ -135,7 +139,7 @@ export async function openInEditorTarget(
throw new Error(`Path does not exist: ${pathToOpen}`);
}
const launch = resolveEditorLaunch({
const launch = await resolveEditorLaunch({
editorId: input.editorId,
path: pathToOpen,
platform,
+2 -1
View File
@@ -33,7 +33,8 @@ export {
export {
applyProviderEnv,
} from "./agent/provider-launch-config.js";
export { findExecutable, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
export { findExecutable, findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "../utils/executable.js";
export { spawnProcess } from "../utils/spawn.js";
// Provider manifest (source of truth for provider definitions)
export {
+65 -42
View File
@@ -8,6 +8,7 @@ import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
import {
isLegacyEditorTargetId,
serializeAgentStreamEvent,
type AgentSnapshotPayload,
type SessionInboundMessage,
@@ -28,6 +29,7 @@ import {
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type EditorTargetDescriptorPayload,
type EditorTargetId,
type ProjectPlacementPayload,
type WorkspaceDescriptorPayload,
@@ -202,13 +204,14 @@ const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
// the entire session message if they encounter an unknown provider.
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";
function clientSupportsAllProviders(appVersion: string | null): boolean {
function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean {
if (!appVersion) return false;
// Strip RC/prerelease suffix: "0.1.45-rc.4" → "0.1.45"
const base = appVersion.replace(/-.*$/, "");
const parts = base.split(".").map(Number);
const minParts = MIN_VERSION_ALL_PROVIDERS.split(".").map(Number);
const minParts = minVersion.split(".").map(Number);
for (let i = 0; i < minParts.length; i++) {
const a = parts[i] ?? 0;
const b = minParts[i] ?? 0;
@@ -218,6 +221,14 @@ function clientSupportsAllProviders(appVersion: string | null): boolean {
return true;
}
function clientSupportsAllProviders(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS);
}
function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS);
}
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
@@ -1215,6 +1226,15 @@ export class Session {
return LEGACY_PROVIDER_IDS.has(provider);
}
private filterEditorsForClient(
editors: EditorTargetDescriptorPayload[],
): EditorTargetDescriptorPayload[] {
if (clientSupportsFlexibleEditorIds(this.appVersion)) {
return editors;
}
return editors.filter((editor) => isLegacyEditorTargetId(editor.id));
}
private matchesAgentFilter(options: {
agent: AgentSnapshotPayload;
project: ProjectPlacementPayload;
@@ -2836,7 +2856,7 @@ export class Session {
images?: Array<{ data: string; mimeType: string }>,
runOptions?: AgentRunOptions,
options?: { spokenInput?: boolean },
): Promise<void> {
): Promise<{ ok: true } | { ok: false; error: string }> {
this.sessionLogger.info(
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
`Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ""}`,
@@ -2848,7 +2868,10 @@ export class Session {
await this.ensureAgentLoaded(agentId);
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt");
return;
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
try {
@@ -2866,7 +2889,7 @@ export class Session {
const promptText = options?.spokenInput ? wrapSpokenInput(text) : text;
const prompt = this.buildAgentPrompt(promptText, images);
this.startAgentStream(agentId, prompt, runOptions);
return this.startAgentStream(agentId, prompt, runOptions);
}
/**
@@ -2914,6 +2937,29 @@ export class Session {
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels });
await this.forwardAgentUpdate(snapshot);
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
agentManager: this.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
initialPrompt: trimmedPrompt,
explicitTitle,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
const started = await this.handleSendAgentMessage(
snapshot.id,
trimmedPrompt,
resolveClientMessageId(clientMessageId),
images,
outputSchema ? { outputSchema } : undefined,
);
if (!started.ok) {
throw new Error(started.error);
}
}
if (requestId) {
const agentPayload = await this.getAgentPayloadById(snapshot.id);
if (!agentPayload) {
@@ -2930,40 +2976,6 @@ export class Session {
});
}
if (trimmedPrompt) {
scheduleAgentMetadataGeneration({
agentManager: this.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
initialPrompt: trimmedPrompt,
explicitTitle,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
void this.handleSendAgentMessage(
snapshot.id,
trimmedPrompt,
resolveClientMessageId(clientMessageId),
images,
outputSchema ? { outputSchema } : undefined,
).catch((promptError) => {
this.sessionLogger.error(
{ err: promptError, agentId: snapshot.id },
`Failed to run initial prompt for agent ${snapshot.id}`,
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
},
});
});
}
if (worktreeConfig) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
@@ -3924,8 +3936,16 @@ export class Session {
);
try {
await this.agentManager.respondToPermission(agentId, requestId, response);
const result = await this.agentManager.respondToPermission(agentId, requestId, response);
this.sessionLogger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`);
if (result?.followUpPrompt) {
this.sessionLogger.debug(
{ agentId },
"Permission response requires follow-up turn, starting agent stream",
);
this.startAgentStream(agentId, result.followUpPrompt);
}
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId, requestId },
@@ -6095,7 +6115,7 @@ export class Session {
}
async getAvailableEditorTargets() {
return listAvailableEditorTargets();
return this.filterEditorsForClient(await listAvailableEditorTargets());
}
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
@@ -6602,6 +6622,7 @@ export class Session {
try {
let result = await this.agentManager.waitForAgentEvent(agentId, {
signal: abortController.signal,
waitForActive: true,
});
let final = await this.getAgentPayloadById(agentId);
if (!final) {
@@ -7522,7 +7543,9 @@ export class Session {
listStoredAgents: () => this.agentStorage.list(),
listLiveAgents: () => this.agentManager.listAgents(),
resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier),
sendAgentMessage: (agentId, text) => this.handleSendAgentMessage(agentId, text),
sendAgentMessage: async (agentId, text) => {
await this.handleSendAgentMessage(agentId, text);
},
});
} catch (error) {
this.emitChatRpcError(request, error);
@@ -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;
@@ -0,0 +1,3 @@
// Adapted from type-fest's LiteralUnion:
// https://github.com/sindresorhus/type-fest/blob/main/source/literal-union.d.ts
export type LiteralUnion<T extends U, U extends string> = T | (U & Record<never, never>);
+24 -3
View File
@@ -47,6 +47,7 @@ import {
LoopLogsResponseSchema,
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",
+9 -8
View File
@@ -1,4 +1,5 @@
import { exec, execFile, spawn } from "child_process";
import { exec, execFile } from "child_process";
import { spawnProcess } from "./spawn.js";
import { promisify } from "util";
import { 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,
+19 -45
View File
@@ -1,12 +1,12 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
findExecutable,
findExecutableSync,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./executable.js";
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutable>[1]>;
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutableSync>[1]>;
function createFindExecutableDependencies(): FindExecutableDependencies {
return {
@@ -22,57 +22,31 @@ beforeEach(() => {
findExecutableDependencies = createFindExecutableDependencies();
});
describe("findExecutable", () => {
test("on Windows, resolves executables using current machine and user PATH entries", () => {
describe("findExecutableSync", () => {
test("on Windows, resolves executables using where.exe with inherited PATH", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((command: string, args?: string[]) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\Users\\boudr\\.local\\bin\r\n";
}
if (command === "where.exe") {
return "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
findExecutableDependencies.execFileSync.mockReturnValue(
"C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n",
);
expect(findExecutable("claude", findExecutableDependencies)).toBe(
expect(findExecutableSync("claude", findExecutableDependencies)).toBe(
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
);
const powershellCall = findExecutableDependencies.execFileSync.mock.calls[0];
expect(powershellCall?.[0]).toBe("powershell");
expect(powershellCall?.[1]).toContain("-NoProfile");
expect(powershellCall?.[1]).toContain("-NonInteractive");
expect(powershellCall?.[1]).toContain(
'$machine = [Environment]::GetEnvironmentVariable("Path", "Machine"); $user = [Environment]::GetEnvironmentVariable("Path", "User"); if ($machine) { Write-Output $machine }; if ($user) { Write-Output $user }',
);
const whereCall = findExecutableDependencies.execFileSync.mock.calls[1];
expect(whereCall?.[0]).toBe("where.exe");
expect(whereCall?.[1]).toEqual(["claude"]);
expect(whereCall?.[2]?.encoding).toBe("utf8");
const env = whereCall?.[2]?.env as Record<string, string | undefined>;
expect(env.PATH).toContain("C:\\Users\\boudr\\.local\\bin");
expect(env.Path).toContain("C:\\Users\\boudr\\.local\\bin");
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledOnce();
const call = findExecutableDependencies.execFileSync.mock.calls[0];
expect(call?.[0]).toBe("where.exe");
expect(call?.[1]).toEqual(["claude"]);
expect(call?.[2]?.encoding).toBe("utf8");
expect(call?.[2]?.windowsHide).toBe(true);
});
test("on Windows, preserves the first where.exe match", () => {
findExecutableDependencies.platform = vi.fn(() => "win32");
process.env.Path = "C:\\Windows\\System32";
findExecutableDependencies.execFileSync.mockImplementation(
((command: string) => {
if (command === "powershell") {
return "C:\\Windows\\System32\r\nC:\\nvm4w\\nodejs\r\n";
}
if (command === "where.exe") {
return "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n";
}
throw new Error(`unexpected command ${command}`);
}) as any,
findExecutableDependencies.execFileSync.mockReturnValue(
"C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n",
);
expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex");
expect(findExecutableSync("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex");
});
test("on Unix, uses the last line from which output", () => {
@@ -80,7 +54,7 @@ describe("findExecutable", () => {
"/usr/local/bin/codex\n",
);
expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
expect(findExecutableSync("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith(
"which",
["codex"],
@@ -92,7 +66,7 @@ describe("findExecutable", () => {
findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(findExecutable("codex", findExecutableDependencies)).toBeNull();
expect(findExecutableSync("codex", findExecutableDependencies)).toBeNull();
expect(warnSpy).toHaveBeenCalledOnce();
warnSpy.mockRestore();
@@ -101,7 +75,7 @@ describe("findExecutable", () => {
test("returns direct paths when they exist", () => {
findExecutableDependencies.existsSync.mockReturnValue(true);
expect(findExecutable("/usr/local/bin/codex", findExecutableDependencies)).toBe(
expect(findExecutableSync("/usr/local/bin/codex", findExecutableDependencies)).toBe(
"/usr/local/bin/codex",
);
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
+56 -52
View File
@@ -1,40 +1,17 @@
import { execFileSync } from "node:child_process";
import { execFile, execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { 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
View File
@@ -0,0 +1,42 @@
import {
spawn,
type ChildProcess,
type SpawnOptions,
} from "node:child_process";
/**
* Platform-aware spawn that centralizes Windows shell and quoting concerns.
*
* On Windows:
* - Enables `shell: true` (routes through cmd.exe) unless the caller explicitly sets `shell`
* - Quotes the command and arguments so paths with spaces survive cmd.exe parsing
* - Always sets `windowsHide: true` to prevent console window flashes
*
* On other platforms the call is passed through to `spawn` unchanged (with `windowsHide: true`).
*/
export function spawnProcess(
command: string,
args: string[],
options?: SpawnOptions,
): ChildProcess {
const isWindows = process.platform === "win32";
const resolvedCommand = isWindows ? quoteForCmd(command) : command;
const resolvedArgs = isWindows ? args.map(quoteForCmd) : args;
return spawn(resolvedCommand, resolvedArgs, {
...options,
shell: options?.shell ?? isWindows,
windowsHide: true,
});
}
/**
* Quote a string for cmd.exe if it contains spaces and isn't already quoted.
* No-op for strings without spaces or strings that are already double-quoted.
*/
function quoteForCmd(value: string): string {
if (!value.includes(" ")) return value;
if (value.startsWith('"') && value.endsWith('"')) return value;
return `"${value}"`;
}
+2 -2
View File
@@ -77,8 +77,8 @@ function UpdatesDocs() {
<section className="space-y-4">
<h2 className="text-xl font-medium">Mobile apps</h2>
<p className="text-white/60">
Mobile apps are not yet publicly available in the App Store or Play Store. We are working
on public store availability.
Mobile apps are available on the App Store and Play Store. Update through your respective
store. Store versions may lag behind the latest release due to review processes.
</p>
</section>
</div>