Compare commits

...

29 Commits

Author SHA1 Message Date
Mohamed Boudra
2d9ca6983a chore(release): cut 0.1.47 2026-04-05 00:58:22 +07:00
Mohamed Boudra
e44e495481 Update CHANGELOG.md for 0.1.47 stable release 2026-04-05 00:57:03 +07:00
Mohamed Boudra
bedf792616 fix(voice): harden Electron speak TTS path 2026-04-05 00:53:50 +07:00
Mohamed Boudra
5db9942125 fix(desktop): restore daemon QR pairing output 2026-04-05 00:53:46 +07:00
Mohamed Boudra
a889dbcc28 Merge branch 'main' of github.com:getpaseo/paseo 2026-04-05 00:14:41 +07:00
Mohamed Boudra
5c1e869bc5 fix: copy sherpa TTS samples to avoid external buffer crash
Same fix as the Silero VAD — sherpa-onnx returns Float32Arrays backed
by native memory which Node.js rejects as "External buffers are not
allowed". Copy into JS-managed memory before passing to audio pipeline.
2026-04-04 23:45:28 +07:00
Mohamed Boudra
a693ff560c fix: remove per-host "Add connection" button that blocked multi-host setups
Users connecting multiple daemons (e.g. local + remote via Tailscale) hit
a "belongs to X, not Y" error because the edit-host modal's Add Connection
button scoped new connections to the current host's server ID. Remove that
button and its supporting state so all connections go through the top-level
flow which has no server ID constraint.
2026-04-04 23:45:03 +07:00
github-actions[bot]
89baf7ff38 fix: update lockfile signatures and Nix hash 2026-04-04 16:00:01 +00:00
Mohamed Boudra
c65a851205 chore(release): cut 0.1.46 2026-04-04 22:57:53 +07:00
Mohamed Boudra
85acdbb05e Update CHANGELOG.md for 0.1.46 stable release 2026-04-04 22:57:45 +07:00
Mohamed Boudra
4e32f9b7bd fix: copy Silero VAD model out of asar so native code can read it
The bundled silero_vad.onnx lives inside Electron's app.asar which
native C++ (sherpa-onnx) cannot open via OS file I/O. On first voice
activation, copy the model to ~/.paseo/models/local-speech/silero-vad/
using Node.js fs (asar-aware) and point the native code there.
2026-04-04 22:11:54 +07:00
Mohamed Boudra
744ca7a2bc fix: send appVersion in probe client hello and update it on session resume
buildClientConfig in test-daemon-connection.ts never set appVersion, so
probe clients (which become the live client) sent hello without it. The
daemon's version gate then hid Pi/Copilot from all these sessions.

Also update appVersion on the Session when a client reconnects with a
newer version, so stale sessions don't stay gated forever.
2026-04-04 21:12:34 +07:00
Mohamed Boudra
3110bae209 fix(website): stack header vertically on mobile to prevent overflow 2026-04-04 21:06:35 +07:00
Mohamed Boudra
16efdb2c95 feat(website): add Copilot and Pi to homepage provider grid 2026-04-04 20:29:44 +07:00
Mohamed Boudra
1e9b7f1157 fix: make worktreeRoot backward-compatible for old clients/daemons
worktreeRoot was added as a required field in 9154f8fc, which breaks
old clients parsing new daemon responses and new clients parsing old
daemon responses. Made it .optional() with .transform() fallbacks.

Also added a critical rule to CLAUDE.md: schema changes must always
be backward-compatible in both directions.
2026-04-04 20:29:44 +07:00
Mohamed Boudra
018ebd5f29 Fix punycode deprecation warning in CLI and daemon entrypoints 2026-04-04 20:29:44 +07:00
github-actions[bot]
4706d9e3bd fix: update lockfile signatures and Nix hash 2026-04-04 12:22:20 +00:00
Mohamed Boudra
ba0c4a3fff chore(release): cut 0.1.45 2026-04-04 19:21:12 +07:00
Mohamed Boudra
2a8da5d4c1 Update CHANGELOG.md for 0.1.45 stable release 2026-04-04 19:21:12 +07:00
Mohamed Boudra
43542ac858 Fix CLI routing: single classifier, open-project for cold & hot start
- Add classify.ts as the single source of truth for CLI invocation
  routing (discriminated union, derives known commands from Commander)
- Remove all hardcoded command lists and duplicate path detection logic
- Simplify shell wrappers to dumb pipes (zero classification)
- Fix hot-start: use `open -n -g -a` (VS Code pattern) so second-instance
  event fires when app is already running
- Fix cold-start race: pull-based IPC (getPendingOpenProject) so renderer
  fetches the pending path after React mounts, instead of push event that
  arrived before the listener existed
- Fix asar read corruption: unpack node-entrypoint-runner.js from asar
  (Node.js v24 in Electron 41 can't parse package.json inside asar)
- Strip ELECTRON_RUN_AS_NODE from env when spawning desktop app from CLI
2026-04-04 19:21:12 +07:00
Mohamed Boudra
fefe260f0a Fix Silero VAD crash: disable external buffers in CircularBuffer.get()
Newer sherpa-onnx-node rejects external buffers by default, causing
"External buffers are not allowed" on every voice audio chunk.
2026-04-04 19:21:12 +07:00
Mohamed Boudra
a17f7d2d20 Fix forward-compatible provider handling for old app clients
AgentProviderSchema was z.enum() which caused old clients to reject
session messages containing unknown providers (pi, copilot), breaking
the entire session. Changed to z.string() for future clients.

For currently deployed clients (<0.1.45), the daemon now filters out
unknown providers based on the appVersion sent in the WebSocket hello
message. Clients that don't send appVersion only see claude/codex/opencode.
2026-04-04 19:21:12 +07:00
Mohamed Boudra
c5a69a1ad9 Update skill CLI examples to use --provider provider/model format
Always specify the model in paseo run examples (e.g. --provider codex/gpt-5.4
instead of --provider codex) to prevent agents from launching with wrong defaults.
2026-04-04 19:21:12 +07:00
github-actions[bot]
155c88254b fix: update lockfile signatures and Nix hash 2026-04-04 09:37:40 +00:00
Mohamed Boudra
22c83118d0 chore(release): cut 0.1.45-rc.4 2026-04-04 16:36:26 +07:00
Mohamed Boudra
7e99529bde Add Codex plan mode draft features 2026-04-04 16:35:31 +07:00
Mohamed Boudra
a6f6c169c8 Fix Pi thinking mode state mapping 2026-04-04 16:35:31 +07:00
Mohamed Boudra
d69fcb861d Fix CLI arg routing and desktop install links 2026-04-04 16:35:31 +07:00
github-actions[bot]
c3313ff82a fix: update lockfile signatures and Nix hash 2026-04-04 07:57:09 +00:00
88 changed files with 2688 additions and 805 deletions

View File

@@ -1,5 +1,54 @@
# Changelog
## 0.1.47 - 2026-04-05
### Fixed
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
## 0.1.46 - 2026-04-04
### Fixed
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
## 0.1.45 - 2026-04-04
### Added
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
- Copilot agent provider re-enabled after ACP compatibility fixes.
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
- Daemon status dialog in desktop settings for quick health checks.
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
### Improved
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
- Settings sections reordered for better grouping (integrations and daemon together).
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
### Fixed
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
- Silero VAD crash caused by external buffer mode in CircularBuffer.
- Bulk close now correctly archives stored agents instead of leaving orphans.
- Pinned archived agents are no longer pruned when closing tabs.
- OpenCode event stream starvation during slash command execution.
- Duplicate workspaces when multiple git worktrees share the same root.
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
- Forward-compatible provider handling so older app clients don't break on new provider types.
- Input event listener race condition in the web scrollbar hook.
- Open-project screen content now vertically centered.
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
## 0.1.44 - 2026-04-03
### Fixed

View File

@@ -45,6 +45,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
- **NEVER add auth checks to tests** — agent providers handle their own auth.
- **Always run typecheck after every change.**
- **NEVER make breaking changes to WebSocket or message schemas.** The mobile app in the App Store always lags behind the daemon, and daemons in the wild lag behind new app releases. Both directions must work. Every schema change MUST be backward-compatible:
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
- Never change a field from optional to required.
- Never remove a field — deprecate it (keep accepting it, stop sending it).
- Never narrow a field's type (e.g. `string``enum`, `nullable` → non-null).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
## Debugging

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-pZGDc2JVlxKz6TvuM7ALD2RzXq/ANzvmJiV+bge/SL4=";
npmDepsHash = "sha256-8OTtv6FJq62fT5E6Ai4M/z21flDpIMtnpnkMGCHyJ9E=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

38
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -634,6 +634,22 @@ function OpenProjectListener() {
void openProject(pathToOpen).catch(() => undefined);
};
// Pull any path that was passed on cold start (before the listener existed).
// Store in the ref even if this effect instance is disposed — the next
// effect run picks it up via maybeOpenProject(pendingPathRef.current).
void getDesktopHost()
?.getPendingOpenProject?.()
?.then((pending) => {
if (pending) {
pendingPathRef.current = pending;
}
if (!disposed && pending) {
maybeOpenProject(pending);
}
})
.catch(() => undefined);
// Listen for hot-start paths relayed via the second-instance event.
void listenToDesktopEvent<OpenProjectEventPayload>("open-project", (payload) => {
if (disposed) {
return;

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint,
normalizeModelId,
resolveAgentModelSelection,
@@ -13,6 +15,31 @@ describe("getStatusSelectorHint", () => {
});
});
describe("feature metadata helpers", () => {
it("prefers explicit feature tooltip copy", () => {
expect(
getFeatureTooltip({
label: "Plan",
tooltip: "Toggle plan mode",
}),
).toBe("Toggle plan mode");
});
it("falls back to the feature label when no tooltip is provided", () => {
expect(
getFeatureTooltip({
label: "Custom",
}),
).toBe("Custom");
});
it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default");
});
});
describe("normalizeModelId", () => {
it("treats empty values as unset", () => {
expect(normalizeModelId("")).toBeNull();

View File

@@ -3,7 +3,16 @@ import { View, Text, Platform, Pressable, Keyboard } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { Brain, ChevronDown, Settings2, ShieldAlert, ShieldCheck, ShieldOff, Zap } from "lucide-react-native";
import {
Brain,
ChevronDown,
ListTodo,
Settings2,
ShieldAlert,
ShieldCheck,
ShieldOff,
Zap,
} from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import { useQuery } from "@tanstack/react-query";
@@ -37,6 +46,8 @@ import {
type AgentModeIcon,
} from "@server/server/agent/provider-manifest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getStatusSelectorHint,
resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils";
@@ -95,6 +106,8 @@ export interface DraftAgentStatusBarProps {
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
disabled?: boolean;
}
@@ -116,6 +129,7 @@ function findOptionLabel(
}
const FEATURE_ICONS: Record<string, typeof Zap> = {
"list-todo": ListTodo,
zap: Zap,
};
@@ -123,6 +137,29 @@ function getFeatureIcon(icon?: string) {
return (icon && FEATURE_ICONS[icon]) || Settings2;
}
function getFeatureIconColor(
featureId: string,
enabled: boolean,
palette: {
blue: { 400: string };
yellow: { 400: string };
},
foregroundMuted: string,
): string {
if (!enabled) {
return foregroundMuted;
}
switch (getFeatureHighlightColor(featureId)) {
case "blue":
return palette.blue[400];
case "yellow":
return palette.yellow[400];
default:
return foregroundMuted;
}
}
const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
@@ -216,7 +253,8 @@ function ControlledStatusBar({
Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) ||
canSelectModel ||
Boolean(thinkingOptions?.length);
Boolean(thinkingOptions?.length) ||
Boolean(features?.length);
if (!hasAnyControl) {
return null;
@@ -420,106 +458,6 @@ function ControlledStatusBar({
</>
) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<Tooltip
key={`feature-${feature.id}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
pressed && styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={feature.label}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={
feature.value
? theme.colors.palette.yellow[400]
: theme.colors.foregroundMuted
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{feature.label}</Text>
</TooltipContent>
</Tooltip>
);
}
if (feature.type === "select") {
const FeatureIcon = getFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<DropdownMenu
key={`feature-${feature.id}`}
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === `feature-${feature.id}`) &&
styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={feature.label}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.modeBadgeText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{feature.label}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
{modeOptions && modeOptions.length > 0 ? (
<>
<Tooltip
@@ -568,6 +506,107 @@ function ControlledStatusBar({
/>
</>
) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<Tooltip
key={`feature-${feature.id}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed, hovered }) => [
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
pressed && styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
);
}
if (feature.type === "select") {
const FeatureIcon = getFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<DropdownMenu
key={`feature-${feature.id}`}
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed, hovered }) => [
styles.modeBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === `feature-${feature.id}`) &&
styles.modeBadgePressed,
disabled && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
<Text style={styles.modeBadgeText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
</>
) : (
<>
@@ -664,86 +703,6 @@ function ControlledStatusBar({
</View>
) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={feature.label}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={
feature.value
? theme.colors.palette.yellow[400]
: theme.colors.foregroundMuted
}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>
{feature.value ? "On" : "Off"}
</Text>
</Pressable>
</View>
);
}
if (feature.type === "select") {
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<DropdownMenu
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
>
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={feature.label}
testID={`agent-feature-${feature.id}`}
>
<Text style={styles.sheetSelectText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
return null;
})}
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
@@ -786,6 +745,85 @@ function ControlledStatusBar({
</DropdownMenu>
</View>
) : null}
{features?.map((feature) => {
if (feature.type === "toggle") {
const FeatureIcon = getFeatureIcon(feature.icon);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<Pressable
disabled={disabled}
onPress={() => onSetFeature?.(feature.id, !feature.value)}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<FeatureIcon
size={theme.iconSize.md}
color={getFeatureIconColor(
feature.id,
feature.value,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
/>
<Text style={styles.sheetSelectText}>{feature.label}</Text>
<Text style={styles.modeBadgeText}>{feature.value ? "On" : "Off"}</Text>
</Pressable>
</View>
);
}
if (feature.type === "select") {
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<View key={`feature-${feature.id}`} style={styles.sheetSection}>
<DropdownMenu
open={openSelector === `feature-${feature.id}`}
onOpenChange={(open) =>
setOpenSelector(open ? `feature-${feature.id}` : null)
}
>
<DropdownMenuTrigger
disabled={disabled}
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
disabled && styles.disabledSheetSelect,
]}
accessibilityRole="button"
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
>
<Text style={styles.sheetSelectText}>
{selectedOption?.label ?? feature.label}
</Text>
<ChevronDown
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{feature.options.map((option) => (
<DropdownMenuItem
key={option.id}
selected={option.id === feature.value}
onSelect={() => onSetFeature?.(feature.id, option.id)}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
return null;
})}
</AdaptiveModalSheet>
</>
)}
@@ -1008,6 +1046,8 @@ export function DraftAgentStatusBar({
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
features,
onSetFeature,
disabled = false,
}: DraftAgentStatusBarProps) {
const isWeb = Platform.OS === "web";
@@ -1061,6 +1101,8 @@ export function DraftAgentStatusBar({
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
disabled={disabled}
/>
</View>
@@ -1093,6 +1135,8 @@ export function DraftAgentStatusBar({
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
features={features}
onSetFeature={onSetFeature}
disabled={disabled}
/>
);

View File

@@ -1,6 +1,7 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentFeature, AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
export type ExplainedStatusSelector = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "yellow";
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
switch (selector) {
@@ -21,6 +22,21 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized;
}
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
return feature.tooltip ?? feature.label;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) {
case "fast_mode":
return "yellow";
case "plan_mode":
return "blue";
default:
return "default";
}
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;

View File

@@ -9,7 +9,6 @@ import {
useState,
} from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "expo-router";
@@ -36,6 +35,7 @@ import {
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
import { PlanCard } from "./plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
@@ -59,9 +59,7 @@ import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -252,10 +250,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap;
}
if (
(item.kind === "user_message" || item.kind === "assistant_message") &&
isToolSequenceItem(belowItem)
) {
if ((item.kind === "user_message" || item.kind === "assistant_message") && isToolSequenceItem(belowItem)) {
return tightGap;
}
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
@@ -368,7 +363,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceRoot={workspaceRoot}
/>
);
case "thought": {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
@@ -755,90 +749,6 @@ function PermissionRequestCard({
return undefined;
}, [request]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownRules = useMemo(() => {
return {
text: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: React.ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}, []);
const permissionMutation = useMutation({
mutationFn: async (input: {
agentId: string;
@@ -891,50 +801,8 @@ function PermissionRequestCard({
);
}
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? (
<View style={permissionStyles.section}>
{!isPlanRequest ? (
<Text style={[permissionStyles.sectionTitle, { color: theme.colors.foregroundMuted }]}>
Proposed plan
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{planMarkdown}
</Markdown>
</View>
) : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
const footer = (
<>
<Text
testID="permission-request-question"
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
@@ -1007,6 +875,55 @@ function PermissionRequestCard({
)}
</Pressable>
</View>
</>
);
if (isPlanRequest && planMarkdown) {
return (
<PlanCard
title={title}
description={description}
text={planMarkdown}
footer={footer}
disableOuterSpacing
/>
);
}
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{planMarkdown ? <PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing /> : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
{footer}
</View>
);
}

View File

@@ -71,6 +71,7 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
import { openExternalUrl } from "@/utils/open-external-url";
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
export type { InlinePathTarget } from "@/utils/inline-path";
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
@@ -1884,6 +1885,12 @@ export const ToolCall = memo(function ToolCall({
);
}, [isMobile, effectiveDetail, errorText, isLoadingDetails]);
if (effectiveDetail?.type === "plan") {
return (
<PlanCard title="Plan" text={effectiveDetail.text} disableOuterSpacing={disableOuterSpacing} />
);
}
return (
<ExpandableBadge
testID="tool-call-badge"

View File

@@ -0,0 +1,154 @@
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import Markdown from "react-native-markdown-display";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker } from "@/utils/markdown-list";
function createPlanMarkdownRules() {
return {
text: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
{node.content}
</Text>
),
textgroup: (
node: any,
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
{children}
</Text>
),
code_block: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
{node.content}
</Text>
),
fence: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
{node.content}
</Text>
),
code_inline: (
node: any,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
{node.content}
</Text>
),
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
return (
<View key={node.key} style={[styles.list_item, { flexShrink: 0 }]}>
<Text style={iconStyle}>{marker}</Text>
<Text style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</Text>
</View>
);
},
};
}
export function PlanCard({
title = "Plan",
description,
text,
footer,
disableOuterSpacing = false,
}: {
title?: string;
description?: string;
text: string;
footer?: ReactNode;
disableOuterSpacing?: boolean;
}) {
const { theme } = useUnistyles();
const markdownStyles = createMarkdownStyles(theme);
const markdownRules = createPlanMarkdownRules();
return (
<View
style={[
styles.container,
disableOuterSpacing && styles.containerCompact,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[styles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{text}
</Markdown>
{footer ? <View style={styles.footer}>{footer}</View> : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
marginVertical: theme.spacing[3],
padding: theme.spacing[3],
borderRadius: theme.spacing[2],
borderWidth: 1,
gap: theme.spacing[2],
},
containerCompact: {
marginVertical: 0,
},
title: {
fontSize: theme.fontSize.base,
lineHeight: 22,
},
description: {
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
footer: {
gap: theme.spacing[2],
},
}));

View File

@@ -100,11 +100,16 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
open: "Open",
merged: "Merged",
closed: "Closed",
};
function getWorkspacePrIconColor(theme: ReturnType<typeof useUnistyles>["theme"], state: PrHint["state"]) {
switch (state) {
case "merged":
return theme.colors.palette.purple[500];
case "open":
return theme.colors.palette.green[500];
case "closed":
return theme.colors.palette.red[500];
}
}
interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[];
@@ -169,7 +174,8 @@ interface WorkspaceRowInnerProps {
function WorkspacePrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const textColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = getWorkspacePrIconColor(theme, hint.state);
const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation();
@@ -197,17 +203,17 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
pressed && styles.workspacePrBadgePressed,
]}
>
<GitPullRequest size={12} color={activeColor} />
<GitPullRequest size={12} color={iconColor} />
<Text
style={[
styles.workspacePrBadgeText,
{ color: activeColor },
{ color: textColor },
]}
numberOfLines={1}
>
#{hint.number} · {GITHUB_PR_STATE_LABELS[hint.state]}
#{hint.number}
</Text>
{isHovered && <ExternalLink size={10} color={activeColor} />}
{isHovered && <ExternalLink size={10} color={textColor} />}
</Pressable>
);
}

View File

@@ -79,6 +79,7 @@ export interface DesktopInvokeBridge {
export interface DesktopHostBridge {
platform?: string;
invoke?: DesktopInvokeBridge["invoke"];
getPendingOpenProject?: () => Promise<string | null>;
events?: DesktopEventsBridge;
window?: DesktopWindowModuleBridge;
dialog?: DesktopDialogBridge;

View File

@@ -62,12 +62,14 @@ function normalizeDraftCommandConfig(
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
const featureValues = draftConfig.featureValues;
return {
provider: draftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
};
}

View File

@@ -16,6 +16,7 @@ export interface DraftCommandConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
}
function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) {
@@ -28,6 +29,7 @@ function commandsQueryKey(serverId: string, agentId: string, draftConfig?: Draft
draftConfig?.modeId ?? null,
draftConfig?.model ?? null,
draftConfig?.thinkingOptionId ?? null,
draftConfig?.featureValues ?? null,
] as const;
}

View File

@@ -0,0 +1,134 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type {
AgentFeature,
AgentProvider,
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
function pruneFeatureValues(
featureValues: Record<string, unknown>,
features: AgentFeature[],
): Record<string, unknown> {
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
let changed = false;
const next: Record<string, unknown> = {};
for (const [featureId, value] of Object.entries(featureValues)) {
if (!allowedFeatureIds.has(featureId)) {
changed = true;
continue;
}
next[featureId] = value;
}
return changed ? next : featureValues;
}
function applyFeatureValues(
features: AgentFeature[],
featureValues: Record<string, unknown>,
): AgentFeature[] {
if (Object.keys(featureValues).length === 0) {
return features;
}
return features.map((feature) => {
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
return feature;
}
return {
...feature,
value: featureValues[feature.id],
} as AgentFeature;
});
}
type DraftFeatureConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
>;
export function useDraftAgentFeatures(input: {
serverId: string | null | undefined;
provider: AgentProvider;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
thinkingOptionId: string | null | undefined;
}) {
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
const [featureValues, setFeatureValues] = useState<Record<string, unknown>>({});
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const normalizedCwd = cwd?.trim() || "";
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedCwd) {
return null;
}
return {
provider,
cwd: normalizedCwd,
...(modeId ? { modeId } : {}),
...(modelId ? { model: modelId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
};
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]);
const featuresQuery = useQuery({
queryKey: [
"providerFeatures",
serverId ?? null,
provider,
normalizedCwd || null,
modeId ?? null,
modelId ?? null,
thinkingOptionId ?? null,
],
enabled: Boolean(serverId && client && isConnected && draftConfig),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !draftConfig) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderFeatures(draftConfig);
if (payload.error) {
throw new Error(payload.error);
}
return payload.features ?? [];
},
});
const features = useMemo(() => {
return applyFeatureValues(featuresQuery.data ?? [], featureValues);
}, [featureValues, featuresQuery.data]);
useEffect(() => {
const next = pruneFeatureValues(featureValues, features);
if (next !== featureValues) {
setFeatureValues(next);
}
}, [featureValues, features]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback((featureId: string, value: unknown) => {
setFeatureValues((current) => {
if (Object.is(current[featureId], value)) {
return current;
}
return { ...current, [featureId]: value };
});
}, []);
return {
features,
featureValues: effectiveFeatureValues,
isLoading: featuresQuery.isLoading,
setFeatureValue,
};
}

View File

@@ -14,6 +14,7 @@ import {
type HostProfile,
} from "@/types/host-connection";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { resolveAppVersion } from "@/utils/app-version";
import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer";
import {
shouldUseDesktopDaemon,
@@ -422,6 +423,7 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
suppressSendErrors: true,
clientId,
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration,
};
if (connection.type === "directSocket" || connection.type === "directPipe") {

View File

@@ -56,6 +56,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
const EMPTY_PENDING_PERMISSIONS = new Map();
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
@@ -757,6 +758,18 @@ function DraftAgentScreenContent({
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
const {
features: draftFeatures,
featureValues: draftFeatureValues,
setFeatureValue: setDraftFeatureValue,
} = useDraftAgentFeatures({
serverId: selectedServerId,
provider: selectedProvider,
cwd: workingDir,
modeId: selectedMode,
modelId: effectiveDraftModelId,
thinkingOptionId: effectiveDraftThinkingOptionId,
});
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
const cwd = (
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
@@ -773,8 +786,10 @@ function DraftAgentScreenContent({
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
};
}, [
draftFeatureValues,
effectiveDraftModelId,
effectiveDraftThinkingOptionId,
isAttachWorktree,
@@ -878,6 +893,7 @@ function DraftAgentScreenContent({
title: "New agent",
cwd,
model,
features: draftFeatures,
thinkingOptionId,
labels: {},
};
@@ -896,6 +912,7 @@ function DraftAgentScreenContent({
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
};
const effectiveBaseBranch = baseBranch.trim();
@@ -1248,6 +1265,8 @@ function DraftAgentScreenContent({
thinkingOptions: availableThinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption: setThinkingOptionFromUser,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
disabled: isSubmitting,
}}
/>

View File

@@ -184,13 +184,10 @@ interface HostsSectionProps {
routeServerId: string;
theme: ReturnType<typeof useUnistyles>["theme"];
handleEditDaemon: (profile: HostProfile) => void;
setAddConnectionTargetServerId: (id: string | null) => void;
setPendingEditReopenServerId: (id: string | null) => void;
setIsAddHostMethodVisible: (visible: boolean) => void;
isAddHostMethodVisible: boolean;
isDirectHostVisible: boolean;
isPasteLinkVisible: boolean;
addConnectionTargetServerId: string | null;
closeAddConnectionFlow: () => void;
goBackToAddConnectionMethods: () => void;
setIsDirectHostVisible: (visible: boolean) => void;
@@ -210,7 +207,6 @@ interface HostsSectionProps {
handleSaveEditDaemon: (label: string) => Promise<void>;
handleRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
handleRemoveDaemon: (profile: HostProfile) => void;
handleAddConnectionFromModal: () => void;
restartConfirmationMessage: string;
waitForCondition: (
predicate: () => boolean,
@@ -250,8 +246,6 @@ function HostsSection(props: HostsSectionProps) {
style={styles.addButton}
textStyle={styles.addButtonText}
onPress={() => {
props.setAddConnectionTargetServerId(null);
props.setPendingEditReopenServerId(null);
props.setIsAddHostMethodVisible(true);
}}
>
@@ -271,22 +265,17 @@ function HostsSection(props: HostsSectionProps) {
props.setIsPasteLinkVisible(true);
}}
onScanQr={() => {
const targetServerId = props.addConnectionTargetServerId;
const source = targetServerId ? "editHost" : "settings";
const sourceServerId = props.routeServerId || targetServerId || undefined;
const sourceServerId = props.routeServerId || undefined;
props.closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: targetServerId
? { source, targetServerId, sourceServerId }
: { source, sourceServerId },
params: { source: "settings", sourceServerId },
});
}}
/>
<AddHostModal
visible={props.isDirectHostVisible}
targetServerId={props.addConnectionTargetServerId ?? undefined}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
@@ -298,7 +287,6 @@ function HostsSection(props: HostsSectionProps) {
<PairLinkModal
visible={props.isPasteLinkVisible}
targetServerId={props.addConnectionTargetServerId ?? undefined}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
@@ -378,7 +366,6 @@ function HostsSection(props: HostsSectionProps) {
onSave={(label) => void props.handleSaveEditDaemon(label)}
onRemoveConnection={props.handleRemoveConnection}
onRemoveHost={props.handleRemoveDaemon}
onAddConnection={props.handleAddConnectionFromModal}
restartConfirmationMessage={props.restartConfirmationMessage}
waitForCondition={props.waitForCondition}
isScreenMountedRef={props.isMountedRef}
@@ -741,10 +728,6 @@ export default function SettingsScreen() {
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState<string | null>(
null,
);
const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
@@ -823,7 +806,6 @@ export default function SettingsScreen() {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsPasteLinkVisible(false);
setAddConnectionTargetServerId(null);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
@@ -842,24 +824,6 @@ export default function SettingsScreen() {
handleEditDaemon(profile);
}, [daemons, handleEditDaemon, params.editHost]);
useEffect(() => {
if (!pendingEditReopenServerId) return;
if (isAddHostMethodVisible || isDirectHostVisible || isPasteLinkVisible) return;
const profile = daemons.find((daemon) => daemon.serverId === pendingEditReopenServerId) ?? null;
setPendingEditReopenServerId(null);
setAddConnectionTargetServerId(null);
if (profile) {
handleEditDaemon(profile);
}
}, [
daemons,
handleEditDaemon,
isAddHostMethodVisible,
isDirectHostVisible,
isPasteLinkVisible,
pendingEditReopenServerId,
]);
const handleSaveEditDaemon = useCallback(
async (nextLabelRaw: string) => {
if (!editingServerId) return;
@@ -897,15 +861,6 @@ export default function SettingsScreen() {
setPendingRemoveHost(profile);
}, []);
const handleAddConnectionFromModal = useCallback(() => {
if (!editingServerId) return;
const serverId = editingServerId;
setEditingDaemon(null);
setAddConnectionTargetServerId(serverId);
setPendingEditReopenServerId(serverId);
setIsAddHostMethodVisible(true);
}, [editingServerId]);
const handleThemeChange = useCallback(
(newTheme: AppSettings["theme"]) => {
void updateSettings({ theme: newTheme });
@@ -951,13 +906,10 @@ export default function SettingsScreen() {
routeServerId,
theme,
handleEditDaemon,
setAddConnectionTargetServerId,
setPendingEditReopenServerId,
setIsAddHostMethodVisible,
isAddHostMethodVisible,
isDirectHostVisible,
isPasteLinkVisible,
addConnectionTargetServerId,
closeAddConnectionFlow,
goBackToAddConnectionMethods,
setIsDirectHostVisible,
@@ -977,7 +929,6 @@ export default function SettingsScreen() {
handleSaveEditDaemon,
handleRemoveConnection,
handleRemoveDaemon,
handleAddConnectionFromModal,
restartConfirmationMessage: "This will restart the daemon. The app will reconnect automatically.",
waitForCondition,
isMountedRef,
@@ -1042,7 +993,6 @@ interface HostDetailModalProps {
onSave: (label: string) => void;
onRemoveConnection: (serverId: string, connectionId: string) => Promise<void>;
onRemoveHost: (host: HostProfile) => void;
onAddConnection: () => void;
restartConfirmationMessage: string;
waitForCondition: (
predicate: () => boolean,
@@ -1060,7 +1010,6 @@ function HostDetailModal({
onSave,
onRemoveConnection,
onRemoveHost,
onAddConnection,
restartConfirmationMessage,
waitForCondition,
isScreenMountedRef,
@@ -1291,15 +1240,6 @@ function HostDetailModal({
/>
);
})}
<Button
variant="outline"
size="md"
style={styles.addButton}
textStyle={styles.addButtonText}
onPress={onAddConnection}
>
+ Add connection
</Button>
</View>
</View>
) : null}

View File

@@ -8,6 +8,7 @@ import type { ImageAttachment } from "@/components/message-input";
import { useAgentFormState } from "@/hooks/use-agent-form-state";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { Agent } from "@/stores/session-store";
@@ -110,6 +111,18 @@ export function WorkspaceDraftAgentTab({
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
const {
features: draftFeatures,
featureValues: draftFeatureValues,
setFeatureValue: setDraftFeatureValue,
} = useDraftAgentFeatures({
serverId,
provider: selectedProvider,
cwd: workspaceId,
modeId: selectedMode,
modelId: effectiveDraftModelId,
thinkingOptionId: effectiveDraftThinkingOptionId,
});
const {
formErrorMessage,
@@ -168,6 +181,7 @@ export function WorkspaceDraftAgentTab({
title: "Agent",
cwd: workspaceId,
model,
features: draftFeatures,
thinkingOptionId,
labels: {},
};
@@ -186,6 +200,7 @@ export function WorkspaceDraftAgentTab({
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
};
const imagesData = await encodeImages(images);
@@ -215,8 +230,10 @@ export function WorkspaceDraftAgentTab({
...(effectiveDraftThinkingOptionId
? { thinkingOptionId: effectiveDraftThinkingOptionId }
: {}),
...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}),
};
}, [
draftFeatureValues,
effectiveDraftModelId,
effectiveDraftThinkingOptionId,
modeOptions.length,
@@ -297,6 +314,8 @@ export function WorkspaceDraftAgentTab({
thinkingOptions: availableThinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption: setThinkingOptionFromUser,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
disabled: isSubmitting,
}}
/>

View File

@@ -2,6 +2,7 @@ import { DaemonClient } from "@server/client/daemon-client";
import type { DaemonClientConfig } from "@server/client/daemon-client";
import type { HostConnection } from "@/types/host-connection";
import { getOrCreateClientId } from "./client-id";
import { resolveAppVersion } from "./app-version";
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "./daemon-endpoints";
import {
buildLocalDaemonTransportUrl,
@@ -53,6 +54,7 @@ export async function buildClientConfig(
const base = {
clientId,
clientType: "mobile" as const,
appVersion: resolveAppVersion() ?? undefined,
suppressSendErrors: true,
reconnect: { enabled: false },
...(connection.type === "directSocket" || connection.type === "directPipe"

View File

@@ -51,6 +51,8 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
);
case "plain_text":
return Boolean(detail.label || detail.text);
case "plan":
return detail.text.trim().length > 0;
case "unknown":
return hasMeaningfulUnknownValue(detail.input) || hasMeaningfulUnknownValue(detail.output);
}

View File

@@ -24,6 +24,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
worktree_setup: SquareTerminal,
sub_agent: Bot,
plain_text: Wrench,
plan: Brain,
unknown: Wrench,
};

View File

@@ -1,2 +1,3 @@
#!/usr/bin/env node
#!/usr/bin/env -S node --disable-warning=DEP0040
import '../dist/index.js'

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.45-rc.3",
"version": "0.1.47",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.45-rc.3",
"@getpaseo/server": "0.1.45-rc.3",
"@getpaseo/relay": "0.1.47",
"@getpaseo/server": "0.1.47",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -0,0 +1,188 @@
import { mkdirSync, mkdtempSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { classifyInvocation, isExistingDirectory, isPathLikeArg } from "./classify.js";
const knownCommands = new Set(["ls", "run", "status"]);
describe("classifyInvocation", () => {
it("classifies no args as CLI mode", () => {
expect(
classifyInvocation({
argv: [],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({ kind: "cli", argv: [] });
});
it("classifies flags as CLI mode", () => {
expect(
classifyInvocation({
argv: ["--version"],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({ kind: "cli", argv: ["--version"] });
});
it("classifies known commands as CLI mode", () => {
expect(
classifyInvocation({
argv: ["ls", "--json"],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({ kind: "cli", argv: ["ls", "--json"] });
});
it("classifies '.' as an open-project invocation", () => {
const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-dot-"));
expect(
classifyInvocation({
argv: ["."],
knownCommands,
cwd: projectDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies '..' as an open-project invocation", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-parent-"));
const childDir = path.join(parentDir, "child");
mkdirSync(childDir);
expect(
classifyInvocation({
argv: [".."],
knownCommands,
cwd: childDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: parentDir,
});
});
it("classifies './myproject' as an open-project invocation", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-relative-"));
const projectDir = path.join(parentDir, "myproject");
mkdirSync(projectDir);
expect(
classifyInvocation({
argv: ["./myproject"],
knownCommands,
cwd: parentDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies an absolute path as an open-project invocation", () => {
const projectDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-absolute-"));
expect(
classifyInvocation({
argv: [projectDir],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies a home-relative path as an open-project invocation", () => {
const projectDir = mkdtempSync(path.join(homedir(), "paseo-classify-home-"));
const relativeToHome = `~/${path.basename(projectDir)}`;
expect(
classifyInvocation({
argv: [relativeToHome],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("classifies an existing directory name as an open-project invocation", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-existing-"));
const projectDir = path.join(parentDir, "myproject");
mkdirSync(projectDir);
expect(
classifyInvocation({
argv: ["myproject"],
knownCommands,
cwd: parentDir,
}),
).toEqual({
kind: "open-project",
resolvedPath: projectDir,
});
});
it("keeps known commands in CLI mode even when a matching directory exists", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-command-"));
mkdirSync(path.join(parentDir, "status"));
expect(
classifyInvocation({
argv: ["status"],
knownCommands,
cwd: parentDir,
}),
).toEqual({
kind: "cli",
argv: ["status"],
});
});
it("classifies nonexistent args as CLI mode", () => {
expect(
classifyInvocation({
argv: ["nonexistent"],
knownCommands,
cwd: process.cwd(),
}),
).toEqual({
kind: "cli",
argv: ["nonexistent"],
});
});
});
describe("path helpers", () => {
it("detects path-like prefixes", () => {
expect(isPathLikeArg(".")).toBe(true);
expect(isPathLikeArg("..")).toBe(true);
expect(isPathLikeArg("./project")).toBe(true);
expect(isPathLikeArg("../project")).toBe(true);
expect(isPathLikeArg("/tmp/project")).toBe(true);
expect(isPathLikeArg("~")).toBe(true);
expect(isPathLikeArg("~/project")).toBe(true);
expect(isPathLikeArg("C:\\project")).toBe(true);
expect(isPathLikeArg("status")).toBe(false);
});
it("detects existing directories relative to cwd", () => {
const parentDir = mkdtempSync(path.join(tmpdir(), "paseo-classify-helper-"));
const projectDir = path.join(parentDir, "project");
mkdirSync(projectDir);
expect(isExistingDirectory({ pathArg: "project", cwd: parentDir })).toBe(true);
expect(isExistingDirectory({ pathArg: "missing", cwd: parentDir })).toBe(false);
});
});

View File

@@ -0,0 +1,70 @@
import { existsSync, statSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
export type CliInvocation =
| { kind: "cli"; argv: string[] }
| { kind: "open-project"; resolvedPath: string };
export function isPathLikeArg(arg: string): boolean {
return (
arg === "." ||
arg === ".." ||
arg.startsWith("./") ||
arg.startsWith("../") ||
arg.startsWith("/") ||
arg === "~" ||
arg.startsWith("~/") ||
/^[A-Za-z]:[\\/]/.test(arg)
);
}
export function expandUserPath(inputPath: string): string {
if (inputPath === "~") {
return homedir();
}
if (inputPath.startsWith("~/")) {
return path.join(homedir(), inputPath.slice(2));
}
return inputPath;
}
export function isExistingDirectory(input: { pathArg: string; cwd: string }): boolean {
const resolvedPath = path.resolve(input.cwd, expandUserPath(input.pathArg));
if (!existsSync(resolvedPath)) {
return false;
}
return statSync(resolvedPath).isDirectory();
}
export function classifyInvocation(input: {
argv: string[];
knownCommands: ReadonlySet<string>;
cwd: string;
}): CliInvocation {
const [firstArg] = input.argv;
if (!firstArg) {
return { kind: "cli", argv: input.argv };
}
if (firstArg.startsWith("-")) {
return { kind: "cli", argv: input.argv };
}
if (input.knownCommands.has(firstArg)) {
return { kind: "cli", argv: input.argv };
}
if (isExistingDirectory({ pathArg: firstArg, cwd: input.cwd })) {
return {
kind: "open-project",
resolvedPath: path.resolve(input.cwd, expandUserPath(firstArg)),
};
}
return { kind: "cli", argv: input.argv };
}

View File

@@ -1,48 +1,8 @@
import { existsSync, statSync } from "node:fs";
import { existsSync } from "node:fs";
import { spawn } from "node:child_process";
import { homedir } from "node:os";
import path from "node:path";
const DESKTOP_GUI_FLAG = "--open-project";
export function isPathLikeArg(arg: string): boolean {
return (
arg === "." ||
arg === ".." ||
arg.startsWith("./") ||
arg.startsWith("../") ||
arg.startsWith("/") ||
arg === "~" ||
arg.startsWith("~/") ||
/^[A-Za-z]:[\\/]/.test(arg)
);
}
function expandUserPath(inputPath: string): string {
if (inputPath === "~") {
return homedir();
}
if (inputPath.startsWith("~/")) {
return path.join(homedir(), inputPath.slice(2));
}
return inputPath;
}
function resolveProjectDirectory(inputPath: string): string {
const absolutePath = path.resolve(expandUserPath(inputPath));
if (!existsSync(absolutePath)) {
throw new Error(`Path does not exist: ${absolutePath}`);
}
const stat = statSync(absolutePath);
if (!stat.isDirectory()) {
throw new Error(`Not a directory: ${absolutePath}`);
}
return absolutePath;
}
function findDesktopApp(): string | null {
if (process.platform === "darwin") {
const candidates = [
@@ -88,17 +48,25 @@ function findDesktopApp(): string | null {
return null;
}
function cleanEnvForDesktopLaunch(): NodeJS.ProcessEnv {
const env = { ...process.env };
// The CLI runs via ELECTRON_RUN_AS_NODE=1. On Linux/Windows the spawned
// desktop process inherits the env directly, so we must strip it or the
// desktop app would start as a bare Node process instead of Electron.
delete env.ELECTRON_RUN_AS_NODE;
return env;
}
function spawnDetached(command: string, args: string[]): void {
spawn(command, args, {
detached: true,
stdio: "ignore",
env: cleanEnvForDesktopLaunch(),
}).unref();
}
export async function openDesktopWithProject(pathArg: string): Promise<void> {
export async function openDesktopWithProject(projectPath: string): Promise<void> {
try {
const projectPath = resolveProjectDirectory(pathArg);
if (process.env.PASEO_DESKTOP_CLI === "1") {
throw new Error(
"Cannot open a desktop project while running in desktop CLI passthrough mode.",
@@ -113,11 +81,16 @@ export async function openDesktopWithProject(pathArg: string): Promise<void> {
}
if (process.platform === "darwin") {
spawnDetached("open", ["-a", desktopApp, "--args", DESKTOP_GUI_FLAG, projectPath]);
// -n forces a new instance even if the app is already running.
// The new instance hits requestSingleInstanceLock(), fails, and relays
// the argv to the first instance via the second-instance event.
// -g keeps the terminal in the foreground (better CLI UX).
// Without -n, macOS just activates the existing window and drops --args.
spawnDetached("open", ["-n", "-g", "-a", desktopApp, "--args", projectPath]);
return;
}
spawnDetached(desktopApp, [DESKTOP_GUI_FLAG, projectPath]);
spawnDetached(desktopApp, [projectPath]);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);

View File

@@ -1,14 +1,26 @@
import { createCli } from "./cli.js";
import { isPathLikeArg, openDesktopWithProject } from "./commands/open.js";
import { classifyInvocation } from "./classify.js";
import { openDesktopWithProject } from "./commands/open.js";
const program = createCli();
const knownCommands = new Set(program.commands.map((command) => command.name()));
const firstArg = process.argv[2];
if (firstArg && isPathLikeArg(firstArg)) {
await openDesktopWithProject(firstArg);
} else {
if (process.argv.length <= 2) {
process.argv.push("onboard");
const invocation = classifyInvocation({
argv: process.argv.slice(2),
knownCommands,
cwd: process.cwd(),
});
switch (invocation.kind) {
case "cli": {
const argv = [...process.argv.slice(0, 2), ...invocation.argv];
if (invocation.argv.length === 0) {
argv.push("onboard");
}
program.parse(argv, { from: "node" });
break;
}
program.parse(process.argv, { from: "node" });
case "open-project":
await openDesktopWithProject(invocation.resolvedPath);
break;
}

View File

@@ -1,13 +1,19 @@
#!/usr/bin/env npx zx
import assert from "node:assert/strict";
import { mkdir, mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { isPathLikeArg, openDesktopWithProject } from "../src/commands/open.ts";
import {
classifyInvocation,
isExistingDirectory,
isPathLikeArg,
} from "../src/classify.ts";
import { openDesktopWithProject } from "../src/commands/open.ts";
console.log("📋 Phase 32: Open Project CLI Tests\n");
console.log(" Testing path-like detection exports...");
console.log(" Testing path-like detection exports...");
assert.equal(isPathLikeArg("."), true);
assert.equal(isPathLikeArg("./app"), true);
assert.equal(isPathLikeArg("/tmp/app"), true);
@@ -16,8 +22,34 @@ assert.equal(isPathLikeArg("run"), false);
assert.equal(isPathLikeArg("foo"), false);
console.log(" ✅ path-like detection matches the expected prefixes");
console.log(" Testing nonexistent project path errors...");
const missingProject = join(tmpdir(), "paseo-open-project-missing");
console.log(" Testing existing directory detection and command precedence...");
const existingProject = join(await mkdtemp(join(tmpdir(), "paseo-open-project-")), "project");
await mkdir(existingProject);
const originalCwd = process.cwd();
process.chdir(join(existingProject, ".."));
assert.equal(isExistingDirectory({ pathArg: "project", cwd: process.cwd() }), true);
assert.equal(
classifyInvocation({
argv: ["project"],
knownCommands: new Set(["run", "status"]),
cwd: process.cwd(),
}).kind,
"open-project",
);
assert.equal(
classifyInvocation({
argv: ["run"],
knownCommands: new Set(["run", "status"]),
cwd: process.cwd(),
}).kind,
"cli",
);
process.chdir(originalCwd);
console.log(" ✅ existing directories open as projects, but known commands still win");
console.log(" Testing desktop CLI passthrough guard...");
const originalWrite = process.stderr.write.bind(process.stderr);
const stderrChunks: string[] = [];
process.stderr.write = ((chunk: string | Uint8Array) => {
@@ -27,13 +59,16 @@ process.stderr.write = ((chunk: string | Uint8Array) => {
const previousExitCode = process.exitCode;
process.exitCode = undefined;
const previousDesktopCli = process.env.PASEO_DESKTOP_CLI;
process.env.PASEO_DESKTOP_CLI = "1";
await openDesktopWithProject(missingProject);
await openDesktopWithProject(existingProject);
process.stderr.write = originalWrite;
assert.equal(process.exitCode, 1);
assert.match(stderrChunks.join(""), /Path does not exist:/);
assert.match(stderrChunks.join(""), /desktop CLI passthrough mode/);
process.exitCode = previousExitCode;
console.log(" ✅ nonexistent paths fail with a helpful error");
process.env.PASEO_DESKTOP_CLI = previousDesktopCli;
console.log(" ✅ desktop CLI passthrough mode is rejected");
console.log("\n✅ Phase 32: Open Project CLI Tests PASSED");

View File

@@ -31,30 +31,7 @@ else
exit 1
fi
case "${1:-}" in
./*|../*|.|..|/*|~|~/*)
case "$1" in
~) ARG_PATH="$HOME" ;;
~/*) ARG_PATH="$HOME/${1#\~/}" ;;
*) ARG_PATH="$1" ;;
esac
if RESOLVED_PATH=$(CDPATH= cd -- "$ARG_PATH" 2>/dev/null && pwd); then
:
elif command -v realpath >/dev/null 2>&1; then
RESOLVED_PATH=$(realpath "$ARG_PATH" 2>/dev/null || printf "%s" "$ARG_PATH")
else
RESOLVED_PATH=$ARG_PATH
fi
if [ ! -e "${RESOLVED_PATH}" ]; then
echo "Path does not exist: ${RESOLVED_PATH}" >&2
exit 1
fi
if [ ! -d "${RESOLVED_PATH}" ]; then
echo "Not a directory: ${RESOLVED_PATH}" >&2
exit 1
fi
exec "${APP_EXECUTABLE}" --open-project "${RESOLVED_PATH}"
;;
esac
RUNNER_PATH="${RESOURCES_DIR}/app.asar.unpacked/dist/daemon/node-entrypoint-runner.js"
CLI_ENTRYPOINT="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@"
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" --disable-warning=DEP0040 "${RUNNER_PATH}" node-script "${CLI_ENTRYPOINT}" "$@"

View File

@@ -9,33 +9,6 @@ if not exist "%APP_EXECUTABLE%" (
exit /b 1
)
set "FIRST_ARG=%~1"
if "%FIRST_ARG%"=="." goto :open_project
if "%FIRST_ARG%"==".." goto :open_project
if "%FIRST_ARG:~0,2%"==".\" goto :open_project
if "%FIRST_ARG:~0,2%"=="./" goto :open_project
if "%FIRST_ARG:~0,3%"=="..\" goto :open_project
if "%FIRST_ARG:~0,3%"=="../" goto :open_project
if "%FIRST_ARG:~0,1%"=="\" goto :open_project
if "%FIRST_ARG:~0,1%"=="/" goto :open_project
if "%FIRST_ARG:~1,2%"==":\" goto :open_project
if "%FIRST_ARG:~1,2%"==":/" goto :open_project
goto :cli_mode
:open_project
for %%I in ("%~1") do set "RESOLVED_PATH=%%~fI"
if not exist "%RESOLVED_PATH%" (
echo Path does not exist: %RESOLVED_PATH% 1>&2
exit /b 1
)
if not exist "%RESOLVED_PATH%\NUL" (
echo Not a directory: %RESOLVED_PATH% 1>&2
exit /b 1
)
"%APP_EXECUTABLE%" --open-project "%RESOLVED_PATH%"
exit /b %errorlevel%
:cli_mode
set "ELECTRON_RUN_AS_NODE=1"
"%APP_EXECUTABLE%" "%RESOURCES_DIR%\app.asar\dist\daemon\node-entrypoint-runner.js" bare "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
"%APP_EXECUTABLE%" --disable-warning=DEP0040 "%RESOURCES_DIR%\app.asar.unpacked\dist\daemon\node-entrypoint-runner.js" node-script "%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js" %*
exit /b %errorlevel%

View File

@@ -7,6 +7,8 @@ directories:
output: release
files:
- dist/**/*
asarUnpack:
- dist/daemon/node-entrypoint-runner.js
extraResources:
- from: ../app/dist
to: app-dist

View File

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

View File

@@ -95,15 +95,16 @@ describe("node-entrypoint-launcher", () => {
isPackaged: true,
packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
entrypoint: CLI_ENTRYPOINT,
argvMode: "bare",
argvMode: "node-script",
args: ["ls", "--json"],
baseEnv: { PATH: "/usr/bin" },
}),
).toEqual({
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
args: [
"--disable-warning=DEP0040",
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
"bare",
"node-script",
"/tmp/paseo-cli.js",
"ls",
"--json",
@@ -122,7 +123,7 @@ describe("node-entrypoint-launcher", () => {
isPackaged: false,
packagedRunnerPath: null,
entrypoint: CLI_ENTRYPOINT,
argvMode: "bare",
argvMode: "node-script",
args: ["ls"],
baseEnv: { PATH: "/usr/bin" },
}),
@@ -150,6 +151,7 @@ describe("node-entrypoint-launcher", () => {
).toEqual({
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
args: [
"--disable-warning=DEP0040",
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
"node-script",
"/tmp/paseo-cli.js",

View File

@@ -70,7 +70,7 @@ export function createNodeEntrypointInvocation(
return {
command: input.execPath,
args: [input.packagedRunnerPath, input.argvMode, input.entrypoint.entryPath, ...input.args],
args: ["--disable-warning=DEP0040", input.packagedRunnerPath, input.argvMode, input.entrypoint.entryPath, ...input.args],
env,
};
}

View File

@@ -76,7 +76,7 @@ function resolvePackagedAsarPath(): string {
}
function resolvePackagedNodeEntrypointRunnerPath(): string {
return path.join(resolvePackagedAsarPath(), "dist", "daemon", "node-entrypoint-runner.js");
return path.join(process.resourcesPath, "app.asar.unpacked", "dist", "daemon", "node-entrypoint-runner.js");
}
function assertPathExists(input: { label: string; filePath: string }): string {
@@ -223,7 +223,7 @@ function createCliInvocation(args: string[]): NodeEntrypointInvocation {
const cli = resolveCliEntrypoint();
return createNodeEntrypointInvocation({
entrypoint: cli,
argvMode: "bare",
argvMode: "node-script",
args,
baseEnv: process.env,
});
@@ -304,8 +304,16 @@ export async function runCliJsonCommand(args: string[]): Promise<unknown> {
throw new Error("CLI command did not produce JSON output.");
}
// The stdout may contain non-JSON preamble (e.g. Node deprecation warnings).
// Extract the first valid JSON object or array from the output.
const jsonStart = stdout.search(/[{[]/);
if (jsonStart < 0) {
throw new Error("CLI command output contained no JSON.");
}
const jsonText = stdout.slice(jsonStart);
try {
return JSON.parse(stdout) as unknown;
return JSON.parse(jsonText) as unknown;
} catch (error) {
throw new Error(
`CLI command returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`,

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { resolveCliInstallSourcePath } from "./cli-install-path";
describe("cli-install-path", () => {
it("uses the packaged executable on supported unix platforms", () => {
expect(
resolveCliInstallSourcePath({
platform: "darwin",
isPackaged: true,
executablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
shimPath: "/Applications/Paseo.app/Contents/Resources/bin/paseo",
}),
).toBe("/Applications/Paseo.app/Contents/MacOS/Paseo");
});
it("prefers the original AppImage path on linux", () => {
expect(
resolveCliInstallSourcePath({
platform: "linux",
isPackaged: true,
executablePath: "/tmp/.mount_paseo123/paseo",
shimPath: "/tmp/.mount_paseo123/resources/bin/paseo",
appImagePath: "/home/user/Applications/Paseo.AppImage",
}),
).toBe("/home/user/Applications/Paseo.AppImage");
});
it("falls back to the shim on windows and in development", () => {
expect(
resolveCliInstallSourcePath({
platform: "win32",
isPackaged: true,
executablePath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
shimPath: "C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd",
}),
).toBe("C:\\Users\\user\\AppData\\Local\\Programs\\Paseo\\resources\\bin\\paseo.cmd");
expect(
resolveCliInstallSourcePath({
platform: "linux",
isPackaged: false,
executablePath: "/opt/Paseo/paseo",
shimPath: "/opt/Paseo/resources/bin/paseo",
}),
).toBe("/opt/Paseo/resources/bin/paseo");
});
});

View File

@@ -0,0 +1,24 @@
export function resolveCliInstallSourcePath(input: {
platform: NodeJS.Platform;
isPackaged: boolean;
executablePath: string;
shimPath: string;
appImagePath?: string | null;
}): string {
if (input.platform === "win32") {
return input.shimPath;
}
if (!input.isPackaged) {
return input.shimPath;
}
if (input.platform === "linux") {
const appImagePath = input.appImagePath?.trim();
if (appImagePath) {
return appImagePath;
}
}
return input.executablePath;
}

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import os from "node:os";
import { app } from "electron";
import log from "electron-log/main";
import { resolveCliInstallSourcePath } from "./cli-install-path.js";
// ---------------------------------------------------------------------------
// Types
@@ -184,6 +185,13 @@ async function ensurePathInShellRc(): Promise<{ shellUpdated: boolean }> {
export async function installCli(): Promise<InstallStatus> {
const targetPath = getCliTargetPath();
const shimPath = getBundledCliShimPath();
const installSourcePath = resolveCliInstallSourcePath({
platform: process.platform,
isPackaged: app.isPackaged,
executablePath: app.getPath("exe"),
shimPath,
appImagePath: process.env.APPIMAGE,
});
const binDir = getLocalBinDir();
await fs.mkdir(binDir, { recursive: true });
@@ -192,12 +200,12 @@ export async function installCli(): Promise<InstallStatus> {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.copyFile(shimPath, targetPath);
await fs.copyFile(installSourcePath, targetPath);
} else {
if (await pathOrSymlinkExists(targetPath)) {
await fs.unlink(targetPath);
}
await fs.symlink(shimPath, targetPath);
await fs.symlink(installSourcePath, targetPath);
}
const { shellUpdated } = await ensurePathInShellRc();

View File

@@ -4,7 +4,7 @@ log.initialize({ spyRendererConsole: true });
import path from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { app, BrowserWindow, nativeImage, net, protocol } from "electron";
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
import { registerDaemonManager } from "./daemon/daemon-manager.js";
import {
parseCliPassthroughArgsFromArgv,
@@ -27,35 +27,30 @@ import {
} from "./features/notifications.js";
import { registerOpenerHandlers } from "./features/opener.js";
import { setupApplicationMenu } from "./features/menu.js";
import { parseOpenProjectPathFromArgv } from "./open-project-routing.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo";
const OPEN_PROJECT_EVENT = "paseo:event:open-project";
const OPEN_PROJECT_FLAG = "--open-project";
const OPEN_PROJECT_IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
app.setName("Paseo");
function parseOpenProjectPath(argv: string[]): string | null {
const startIndex = process.defaultApp ? 2 : 1;
let pendingOpenProjectPath = parseOpenProjectPathFromArgv({
argv: process.argv,
isDefaultApp: process.defaultApp,
});
for (let index = startIndex; index < argv.length; index += 1) {
const arg = argv[index];
if (OPEN_PROJECT_IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix))) {
continue;
}
log.info("[open-project] argv:", process.argv);
log.info("[open-project] isDefaultApp:", process.defaultApp);
log.info("[open-project] pendingOpenProjectPath:", pendingOpenProjectPath);
if (arg !== OPEN_PROJECT_FLAG) {
return null;
}
const pathArg = argv[index + 1];
return pathArg ? pathArg : null;
}
return null;
}
const pendingOpenProjectPath = parseOpenProjectPath(process.argv);
// The renderer pulls the pending path on mount via IPC — this avoids
// a race where the push event arrives before React registers its listener.
ipcMain.handle("paseo:get-pending-open-project", () => {
log.info("[open-project] renderer requested pending path:", pendingOpenProjectPath);
const result = pendingOpenProjectPath;
pendingOpenProjectPath = null;
return result;
});
protocol.registerSchemesAsPrivileged([
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
@@ -153,10 +148,12 @@ async function createMainWindow(): Promise<void> {
function sendOpenProjectEvent(win: BrowserWindow, projectPath: string): void {
const send = () => {
log.info("[open-project] sending event to renderer:", projectPath);
win.webContents.send(OPEN_PROJECT_EVENT, { path: projectPath });
};
if (win.webContents.isLoadingMainFrame()) {
log.info("[open-project] waiting for did-finish-load before sending event");
win.webContents.once("did-finish-load", send);
return;
}
@@ -176,7 +173,12 @@ function setupSingleInstanceLock(): boolean {
}
app.on("second-instance", (_event, commandLine) => {
const openProjectPath = parseOpenProjectPath(commandLine);
log.info("[open-project] second-instance commandLine:", commandLine);
const openProjectPath = parseOpenProjectPathFromArgv({
argv: commandLine,
isDefaultApp: false,
});
log.info("[open-project] second-instance openProjectPath:", openProjectPath);
const win = BrowserWindow.getAllWindows()[0];
if (win) {
win.show();
@@ -256,10 +258,6 @@ async function bootstrap(): Promise<void> {
registerNotificationHandlers();
registerOpenerHandlers();
await createMainWindow();
const mainWindow = BrowserWindow.getAllWindows()[0];
if (mainWindow && pendingOpenProjectPath) {
sendOpenProjectEvent(mainWindow, pendingOpenProjectPath);
}
app.on("activate", async () => {
if (BrowserWindow.getAllWindows().length === 0) {

View File

@@ -0,0 +1,69 @@
import { mkdtempSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parseOpenProjectPathFromArgv } from "./open-project-routing";
describe("open-project-routing", () => {
it("returns a bare absolute path argument", () => {
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
expect(
parseOpenProjectPathFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", projectPath],
isDefaultApp: false,
}),
).toBe(projectPath);
});
it("finds a bare absolute path even when Chromium noise args appear first", () => {
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
expect(
parseOpenProjectPathFromArgv({
argv: [
"/Applications/Paseo.app/Contents/MacOS/Paseo",
"--allow-file-access-from-files",
"--no-sandbox",
projectPath,
],
isDefaultApp: false,
}),
).toBe(projectPath);
});
it("does not treat flags as project paths", () => {
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
const flagLikeDirectory = path.join(projectPath, "--version");
mkdirSync(flagLikeDirectory);
expect(
parseOpenProjectPathFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version", flagLikeDirectory],
isDefaultApp: false,
}),
).toBe(flagLikeDirectory);
expect(
parseOpenProjectPathFromArgv({
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"],
isDefaultApp: false,
}),
).toBeNull();
});
it("returns the path from an explicit --open-project flag for backward compatibility", () => {
const projectPath = mkdtempSync(path.join(tmpdir(), "paseo-open-project-"));
expect(
parseOpenProjectPathFromArgv({
argv: [
"/Applications/Paseo.app/Contents/MacOS/Paseo",
"--open-project",
projectPath,
],
isDefaultApp: false,
}),
).toBe(projectPath);
});
});

View File

@@ -0,0 +1,45 @@
import { existsSync, statSync } from "node:fs";
import path from "node:path";
const OPEN_PROJECT_FLAG = "--open-project";
const OPEN_PROJECT_IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
function isExistingDirectoryAbsolutePath(candidate: string): boolean {
if (!path.isAbsolute(candidate) || !existsSync(candidate)) {
return false;
}
try {
return statSync(candidate).isDirectory();
} catch {
return false;
}
}
export function parseOpenProjectPathFromArgv(input: {
argv: string[];
isDefaultApp: boolean;
}): string | null {
const effectiveArgs = input.argv
.slice(input.isDefaultApp ? 2 : 1)
.filter(
(arg) => !OPEN_PROJECT_IGNORED_ARG_PREFIXES.some((prefix) => arg.startsWith(prefix)),
);
const positionalProjectPath = effectiveArgs.find(
(arg) => !arg.startsWith("-") && isExistingDirectoryAbsolutePath(arg),
);
if (positionalProjectPath) {
return positionalProjectPath;
}
const openProjectIndex = effectiveArgs.indexOf(OPEN_PROJECT_FLAG);
if (openProjectIndex === -1) {
return null;
}
const flaggedProjectPath = effectiveArgs[openProjectIndex + 1];
return flaggedProjectPath && isExistingDirectoryAbsolutePath(flaggedProjectPath)
? flaggedProjectPath
: null;
}

View File

@@ -6,6 +6,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
platform: process.platform,
invoke: (command: string, args?: Record<string, unknown>) =>
ipcRenderer.invoke("paseo:invoke", command, args),
getPendingOpenProject: () =>
ipcRenderer.invoke("paseo:get-pending-open-project") as Promise<string | null>,
events: {
on: (event: string, handler: EventHandler): Promise<() => void> => {
const listener = (_ipcEvent: Electron.IpcRendererEvent, payload: unknown) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ import type {
OpenProjectResponseMessage,
ArchiveWorkspaceResponseMessage,
ListCommandsResponse,
ListProviderFeaturesResponseMessage,
ListProviderModelsResponseMessage,
ListProviderModesResponseMessage,
ListAvailableProvidersResponse,
@@ -154,6 +155,7 @@ export type DaemonClientConfig = {
url: string;
clientId: string;
clientType?: "mobile" | "browser" | "cli" | "mcp";
appVersion?: string;
runtimeGeneration?: number | null;
authHeader?: string;
suppressSendErrors?: boolean;
@@ -216,13 +218,14 @@ type CreatePaseoWorktreePayload = Extract<
>["payload"];
type FileExplorerPayload = FileExplorerResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
>;
type ListCommandsOptions = {
requestId?: string;
@@ -2521,6 +2524,21 @@ export class DaemonClient {
});
}
async listProviderFeatures(
draftConfig: ListCommandsDraftConfig,
options?: { requestId?: string },
): Promise<ListProviderFeaturesPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "list_provider_features_request",
draftConfig,
},
responseType: "list_provider_features_response",
timeout: 45000,
});
}
async listAvailableProviders(options?: {
requestId?: string;
}): Promise<ListAvailableProvidersPayload> {
@@ -3268,6 +3286,7 @@ export class DaemonClient {
clientId: this.config.clientId,
clientType: this.config.clientType ?? "cli",
protocolVersion: 1,
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
}),
);
} catch (error) {

View File

@@ -550,6 +550,31 @@ export class AgentManager {
}
}
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
const normalizedConfig = await this.normalizeConfig(config);
const client = this.requireClient(normalizedConfig.provider);
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const session = await client.createSession(normalizedConfig);
try {
return session.features ?? [];
} finally {
try {
await session.close();
} catch (error) {
this.logger.warn(
{ err: error, provider: normalizedConfig.provider },
"Failed to close draft feature listing session",
);
}
}
}
getAgent(id: string): ManagedAgent | null {
const agent = this.agents.get(id);
return agent ? { ...agent } : null;

View File

@@ -69,6 +69,7 @@ export type AgentFeatureToggle = {
id: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
value: boolean;
};
@@ -78,6 +79,7 @@ export type AgentFeatureSelect = {
id: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
value: string | null;
options: AgentSelectOption[];
@@ -222,6 +224,10 @@ export type ToolCallDetail =
text?: string;
icon?: ToolCallIconName;
}
| {
type: "plan";
text: string;
}
| {
type: "unknown";
input: unknown | null;

View File

@@ -59,18 +59,10 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [
];
const CODEX_MODES: AgentProviderModeDefinition[] = [
{
id: "read-only",
label: "Read Only",
description:
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
icon: "ShieldCheck",
colorTier: "safe",
},
{
id: "auto",
label: "Auto",
description: "Edit files and run commands but still request approval before escalating scope.",
label: "Default Permissions",
description: "Edit files and run commands with Codex's default approval flow.",
icon: "ShieldAlert",
colorTier: "moderate",
},
@@ -145,7 +137,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
modes: CODEX_MODES,
voice: {
enabled: true,
defaultModeId: "read-only",
defaultModeId: "auto",
defaultModel: "gpt-5.1-codex-mini",
},
},
@@ -184,12 +176,9 @@ export function getAgentProviderDefinition(provider: string): AgentProviderDefin
return definition;
}
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id) as [
string,
...string[],
];
export const AGENT_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS);
export const AgentProviderSchema = z.string();
export function isValidAgentProvider(value: string): boolean {
return AGENT_PROVIDER_IDS.includes(value);

View File

@@ -8,7 +8,7 @@ import {
deriveModesFromACP,
mapACPUsage,
} from "./acp-agent.js";
import { transformPiModels, transformPiSessionResponse } from "./pi-acp-agent.js";
import { transformPiModels, transformPiSessionResponse, wrapPiSession } from "./pi-acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
function createSession(): ACPAgentSession {
@@ -526,6 +526,84 @@ describe("ACPAgentSession", () => {
]);
});
test("Pi session wrapper hides synthetic modes and exposes them as thinking", async () => {
const wrapped = wrapPiSession(
{
provider: "pi",
id: "session-1",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
features: [
{
type: "select",
id: "thought_level",
label: "Thinking",
value: "medium",
options: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
],
run: vi.fn(),
startTurn: vi.fn(),
subscribe: vi.fn(() => () => {}),
streamHistory: async function* () {},
getRuntimeInfo: vi.fn(async () => ({
provider: "pi",
sessionId: "session-1",
model: "gpt-4.1-mini",
thinkingOptionId: null,
modeId: "xhigh",
})),
getAvailableModes: vi.fn(async () => [
{ id: "xhigh", label: "xhigh" },
]),
getCurrentMode: vi.fn(async () => "xhigh"),
setMode: vi.fn(),
getPendingPermissions: vi.fn(() => []),
respondToPermission: vi.fn(),
describePersistence: vi.fn(() => null),
interrupt: vi.fn(),
close: vi.fn(),
setThinkingOption: vi.fn(),
},
{
provider: "pi",
cwd: "/tmp/paseo-acp-test",
thinkingOptionId: "medium",
},
);
await expect(wrapped.getAvailableModes()).resolves.toEqual([]);
await expect(wrapped.getCurrentMode()).resolves.toBeNull();
await expect(wrapped.getRuntimeInfo()).resolves.toEqual({
provider: "pi",
sessionId: "session-1",
model: "gpt-4.1-mini",
thinkingOptionId: "xhigh",
modeId: null,
});
expect(wrapped.features).toEqual([
{
type: "select",
id: "thought_level",
label: "Thinking",
value: "xhigh",
options: [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
],
},
]);
});
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
const session = createSession();
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];

View File

@@ -35,6 +35,7 @@ describe("Codex app-server provider (e2e)", () => {
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
});
expect(session.features?.some((feature) => feature.id === "plan_mode")).toBe(true);
const result = await session.run("Say hello in one sentence.");
expect(result.finalText.length).toBeGreaterThan(0);

View File

@@ -5,6 +5,18 @@ import { __codexAppServerInternals } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const CODEX_PROVIDER = "codex";
const TEST_COLLABORATION_MODES = [
{
name: "Code",
mode: "code",
developer_instructions: "Built-in code mode",
},
{
name: "Plan",
mode: "plan",
developer_instructions: "Built-in plan mode",
},
];
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return {
@@ -28,11 +40,13 @@ function createSession(configOverrides: Partial<AgentSessionConfig> = {}) {
) as unknown as AgentSession & { [key: string]: unknown };
session.connected = true;
session.currentThreadId = "test-thread";
session.collaborationModes = TEST_COLLABORATION_MODES;
session.refreshResolvedCollaborationMode();
return session;
}
describe("Codex app-server provider fast mode", () => {
test("features returns fast_mode toggle when model supports it", async () => {
describe("Codex app-server provider features", () => {
test("features returns fast and plan toggles when supported", async () => {
const session = createSession();
expect(session.features).toEqual([
@@ -41,12 +55,23 @@ describe("Codex app-server provider fast mode", () => {
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: false,
},
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
await session.setFeature?.("fast_mode", true);
await session.setFeature?.("plan_mode", true);
expect(session.features).toEqual([
{
@@ -54,16 +79,36 @@ describe("Codex app-server provider fast mode", () => {
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: true,
},
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]);
});
test("features returns empty array when model does not support fast mode", () => {
test("features returns only plan toggle when model does not support fast mode", () => {
const session = createSession({ model: "gpt-3.5-turbo" });
expect(session.features).toEqual([]);
expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
});
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
@@ -103,21 +148,32 @@ describe("Codex app-server provider fast mode", () => {
);
});
test("constructor restores serviceTier from config.featureValues", () => {
test("constructor restores feature flags from config.featureValues", () => {
const session = createSession({
featureValues: { fast_mode: true },
featureValues: { fast_mode: true, plan_mode: true },
});
expect((session as any).serviceTier).toBe("fast");
expect((session as any).planModeEnabled).toBe(true);
expect(session.features).toEqual([
{
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: true,
},
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]);
});
@@ -158,7 +214,17 @@ describe("Codex app-server provider fast mode", () => {
await session.setFeature?.("fast_mode", true);
await session.setModel("gpt-3.5-turbo");
expect(session.features).toEqual([]);
expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
expect((session as any).serviceTier).toBeNull();
await session.startTurn("hello");
@@ -171,4 +237,29 @@ describe("Codex app-server provider fast mode", () => {
expect.any(Number),
);
});
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
const session = createSession();
const request = vi.fn().mockResolvedValue(undefined);
(session as any).client = { request };
(session as any).connected = true;
(session as any).currentThreadId = "thread-123";
(session as any).ensureThreadLoaded = vi.fn().mockResolvedValue(undefined);
(session as any).ensureThread = vi.fn().mockResolvedValue(undefined);
(session as any).buildUserInput = vi.fn().mockResolvedValue([{ type: "text", text: "hi" }]);
(session as any).resolveSlashCommandInvocation = vi.fn().mockResolvedValue(null);
await session.setFeature?.("plan_mode", true);
await session.startTurn("hello");
expect(request).toHaveBeenCalledWith(
"turn/start",
expect.objectContaining({
collaborationMode: expect.objectContaining({
mode: "plan",
}),
}),
expect.any(Number),
);
});
});

View File

@@ -55,6 +55,25 @@ describe("Codex app-server provider", () => {
}
});
test("maps Codex plan markdown to a synthetic plan tool call", () => {
const item = __codexAppServerInternals.mapCodexPlanToToolCall({
callId: "plan-turn-1",
text: "### Login Screen\n- Build layout\n- Add validation",
});
expect(item).toEqual({
type: "tool_call",
callId: "plan-turn-1",
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: "### Login Screen\n- Build layout\n- Add validation",
},
});
});
test("maps patch notifications with object-style single change payloads", () => {
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
callId: "patch-object-single",

View File

@@ -2,7 +2,6 @@ import type {
AgentCapabilityFlags,
AgentClient,
AgentFeature,
AgentFeatureToggle,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
@@ -52,6 +51,7 @@ import {
quoteWindowsCommand,
} from "../../../utils/executable.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const TURN_START_TIMEOUT_MS = 90 * 1000;
@@ -68,16 +68,10 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
};
const CODEX_MODES: AgentMode[] = [
{
id: "read-only",
label: "Read Only",
description:
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
},
{
id: "auto",
label: "Auto",
description: "Edit files and run commands but still request approval before escalating scope.",
label: "Default Permissions",
description: "Edit files and run commands with Codex's default approval flow.",
},
{
id: "full-access",
@@ -87,20 +81,6 @@ const CODEX_MODES: AgentMode[] = [
];
const DEFAULT_CODEX_MODE_ID = "auto";
const CODEX_FAST_MODE_SUPPORTED_MODE_PREFIXES = [
"gpt-5",
"gpt-4.1",
"o3",
"o4-mini",
] as const;
const CODEX_FAST_MODE_FEATURE: AgentFeatureToggle = {
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
icon: "zap",
value: false,
};
const MODE_PRESETS: Record<
string,
@@ -156,16 +136,6 @@ function normalizeCodexModelLabel(displayName: string): string {
return displayName.replace(/\bgpt\b/gi, "GPT");
}
function codexModelSupportsFastMode(modelId: string | null | undefined): boolean {
const normalizedModelId = normalizeCodexModelId(modelId);
if (!normalizedModelId) {
return false;
}
return CODEX_FAST_MODE_SUPPORTED_MODE_PREFIXES.some(
(prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix),
);
}
type CodexConfiguredDefaults = {
model?: string;
thinkingOptionId?: string;
@@ -714,28 +684,43 @@ function extractUserText(content: unknown): string | null {
return parts.length > 0 ? parts.join("\n") : null;
}
function parsePlanTextToTodoItems(text: string): { text: string; completed: boolean }[] {
const lines = text
function normalizePlanMarkdown(text: string): string {
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (lines.length === 0) {
return [{ text, completed: false }];
}
return lines.map((line) => ({
text: line.replace(/^[-*]\s+/, ""),
completed: false,
}));
.map((line) => line.replace(/\s+$/, ""))
.join("\n")
.trim();
}
function planStepsToTodoItems(steps: Array<{ step: string; status: string }>): {
text: string;
completed: boolean;
}[] {
return steps.map((entry) => ({
text: entry.step,
completed: entry.status === "completed",
}));
function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string {
const lines = steps
.map((entry) => entry.step.trim())
.filter((step) => step.length > 0)
.map((step) => {
if (/^(#{1,6}\s|[-*+]\s|\d+\.\s)/.test(step)) {
return step;
}
return `- ${step}`;
});
return normalizePlanMarkdown(lines.join("\n"));
}
function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolCallTimelineItem | null {
const text = normalizePlanMarkdown(params.text);
if (!text) {
return null;
}
return {
type: "tool_call",
callId: params.callId,
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text,
},
};
}
type CodexPatchFileChange = {
@@ -1120,9 +1105,12 @@ function threadItemToTimeline(
return { type: "assistant_message", text: normalizedItem.text ?? "" };
}
case "plan": {
const text = normalizedItem.text ?? "";
const items = parsePlanTextToTodoItems(text);
return { type: "todo", items };
return mapCodexPlanToToolCall({
callId:
nonEmptyString(normalizedItem.id ?? normalizedItem.itemId ?? undefined) ??
`plan:${normalizePlanMarkdown(normalizedItem.text ?? "")}`,
text: normalizedItem.text ?? "",
});
}
case "reasoning": {
const summary = Array.isArray(normalizedItem.summary)
@@ -2045,6 +2033,22 @@ function buildCodexAppServerEnv(
};
}
function buildCodexAppServerInitializeParams(): {
clientInfo: { name: string; title: string; version: string };
capabilities: { experimentalApi: true };
} {
return {
clientInfo: {
name: "paseo",
title: "Paseo",
version: "0.0.0",
},
capabilities: {
experimentalApi: true,
},
};
}
class CodexAppServerAgentSession implements AgentSession {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
@@ -2060,6 +2064,7 @@ class CodexAppServerAgentSession implements AgentSession {
private activeForegroundTurnId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private serviceTier: "fast" | null = null;
private planModeEnabled = false;
private historyPending = false;
private persistedHistory: AgentTimelineItem[] = [];
private pendingPermissions = new Map<string, AgentPermissionRequest>();
@@ -2119,6 +2124,9 @@ class CodexAppServerAgentSession implements AgentSession {
if (this.config.featureValues?.fast_mode) {
this.serviceTier = "fast";
}
if (this.config.featureValues?.plan_mode) {
this.planModeEnabled = true;
}
if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId;
@@ -2131,10 +2139,12 @@ class CodexAppServerAgentSession implements AgentSession {
}
get features(): AgentFeature[] {
if (!codexModelSupportsFastMode(this.config.model)) {
return [];
}
return [{ ...CODEX_FAST_MODE_FEATURE, value: this.serviceTier === "fast" }];
return buildCodexFeatures({
modelId: this.config.model,
fastModeEnabled: this.serviceTier === "fast",
planModeEnabled: this.planModeEnabled,
planModeAvailable: this.hasPlanCollaborationMode(),
});
}
async connect(): Promise<void> {
@@ -2144,13 +2154,7 @@ class CodexAppServerAgentSession implements AgentSession {
this.client.setNotificationHandler((method, params) => this.handleNotification(method, params));
this.registerRequestHandlers();
await this.client.request("initialize", {
clientInfo: {
name: "paseo",
title: "Paseo",
version: "0.0.0",
},
});
await this.client.request("initialize", buildCodexAppServerInitializeParams());
this.client.notify("initialized", {});
await this.loadCollaborationModes();
@@ -2182,7 +2186,7 @@ class CodexAppServerAgentSession implements AgentSession {
this.logger.trace({ error }, "Failed to load collaboration modes");
this.collaborationModes = [];
}
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
this.refreshResolvedCollaborationMode();
}
private async loadSkills(): Promise<void> {
@@ -2211,23 +2215,46 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
private resolveCollaborationMode(
modeId: string,
): { mode: string; settings: Record<string, unknown>; name: string } | null {
private findCollaborationMode(
target: "code" | "plan",
): {
name: string;
mode?: string | null;
model?: string | null;
reasoning_effort?: string | null;
developer_instructions?: string | null;
} | null {
if (this.collaborationModes.length === 0) return null;
const normalized = modeId.toLowerCase();
const findByName = (predicate: (name: string) => boolean) =>
this.collaborationModes.find((entry) => predicate(entry.name.toLowerCase()));
let match =
normalized === "read-only"
? findByName((name) => name.includes("read") || name.includes("plan"))
: normalized === "full-access"
? findByName((name) => name.includes("full") || name.includes("exec"))
: findByName((name) => name.includes("auto") || name.includes("code"));
if (!match) {
match = this.collaborationModes[0] ?? null;
if (target === "plan") {
return findByName((name) => name.includes("plan") || name.includes("read")) ?? null;
}
return (
findByName((name) => name.includes("auto") || name.includes("code")) ??
this.collaborationModes.find((entry) => {
const name = entry.name.toLowerCase();
return !name.includes("plan") && !name.includes("read");
}) ??
this.collaborationModes[0] ??
null
);
}
private hasPlanCollaborationMode(): boolean {
return this.findCollaborationMode("plan") !== null;
}
private resolveCollaborationMode(): {
mode: string;
settings: Record<string, unknown>;
name: string;
} | null {
const match = this.findCollaborationMode(this.planModeEnabled ? "plan" : "code");
if (!match) return null;
const settings: Record<string, unknown> = {};
if (match.model) settings.model = match.model;
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
@@ -2244,6 +2271,10 @@ class CodexAppServerAgentSession implements AgentSession {
return { mode: match.mode ?? "code", settings, name: match.name };
}
private refreshResolvedCollaborationMode(): void {
this.resolvedCollaborationMode = this.resolveCollaborationMode();
}
private registerRequestHandlers(): void {
if (!this.client) return;
@@ -2425,6 +2456,8 @@ class CodexAppServerAgentSession implements AgentSession {
timeline.push(event.item);
if (event.item.type === "assistant_message") {
finalText = event.item.text;
} else if (event.item.type === "tool_call" && event.item.detail.type === "plan") {
finalText = event.item.detail.text;
}
return;
}
@@ -2615,7 +2648,6 @@ class CodexAppServerAgentSession implements AgentSession {
async setMode(modeId: string): Promise<void> {
validateCodexMode(modeId);
this.currentMode = modeId;
this.resolvedCollaborationMode = this.resolveCollaborationMode(modeId);
this.cachedRuntimeInfo = null;
}
@@ -2624,13 +2656,13 @@ class CodexAppServerAgentSession implements AgentSession {
if (!codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = null;
}
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId);
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
@@ -2640,6 +2672,12 @@ class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
return;
}
if (featureId === "plan_mode") {
this.planModeEnabled = Boolean(value);
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
return;
}
throw new Error(`Unknown Codex feature: ${featureId}`);
}
@@ -2966,17 +3004,22 @@ class CodexAppServerAgentSession implements AgentSession {
}
if (parsed.kind === "plan_updated") {
const items = planStepsToTodoItems(
parsed.plan.map((entry) => ({
step: entry.step ?? "",
status: entry.status ?? "pending",
})),
);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "todo", items },
const timelineItem = mapCodexPlanToToolCall({
callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`,
text: planStepsToMarkdown(
parsed.plan.map((entry) => ({
step: entry.step ?? "",
status: entry.status ?? "pending",
})),
),
});
if (timelineItem) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: timelineItem,
});
}
return;
}
@@ -3520,9 +3563,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const client = new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", {
clientInfo: { name: "paseo", title: "Paseo", version: "0.0.0" },
});
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
const limit = options?.limit ?? 20;
@@ -3592,13 +3633,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const client = new CodexAppServerClient(child, this.logger);
try {
await client.request("initialize", {
clientInfo: {
name: "paseo",
title: "Paseo",
version: "0.0.0",
},
});
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
const response = (await client.request("model/list", {})) as { data?: Array<any> };
@@ -3689,4 +3724,7 @@ export const __codexAppServerInternals = {
codexModelSupportsFastMode,
CodexAppServerAgentSession,
mapCodexPatchNotificationToToolCall,
planStepsToMarkdown,
mapCodexPlanToToolCall,
threadItemToTimeline,
};

View File

@@ -0,0 +1,66 @@
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = [
"gpt-5",
"gpt-4.1",
"o3",
"o4-mini",
] as const;
export const CODEX_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
tooltip: "Toggle fast mode",
icon: "zap",
};
export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
};
function normalizeCodexModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
return normalized.length > 0 ? normalized : null;
}
export function codexModelSupportsFastMode(modelId: string | null | undefined): boolean {
const normalizedModelId = normalizeCodexModelId(modelId);
if (!normalizedModelId) {
return false;
}
return CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES.some(
(prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix),
);
}
export function buildCodexFeatures(input: {
modelId: string | null | undefined;
fastModeEnabled: boolean;
planModeEnabled: boolean;
planModeAvailable?: boolean;
}): AgentFeature[] {
const features: AgentFeature[] = [];
if (codexModelSupportsFastMode(input.modelId)) {
features.push({
...CODEX_FAST_MODE_FEATURE,
value: input.fastModeEnabled,
});
}
if (input.planModeAvailable !== false) {
features.push({
...CODEX_PLAN_MODE_FEATURE,
value: input.planModeEnabled,
});
}
return features;
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "codex-plan-mode-real-"));
}
describe("Codex app-server provider (real) plan mode", () => {
test.runIf(isProviderAvailable("codex"))(
"maps gpt-5.4 markdown plans to a plan tool call instead of todo items",
async () => {
const cwd = tmpCwd();
const client = new CodexAppServerAgentClient(createTestLogger());
try {
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "medium",
});
try {
await session.setFeature?.("plan_mode", true);
const result = await session.run(
"You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.",
);
expect(result.timeline).not.toContainEqual(
expect.objectContaining({
type: "todo",
}),
);
const planCall = result.timeline.find(
(item) => item.type === "tool_call" && item.detail.type === "plan",
);
expect(planCall).toBeDefined();
if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") {
throw new Error("Expected a plan tool call");
}
expect(planCall.detail.text).toContain("Login");
expect(planCall.detail.text).toContain("- ");
expect(result.finalText).toBe(planCall.detail.text);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
240_000,
);
});

View File

@@ -9,7 +9,25 @@ import type {
ToolKind,
} from "@agentclientprotocol/sdk";
import type { AgentCapabilityFlags, AgentModelDefinition } from "../agent-sdk-types.js";
import type {
AgentLaunchContext,
AgentCapabilityFlags,
AgentFeature,
AgentMode,
AgentModelDefinition,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentFeatureSelect,
} from "../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
import { isCommandAvailable } from "../../../utils/executable.js";
import {
@@ -108,6 +126,152 @@ export function transformPiSessionResponse(
};
}
function isThoughtLevelFeature(feature: AgentFeature): feature is AgentFeatureSelect {
return feature.type === "select" && feature.id === "thought_level";
}
function normalizePiFeatures(
features: AgentFeature[] | undefined,
thinkingOptionId: string | null | undefined,
): AgentFeature[] | undefined {
if (!features) {
return features;
}
return features.map((feature) => {
if (!isThoughtLevelFeature(feature)) {
return feature;
}
return {
...feature,
value: thinkingOptionId ?? feature.value,
};
});
}
class PiACPAgentSession implements AgentSession {
readonly provider: AgentSession["provider"];
readonly capabilities: AgentSession["capabilities"];
get id(): string | null {
return this.inner.id;
}
get features(): AgentFeature[] | undefined {
return normalizePiFeatures(this.inner.features, this.thinkingOptionId);
}
private thinkingOptionId: string | null;
constructor(
private readonly inner: AgentSession,
config: AgentSessionConfig,
) {
this.provider = inner.provider;
this.capabilities = inner.capabilities;
this.thinkingOptionId = config.thinkingOptionId ?? null;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
return this.inner.run(prompt, options);
}
async startTurn(
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
return this.inner.startTurn(prompt, options);
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
return this.inner.subscribe(callback);
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield* this.inner.streamHistory();
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
const runtimeInfo = await this.inner.getRuntimeInfo();
const thinkingOptionId =
runtimeInfo.modeId ?? runtimeInfo.thinkingOptionId ?? this.thinkingOptionId;
this.thinkingOptionId = thinkingOptionId ?? null;
return {
...runtimeInfo,
modeId: null,
thinkingOptionId: thinkingOptionId ?? null,
};
}
async getAvailableModes(): Promise<AgentMode[]> {
return [];
}
async getCurrentMode(): Promise<string | null> {
return null;
}
async setMode(modeId: string): Promise<void> {
void modeId;
throw new Error("Pi does not expose selectable modes");
}
getPendingPermissions(): AgentPermissionRequest[] {
return this.inner.getPendingPermissions();
}
async respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
await this.inner.respondToPermission(requestId, response);
}
describePersistence(): AgentPersistenceHandle | null {
return this.inner.describePersistence();
}
async interrupt(): Promise<void> {
await this.inner.interrupt();
}
async close(): Promise<void> {
await this.inner.close();
}
async listCommands(): Promise<AgentSlashCommand[]> {
return this.inner.listCommands ? this.inner.listCommands() : [];
}
async setModel(modelId: string | null): Promise<void> {
if (this.inner.setModel) {
await this.inner.setModel(modelId);
}
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.thinkingOptionId = thinkingOptionId ?? null;
if (this.inner.setThinkingOption) {
await this.inner.setThinkingOption(thinkingOptionId);
}
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (!this.inner.setFeature) {
throw new Error("Agent session does not support setting features");
}
await this.inner.setFeature(featureId, value);
}
}
export function wrapPiSession(
session: AgentSession,
config: Pick<AgentSessionConfig, "provider" | "cwd" | "thinkingOptionId">,
): AgentSession {
return new PiACPAgentSession(session, config);
}
export class PiACPAgentClient extends ACPAgentClient {
constructor(options: PiACPAgentClientOptions) {
super({
@@ -130,6 +294,27 @@ export class PiACPAgentClient extends ACPAgentClient {
});
}
override async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const session = await super.createSession(config, launchContext);
return wrapPiSession(session, config);
}
override async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const session = await super.resumeSession(handle, overrides, launchContext);
return wrapPiSession(session, {
provider: "pi",
cwd: overrides?.cwd ?? process.cwd(),
thinkingOptionId: overrides?.thinkingOptionId,
});
}
override async isAvailable(): Promise<boolean> {
if (!existsSync(resolvedPiAcpPath)) {
return false;

View File

@@ -590,6 +590,7 @@ export async function createPaseoDaemon(
command: voiceMcpBridgeCommand.command,
baseArgs: [...voiceMcpBridgeCommand.baseArgs],
env: {
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: config.paseoHome,
},
},

View File

@@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import { AgentProviderRuntimeSettingsMapSchema } from "./agent/provider-launch-config.js";
const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
@@ -82,7 +82,7 @@ const FeatureVoiceModeSchema = z
enabled: z.boolean().optional(),
llm: z
.object({
provider: z.enum(AGENT_PROVIDER_IDS as [string, ...string[]]).optional(),
provider: z.string().optional(),
model: z.string().min(1).optional(),
})
.strict()

View File

@@ -190,6 +190,28 @@ const execFileAsync = promisify(execFile);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings.
// Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject
// 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";
function clientSupportsAllProviders(appVersion: string | null): 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);
for (let i = 0; i < minParts.length; i++) {
const a = parts[i] ?? 0;
const b = minParts[i] ?? 0;
if (a > b) return true;
if (a < b) return false;
}
return true;
}
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
@@ -355,6 +377,7 @@ type VoiceTranscriptionResultPayload = {
export type SessionOptions = {
clientId: string;
appVersion: string | null;
onMessage: (msg: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: Uint8Array) => void;
getBinaryBufferedAmount?: () => number;
@@ -506,6 +529,7 @@ function toAgentPersistenceHandle(
*/
export class Session {
private readonly clientId: string;
private appVersion: string | null;
private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
@@ -601,6 +625,7 @@ export class Session {
constructor(options: SessionOptions) {
const {
clientId,
appVersion,
onMessage,
onBinaryMessage,
getBinaryBufferedAmount,
@@ -627,6 +652,7 @@ export class Session {
agentProviderRuntimeSettings,
} = options;
this.clientId = clientId;
this.appVersion = appVersion;
this.sessionId = uuidv4();
this.onMessage = onMessage;
this.onBinaryMessage = onBinaryMessage ?? null;
@@ -688,6 +714,12 @@ export class Session {
this.sessionLogger.trace("Session created");
}
updateAppVersion(appVersion: string | null): void {
if (appVersion && appVersion !== this.appVersion) {
this.appVersion = appVersion;
}
}
/**
* Get the client's current activity state
*/
@@ -1130,6 +1162,12 @@ export class Session {
}
}
// TODO: Remove once all app store clients are on >=0.1.45.
private isProviderVisibleToClient(provider: string): boolean {
if (clientSupportsAllProviders(this.appVersion)) return true;
return LEGACY_PROVIDER_IDS.has(provider);
}
private matchesAgentFilter(options: {
agent: AgentSnapshotPayload;
project: ProjectPlacementPayload;
@@ -1198,6 +1236,11 @@ export class Session {
subscription: AgentUpdatesSubscriptionState,
payload: AgentUpdatePayload,
): void {
// TODO: Remove once all app store clients are on >=0.1.45.
if (payload.kind === "upsert" && !this.isProviderVisibleToClient(payload.agent.provider)) {
return;
}
if (subscription.isBootstrapping) {
subscription.pendingUpdatesByAgentId.set(this.getAgentUpdateTargetId(payload), payload);
return;
@@ -1700,6 +1743,10 @@ export class Session {
await this.handleListProviderModesRequest(msg);
break;
case "list_provider_features_request":
await this.handleListProviderFeaturesRequest(msg);
break;
case "list_available_providers_request":
await this.handleListAvailableProvidersRequest(msg);
break;
@@ -3116,12 +3163,70 @@ export class Session {
}
}
private buildDraftAgentSessionConfig(draftConfig: {
provider: AgentProvider;
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
}): AgentSessionConfig {
return {
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId
? { thinkingOptionId: draftConfig.thinkingOptionId }
: {}),
...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}),
};
}
private async handleListProviderFeaturesRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
const features = await this.agentManager.listDraftFeatures(sessionConfig);
this.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
features,
error: null,
fetchedAt,
requestId: msg.requestId,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error, provider: msg.draftConfig.provider, draftConfig: msg.draftConfig },
`Failed to list features for ${msg.draftConfig.provider}`,
);
this.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
error: (error as Error)?.message ?? String(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
}
private async handleListAvailableProvidersRequest(
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const providers = await this.agentManager.listProviderAvailability();
let providers = await this.agentManager.listProviderAvailability();
// TODO: Remove once all app store clients are on >=0.1.45.
providers = providers.filter((p) => this.isProviderVisibleToClient(p.provider));
this.emit({
type: "list_available_providers_response",
payload: {
@@ -3632,16 +3737,7 @@ export class Session {
}
if (!agent && draftConfig) {
const sessionConfig: AgentSessionConfig = {
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId
? { thinkingOptionId: draftConfig.thinkingOptionId }
: {}),
};
const sessionConfig = this.buildDraftAgentSessionConfig(draftConfig);
const commands = await this.agentManager.listDraftCommands(sessionConfig);
this.emit({
type: "list_commands_response",
@@ -5642,6 +5738,12 @@ export class Session {
}
const payload = await this.listFetchAgentsEntries(request);
// TODO: Remove once all app store clients are on >=0.1.45.
payload.entries = payload.entries.filter((entry) =>
this.isProviderVisibleToClient(entry.agent.provider),
);
const snapshotUpdatedAtByAgentId = new Map<string, number>();
for (const entry of payload.entries) {
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);

View File

@@ -13,6 +13,7 @@ describe("voice MCP stdio config", () => {
baseArgs: ["/tmp/mcp-stdio-socket-bridge-cli.mjs"],
socketPath: "/tmp/paseo-voice.sock",
env: {
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: "/tmp/paseo-home",
},
});
@@ -25,6 +26,7 @@ describe("voice MCP stdio config", () => {
"/tmp/paseo-voice.sock",
]);
expect(config.env).toEqual({
ELECTRON_RUN_AS_NODE: "1",
PASEO_HOME: "/tmp/paseo-home",
});
});

View File

@@ -22,7 +22,7 @@ import { SherpaParakeetRealtimeTranscriptionSession } from "./sherpa/sherpa-para
import { SherpaRealtimeTranscriptionSession } from "./sherpa/sherpa-realtime-session.js";
import { SherpaOnnxSTT } from "./sherpa/sherpa-stt.js";
import { SherpaOnnxTTS } from "./sherpa/sherpa-tts.js";
import { SherpaSileroTurnDetectionProvider } from "./sherpa/silero-vad-provider.js";
import { ensureSileroVadModel, SherpaSileroTurnDetectionProvider } from "./sherpa/silero-vad-provider.js";
type LocalSttEngine =
| { kind: "offline"; engine: SherpaOfflineRecognizerEngine }
@@ -236,7 +236,15 @@ export async function initializeLocalSpeechServices(params: {
providers.voiceTurnDetection.enabled !== false &&
providers.voiceTurnDetection.provider === "local"
) {
turnDetectionService = new SherpaSileroTurnDetectionProvider({}, logger);
let vadModelPath: string | undefined;
if (localConfig) {
try {
vadModelPath = await ensureSileroVadModel(localConfig.modelsDir, logger);
} catch (err) {
logger.warn({ err }, "Failed to provision Silero VAD model, falling back to bundled");
}
}
turnDetectionService = new SherpaSileroTurnDetectionProvider({ modelPath: vadModelPath }, logger);
}
if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") {

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import pino from "pino";
import { SherpaOnnxParakeetSTT } from "./sherpa-parakeet-stt.js";
import type { TranscriptionResult } from "../../../speech-provider.js";
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
class TestSherpaOnnxParakeetStt extends SherpaOnnxParakeetSTT {
public readonly calls: Array<{ audio: Buffer; format: string }> = [];
public readonly pending: Array<ReturnType<typeof createDeferred<TranscriptionResult>>> = [];
constructor() {
super({ engine: { sampleRate: 16000 } as any }, pino({ level: "silent" }));
}
override async transcribeAudio(audioBuffer: Buffer, format: string): Promise<TranscriptionResult> {
this.calls.push({ audio: Buffer.from(audioBuffer), format });
const deferred = createDeferred<TranscriptionResult>();
this.pending.push(deferred);
return deferred.promise;
}
}
describe("SherpaOnnxParakeetSTT session", () => {
it("snapshots segment ids and buffers before async transcription starts", async () => {
const provider = new TestSherpaOnnxParakeetStt();
const session = provider.createSession({
logger: pino({ level: "silent" }),
language: "en",
});
const committed: Array<{ segmentId: string; previousSegmentId: string | null }> = [];
const transcripts: Array<{ segmentId: string; transcript: string; isFinal: boolean }> = [];
session.on("committed", (payload) => {
committed.push(payload);
});
session.on("transcript", (payload) => {
transcripts.push(payload);
});
await session.connect();
session.appendPcm16(Buffer.from([1, 2, 3, 4]));
session.commit();
session.appendPcm16(Buffer.from([5, 6, 7, 8]));
session.commit();
expect(committed).toHaveLength(2);
expect(committed[1]?.segmentId).not.toBe(committed[0]?.segmentId);
expect(committed[0]?.previousSegmentId).toBeNull();
expect(committed[1]?.previousSegmentId).toBe(committed[0]?.segmentId);
expect(provider.calls).toEqual([
{ audio: Buffer.from([1, 2, 3, 4]), format: "audio/pcm;rate=16000" },
{ audio: Buffer.from([5, 6, 7, 8]), format: "audio/pcm;rate=16000" },
]);
provider.pending[0]?.resolve({ text: "first", duration: 1 });
provider.pending[1]?.resolve({ text: "second", duration: 1 });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(transcripts).toHaveLength(2);
expect(transcripts).toEqual([
expect.objectContaining({
segmentId: committed[0]!.segmentId,
transcript: "first",
isFinal: true,
}),
expect.objectContaining({
segmentId: committed[1]!.segmentId,
transcript: "second",
isFinal: true,
}),
]);
});
});

View File

@@ -66,11 +66,18 @@ export class SherpaOnnxParakeetSTT implements SpeechToTextProvider {
const committedId = segmentId;
const prev = previousSegmentId;
const committedPcm16 = pcm16;
previousSegmentId = committedId;
segmentId = uuidv4();
pcm16 = Buffer.alloc(0);
(emitter as any).emit("committed", { segmentId: committedId, previousSegmentId: prev });
void (async () => {
try {
const rt = await this.transcribeAudio(pcm16, `audio/pcm;rate=${requiredSampleRate}`);
const rt = await this.transcribeAudio(
committedPcm16,
`audio/pcm;rate=${requiredSampleRate}`,
);
(emitter as any).emit("transcript", {
segmentId: committedId,
transcript: rt.text,
@@ -83,10 +90,7 @@ export class SherpaOnnxParakeetSTT implements SpeechToTextProvider {
} catch (err) {
(emitter as any).emit("error", err);
} finally {
previousSegmentId = committedId;
segmentId = uuidv4();
pcm16 = Buffer.alloc(0);
logger.debug({ bytes: pcm16.length }, "Parakeet session reset");
logger.debug({ bytes: committedPcm16.length }, "Parakeet session reset");
}
})();
},

View File

@@ -0,0 +1,55 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import pino from "pino";
const generate = vi.fn();
const free = vi.fn();
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => true),
}));
vi.mock("./sherpa-onnx-node-loader.js", () => ({
loadSherpaOnnxNode: () => ({
OfflineTts: class {
public readonly sampleRate = 24000;
constructor(_config: unknown) {}
generate = generate;
free = free;
},
}),
}));
describe("SherpaOnnxTTS", () => {
beforeEach(() => {
generate.mockReset();
free.mockReset();
});
it("disables external buffers when calling sherpa generate", async () => {
generate.mockReturnValue({
samples: Float32Array.from([0, 0.5, -0.5, 0.25]),
sampleRate: 24000,
});
const { SherpaOnnxTTS } = await import("./sherpa-tts.js");
const tts = new SherpaOnnxTTS(
{
preset: "kokoro-en-v0_19",
modelDir: "/tmp/fake-model",
},
pino({ level: "silent" }),
);
const result = await tts.synthesizeSpeech("hello");
expect(generate).toHaveBeenCalledWith({
text: "hello",
sid: 0,
speed: 1,
enableExternalBuffer: false,
});
expect(result.format).toBe("pcm;rate=24000");
});
});

View File

@@ -94,13 +94,23 @@ export class SherpaOnnxTTS implements TextToSpeechProvider {
throw new Error("Cannot synthesize empty text");
}
const audio = this.tts.generate({ text: trimmed, sid: this.speakerId, speed: this.speed });
const samples: Float32Array | null =
const audio = this.tts.generate({
text: trimmed,
sid: this.speakerId,
speed: this.speed,
// Electron rejects native external-backed typed arrays. Request a copied buffer
// from sherpa itself instead of trying to clone after generate() returns.
enableExternalBuffer: false,
});
const rawSamples: Float32Array | null =
audio && audio.samples instanceof Float32Array
? audio.samples
: audio && Array.isArray(audio.samples)
? Float32Array.from(audio.samples as number[])
: null;
// Copy to avoid "External buffers are not allowed" when sherpa-onnx
// returns a Float32Array backed by native memory.
const samples = rawSamples ? Float32Array.from(rawSamples) : null;
const sampleRate: number =
audio &&
typeof audio.sampleRate === "number" &&

View File

@@ -1,10 +1,43 @@
import { copyFile, mkdir, stat } from "node:fs/promises";
import path from "node:path";
import type { Logger } from "pino";
import type {
TurnDetectionProvider,
TurnDetectionSession,
} from "../../../turn-detection-provider.js";
import { SherpaSileroVadSession, type SherpaSileroVadSessionConfig } from "./silero-vad-session.js";
import {
resolveBundledSileroVadModelPath,
SherpaSileroVadSession,
type SherpaSileroVadSessionConfig,
} from "./silero-vad-session.js";
const SILERO_VAD_DIR = "silero-vad";
const SILERO_VAD_FILE = "silero_vad.onnx";
/**
* Ensure the Silero VAD ONNX model exists in modelsDir where native code can
* read it. The bundled asset lives inside Electron's app.asar which native C++
* cannot open, so we copy it out on first run using Node.js fs (asar-aware).
*/
export async function ensureSileroVadModel(modelsDir: string, logger: Logger): Promise<string> {
const destDir = path.join(modelsDir, SILERO_VAD_DIR);
const destPath = path.join(destDir, SILERO_VAD_FILE);
try {
const s = await stat(destPath);
if (s.isFile() && s.size > 0) return destPath;
} catch {
// not present yet
}
const bundledPath = resolveBundledSileroVadModelPath();
await mkdir(destDir, { recursive: true });
await copyFile(bundledPath, destPath);
logger.info({ destPath }, "Copied Silero VAD model to models directory");
return destPath;
}
export class SherpaSileroTurnDetectionProvider implements TurnDetectionProvider {
public readonly id = "local" as const;

View File

@@ -34,7 +34,7 @@ type SherpaVadModule = {
CircularBuffer: new (capacity: number) => SherpaCircularBufferHandle;
};
function resolveBundledSileroVadModelPath(): string {
export function resolveBundledSileroVadModelPath(): string {
return fileURLToPath(new URL("./assets/silero_vad.onnx", import.meta.url));
}
@@ -107,7 +107,7 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
const samples = pcm16leToFloat32(pcm16le, 1);
this.inputBuffer.push(samples);
while (this.inputBuffer.size() > this.windowSize) {
const window = this.inputBuffer.get(this.inputBuffer.head(), this.windowSize);
const window = this.inputBuffer.get(this.inputBuffer.head(), this.windowSize, false);
this.inputBuffer.pop(this.windowSize);
this.vad.acceptWaveform(window);
this.syncDetectionState();

View File

@@ -176,6 +176,7 @@ type WebSocketLike = {
type SessionConnection = {
session: Session;
clientId: string;
appVersion: string | null;
connectionLogger: pino.Logger;
sockets: Set<WebSocketLike>;
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
@@ -595,13 +596,15 @@ export class VoiceAssistantWebSocketServer {
private createSessionConnection(params: {
ws: WebSocketLike;
clientId: string;
appVersion: string | null;
connectionLogger: pino.Logger;
}): SessionConnection {
const { ws, clientId, connectionLogger } = params;
const { ws, clientId, appVersion, connectionLogger } = params;
let connection: SessionConnection | null = null;
const session = new Session({
clientId,
appVersion,
onMessage: (msg) => {
if (!connection) {
return;
@@ -677,6 +680,7 @@ export class VoiceAssistantWebSocketServer {
connection = {
session,
clientId,
appVersion,
connectionLogger,
sockets: new Set([ws]),
externalDisconnectCleanupTimeout: null,
@@ -741,6 +745,11 @@ export class VoiceAssistantWebSocketServer {
clearTimeout(existing.externalDisconnectCleanupTimeout);
existing.externalDisconnectCleanupTimeout = null;
}
const newAppVersion = message.appVersion ?? null;
if (newAppVersion && newAppVersion !== existing.appVersion) {
existing.appVersion = newAppVersion;
existing.session.updateAppVersion(newAppVersion);
}
existing.sockets.add(ws);
this.sessions.set(ws, existing);
this.sendToClient(ws, this.createServerInfoMessage());
@@ -760,6 +769,7 @@ export class VoiceAssistantWebSocketServer {
const connection = this.createSessionConnection({
ws,
clientId,
appVersion: message.appVersion ?? null,
connectionLogger,
});
this.sessions.set(ws, connection);

View File

@@ -14,6 +14,7 @@ describe("agent feature schemas", () => {
id: "fast_mode",
label: "Fast mode",
description: "Uses lower latency service tier",
tooltip: "Toggle fast mode",
icon: "bolt",
value: true,
});
@@ -31,6 +32,7 @@ describe("agent feature schemas", () => {
id: "service_tier",
label: "Service tier",
description: "Choose a processing tier",
tooltip: "Select service tier",
icon: "gauge",
value: "flex",
options: [
@@ -112,6 +114,7 @@ describe("agent feature schemas", () => {
type: "toggle",
id: "fast_mode",
label: "Fast mode",
tooltip: "Toggle fast mode",
value: false,
},
],

View File

@@ -27,6 +27,9 @@ describe("list_commands_request schema", () => {
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
featureValues: {
plan_mode: true,
},
},
requestId: "req-456",
});
@@ -41,6 +44,9 @@ describe("list_commands_request schema", () => {
modeId: "bypassPermissions",
model: "gpt-5",
thinkingOptionId: "off",
featureValues: {
plan_mode: true,
},
});
});
});

View File

@@ -82,6 +82,7 @@ export const AgentFeatureToggleSchema = z.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
tooltip: z.string().optional(),
icon: z.string().optional(),
value: z.boolean(),
});
@@ -91,6 +92,7 @@ export const AgentFeatureSelectSchema = z.object({
id: z.string(),
label: z.string(),
description: z.string().optional(),
tooltip: z.string().optional(),
icon: z.string().optional(),
value: z.string().nullable(),
options: z.array(AgentSelectOptionSchema),
@@ -315,6 +317,10 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
text: z.string().optional(),
icon: z.enum(TOOL_CALL_ICON_NAMES).optional(),
}),
z.object({
type: z.literal("plan"),
text: z.string(),
}),
z.object({
type: z.literal("unknown"),
input: UnknownValueSchema,
@@ -1154,6 +1160,13 @@ const ListCommandsDraftConfigSchema = z.object({
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
featureValues: z.record(z.unknown()).optional(),
});
export const ListProviderFeaturesRequestMessageSchema = z.object({
type: z.literal("list_provider_features_request"),
draftConfig: ListCommandsDraftConfigSchema,
requestId: z.string(),
});
export const ListCommandsRequestSchema = z.object({
@@ -1260,6 +1273,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CreateAgentRequestMessageSchema,
ListProviderModelsRequestMessageSchema,
ListProviderModesRequestMessageSchema,
ListProviderFeaturesRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema,
ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema,
@@ -1593,35 +1607,50 @@ export const ArtifactMessageSchema = z.object({
}),
});
export const ProjectCheckoutLiteNotGitPayloadSchema = z.object({
cwd: z.string(),
isGit: z.literal(false),
currentBranch: z.null(),
remoteUrl: z.null(),
worktreeRoot: z.null(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
});
export const ProjectCheckoutLiteNotGitPayloadSchema = z
.object({
cwd: z.string(),
isGit: z.literal(false),
currentBranch: z.null(),
remoteUrl: z.null(),
worktreeRoot: z.null().optional(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
})
.transform((value) => ({
...value,
worktreeRoot: null,
}));
export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
});
export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z
.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string().optional(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
})
.transform((value) => ({
...value,
worktreeRoot: value.worktreeRoot ?? value.cwd,
}));
export const ProjectCheckoutLiteGitPaseoPayloadSchema = z.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string(),
isPaseoOwnedWorktree: z.literal(true),
mainRepoRoot: z.string(),
});
export const ProjectCheckoutLiteGitPaseoPayloadSchema = z
.object({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string().optional(),
isPaseoOwnedWorktree: z.literal(true),
mainRepoRoot: z.string(),
})
.transform((value) => ({
...value,
worktreeRoot: value.worktreeRoot ?? value.cwd,
}));
export const ProjectCheckoutLitePayloadSchema = z.union([
ProjectCheckoutLiteNotGitPayloadSchema,
@@ -2179,6 +2208,17 @@ export const ListProviderModesResponseMessageSchema = z.object({
}),
});
export const ListProviderFeaturesResponseMessageSchema = z.object({
type: z.literal("list_provider_features_response"),
payload: z.object({
provider: AgentProviderSchema,
features: z.array(AgentFeatureSchema).optional(),
error: z.string().nullable().optional(),
fetchedAt: z.string(),
requestId: z.string(),
}),
});
const ProviderAvailabilitySchema = z.object({
provider: AgentProviderSchema,
available: z.boolean(),
@@ -2386,6 +2426,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FileDownloadTokenResponseSchema,
ListProviderModelsResponseMessageSchema,
ListProviderModesResponseMessageSchema,
ListProviderFeaturesResponseMessageSchema,
ListAvailableProvidersResponseSchema,
ListCommandsResponseSchema,
ListTerminalsResponseSchema,
@@ -2462,6 +2503,9 @@ export type ListProviderModelsResponseMessage = z.infer<
export type ListProviderModesResponseMessage = z.infer<
typeof ListProviderModesResponseMessageSchema
>;
export type ListProviderFeaturesResponseMessage = z.infer<
typeof ListProviderFeaturesResponseMessageSchema
>;
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
export type ChatCreateResponse = z.infer<typeof ChatCreateResponseSchema>;
export type ChatListResponse = z.infer<typeof ChatListResponseSchema>;
@@ -2504,6 +2548,9 @@ export type ListProviderModelsRequestMessage = z.infer<
export type ListProviderModesRequestMessage = z.infer<
typeof ListProviderModesRequestMessageSchema
>;
export type ListProviderFeaturesRequestMessage = z.infer<
typeof ListProviderFeaturesRequestMessageSchema
>;
export type ListAvailableProvidersRequestMessage = z.infer<
typeof ListAvailableProvidersRequestMessageSchema
>;
@@ -2621,6 +2668,7 @@ export const WSHelloMessageSchema = z.object({
clientId: z.string().min(1),
clientType: z.enum(["mobile", "browser", "cli", "mcp"]),
protocolVersion: z.number().int(),
appVersion: z.string().optional(),
capabilities: z
.object({
voice: z.boolean().optional(),

View File

@@ -50,4 +50,71 @@ describe("workspace message schemas", () => {
expect(result.success).toBe(false);
});
test("parses legacy fetch_agents_response checkout payloads without worktreeRoot", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "fetch_agents_response",
payload: {
requestId: "req-1",
entries: [
{
agent: {
id: "agent-1",
provider: "codex",
cwd: "C:\\repo",
model: null,
features: [],
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-04T00:00:00.000Z",
updatedAt: "2026-04-04T00:00:00.000Z",
lastUserMessageAt: null,
status: "running",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Agent 1",
labels: {},
requiresAttention: false,
attentionReason: null,
},
project: {
projectKey: "remote:github.com/acme/repo",
projectName: "acme/repo",
checkout: {
cwd: "C:\\repo",
isGit: true,
currentBranch: "main",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
},
},
],
pageInfo: {
nextCursor: null,
prevCursor: null,
hasMore: false,
},
},
});
expect(result.success).toBe(true);
if (!result.success) {
return;
}
const checkout = result.data.payload.entries[0]?.project.checkout;
expect(checkout?.worktreeRoot).toBe("C:\\repo");
});
});

View File

@@ -140,4 +140,20 @@ describe("shared tool-call display mapping", () => {
summary: "npm run test",
});
});
it("labels plan detail rows as Plan", () => {
const display = buildToolCallDisplayModel({
name: "plan",
status: "completed",
error: null,
detail: {
type: "plan",
text: "### Login Screen\n- Build layout",
},
});
expect(display).toEqual({
displayName: "Plan",
});
});
});

View File

@@ -109,6 +109,10 @@ function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay
return {
summary: input.detail.label,
};
case "plan":
return {
displayName: "Plan",
};
case "unknown":
return {};
}

View File

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

View File

@@ -297,6 +297,8 @@ function MultiProviderSection() {
{ name: "Claude Code", icon: <ClaudeIcon size={28} /> },
{ name: "Codex", icon: <CodexIcon className="w-7 h-7" /> },
{ name: "OpenCode", icon: <OpenCodeIcon className="w-7 h-7" /> },
{ name: "Copilot", icon: <CopilotIcon className="w-7 h-7" /> },
{ name: "Pi", icon: <PiIcon className="w-7 h-7" /> },
];
return (
@@ -305,7 +307,18 @@ function MultiProviderSection() {
description="Run multiple providers from a single interface. Paseo runs the native agent harness as you'd normally run it, with your skills, config and MCP servers intact."
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{providers.map((p) => (
{providers.slice(0, 3).map((p) => (
<div
key={p.name}
className="flex items-center justify-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4"
>
<span className="text-white/80">{p.icon}</span>
<span className="font-medium">{p.name}</span>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-4 sm:w-2/3">
{providers.slice(3).map((p) => (
<div
key={p.name}
className="flex items-center justify-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4"
@@ -861,6 +874,42 @@ function OpenCodeIcon(props: React.SVGProps<SVGSVGElement>) {
);
}
function CopilotIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 416"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path
d="M181.33 266.143c0-11.497 9.32-20.818 20.818-20.818 11.498 0 20.819 9.321 20.819 20.818v38.373c0 11.497-9.321 20.818-20.819 20.818-11.497 0-20.818-9.32-20.818-20.818v-38.373zM308.807 245.325c-11.477 0-20.798 9.321-20.798 20.818v38.373c0 11.497 9.32 20.818 20.798 20.818 11.497 0 20.818-9.32 20.818-20.818v-38.373c0-11.497-9.32-20.818-20.818-20.818z"
fillRule="evenodd"
/>
<path d="M512.002 246.393v57.384c-.02 7.411-3.696 14.638-9.67 19.011C431.767 374.444 344.695 416 256 416c-98.138 0-196.379-56.542-246.33-93.21-5.975-4.374-9.65-11.6-9.671-19.012v-57.384a35.347 35.347 0 016.857-20.922l15.583-21.085c8.336-11.312 20.757-14.31 33.98-14.31 4.988-56.953 16.794-97.604 45.024-127.354C155.194 5.77 226.56 0 256 0c29.441 0 100.807 5.77 154.557 62.722 28.19 29.75 40.036 70.401 45.025 127.354 13.263 0 25.602 2.936 33.958 14.31l15.583 21.127c4.476 6.077 6.878 13.345 6.878 20.88zm-97.666-26.075c-.677-13.058-11.292-18.19-22.338-21.824-11.64 7.309-25.848 10.183-39.46 10.183-14.454 0-41.432-3.47-63.872-25.869-5.667-5.625-9.527-14.454-12.155-24.247a212.902 212.902 0 00-20.469-1.088c-6.098 0-13.099.349-20.551 1.088-2.628 9.793-6.509 18.622-12.155 24.247-22.4 22.4-49.418 25.87-63.872 25.87-13.612 0-27.86-2.855-39.501-10.184-11.005 3.613-21.558 8.828-22.277 21.824-1.17 24.555-1.272 49.11-1.375 73.645-.041 12.318-.082 24.658-.288 36.976.062 7.166 4.374 13.818 10.882 16.774 52.97 24.124 103.045 36.278 149.137 36.278 46.01 0 96.085-12.154 149.014-36.278 6.508-2.956 10.84-9.608 10.881-16.774.637-36.832.124-73.809-1.642-110.62h.041zM107.521 168.97c8.643 8.623 24.966 14.392 42.56 14.392 13.448 0 39.03-2.874 60.156-24.329 9.28-8.951 15.05-31.35 14.413-54.079-.657-18.231-5.769-33.28-13.448-39.665-8.315-7.371-27.203-10.574-48.33-8.644-22.399 2.238-41.267 9.588-50.875 19.833-20.798 22.728-16.323 80.317-4.476 92.492zm130.556-56.008c.637 3.51.965 7.35 1.273 11.517 0 2.875 0 5.77-.308 8.952 6.406-.636 11.847-.636 16.959-.636s10.553 0 16.959.636c-.329-3.182-.329-6.077-.329-8.952.329-4.167.657-8.007 1.294-11.517-6.735-.637-12.812-.965-17.924-.965s-11.21.328-17.924.965zm49.275-8.008c-.637 22.728 5.133 45.128 14.413 54.08 21.105 21.454 46.708 24.328 60.155 24.328 17.596 0 33.918-5.769 42.561-14.392 11.847-12.175 16.322-69.764-4.476-92.492-9.608-10.245-28.476-17.595-50.875-19.833-21.127-1.93-40.015 1.273-48.33 8.644-7.679 6.385-12.791 21.434-13.448 39.665z" />
</svg>
);
}
function PiIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 800 800"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path
d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"
fillRule="evenodd"
/>
<path d="M517.36 400 H634.72 V634.72 H517.36 Z" />
</svg>
);
}
function AppStoreIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg

View File

@@ -2,12 +2,12 @@ import "~/styles.css";
export function SiteHeader() {
return (
<header className="flex items-center justify-between gap-4">
<header className="flex flex-col items-center gap-4 md:flex-row md:justify-between">
<a href="/" className="flex items-center gap-3">
<img src="/logo.svg" alt="Paseo" className="w-6 h-6" />
<span className="text-lg font-medium">Paseo</span>
</a>
<div className="flex items-center gap-4">
<div className="flex flex-wrap items-center justify-center gap-4">
<a
href="/blog"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"

View File

@@ -77,8 +77,8 @@ $NO_EDITS"
Same prompt to both, `[Committee]` prefix for identification:
```bash
opus_id=$(paseo run -d --mode bypassPermissions --model opus --thinking on --name "[Committee] Task description" "$prompt" -q)
gpt_id=$(paseo run -d --mode full-access --provider codex --model gpt-5.4 --thinking medium --name "[Committee] Task description" "$prompt" -q)
opus_id=$(paseo run -d --mode bypassPermissions --provider claude/opus --thinking on --name "[Committee] Task description" "$prompt" -q)
gpt_id=$(paseo run -d --mode full-access --provider codex/gpt-5.4 --thinking medium --name "[Committee] Task description" "$prompt" -q)
```
### Wait for both
@@ -129,7 +129,7 @@ paseo send "$gpt_id" "Merged plan: [plan]. Concerns? $NO_EDITS"
Implement the plan yourself — unless the user said **"delegate"**, in which case launch an implementer:
```bash
impl_id=$(paseo run -d --mode full-access --provider codex --name "[Impl] Task description" "Implement the following plan end-to-end. [plan]" -q)
impl_id=$(paseo run -d --mode full-access --provider codex/gpt-5.4 --name "[Impl] Task description" "Implement the following plan end-to-end. [plan]" -q)
paseo wait "$impl_id"
```

View File

@@ -30,13 +30,13 @@ Parse `$ARGUMENTS` to determine:
### Provider Resolution
| User says | Provider | Model | Mode |
|---|---|---|---|
| *(nothing)* | `codex` | `gpt-5.4` | `full-access` |
| `codex` | `codex` | `gpt-5.4` | `full-access` |
| `claude` | `claude` | `opus` | `bypass` |
| `opus` | `claude` | `opus` | `bypass` |
| `sonnet` | `claude` | `sonnet` | `bypass` |
| User says | --provider | Mode |
|---|---|---|
| *(nothing)* | `codex/gpt-5.4` | `full-access` |
| `codex` | `codex/gpt-5.4` | `full-access` |
| `claude` | `claude/opus` | `bypass` |
| `opus` | `claude/opus` | `bypass` |
| `sonnet` | `claude/sonnet` | `bypass` |
Default is **Codex** with `gpt-5.4`.
@@ -109,27 +109,27 @@ This is the critical step. The receiving agent has **zero context** about your c
### Default (Codex, no worktree)
```bash
paseo run -d --mode full-access --provider codex --name "[Handoff] Task description" "$prompt"
paseo run -d --mode full-access --provider codex/gpt-5.4 --name "[Handoff] Task description" "$prompt"
```
### Claude (Opus, no worktree)
```bash
paseo run -d --mode bypassPermissions --model opus --name "[Handoff] Task description" "$prompt"
paseo run -d --mode bypassPermissions --provider claude/opus --name "[Handoff] Task description" "$prompt"
```
### Codex in a worktree
```bash
base=$(git branch --show-current)
paseo run -d --mode full-access --provider codex --worktree task-branch-name --base "$base" --name "[Handoff] Task description" "$prompt"
paseo run -d --mode full-access --provider codex/gpt-5.4 --worktree task-branch-name --base "$base" --name "[Handoff] Task description" "$prompt"
```
### Claude in a worktree
```bash
base=$(git branch --show-current)
paseo run -d --mode bypass --model opus --worktree task-branch-name --base "$base" --name "[Handoff] Task description" "$prompt"
paseo run -d --mode bypass --provider claude/opus --worktree task-branch-name --base "$base" --name "[Handoff] Task description" "$prompt"
```
## After Launch

View File

@@ -39,8 +39,8 @@ Every loop needs at least one form of verification:
Choose the right provider/model for worker and verifier independently:
- `--provider`/`--model` — sets the worker's provider and model
- `--verify-provider`/`--verify-model` — sets the verifier's provider and model
- `--provider <provider/model>` — sets the worker (e.g. `codex/gpt-5.4`)
- `--verify-provider <provider/model>` — sets the verifier (e.g. `claude/opus`)
Default: both use Claude/sonnet. For implementation loops, use Codex for the worker and Claude for the verifier — each catches the other's blind spots.
@@ -64,7 +64,7 @@ paseo loop run "Check PR #42. Review CI, comments, and branch status. Fix issues
```bash
paseo loop run "Run the test suite, investigate failures, and fix the code." \
--provider codex \
--provider codex/gpt-5.4 \
--verify "Run the test suite. Return done=true only if all tests pass. Cite the exact command and outcome." \
--verify-check "npm test" \
--max-iterations 10 \
@@ -75,9 +75,9 @@ paseo loop run "Run the test suite, investigate failures, and fix the code." \
```bash
paseo loop run "Implement issue #456. Make incremental progress each iteration." \
--provider codex \
--provider codex/gpt-5.4 \
--verify "Verify issue #456 is complete. Check changed files, run typecheck and tests." \
--verify-provider claude --verify-model sonnet \
--verify-provider claude/sonnet \
--max-iterations 8 \
--max-time 2h \
--archive \

View File

@@ -78,7 +78,7 @@ Launch agents with lightweight initial prompts. Each agent gets:
### Initial prompt template
```bash
paseo run -d --mode full-access --provider codex \
paseo run -d --mode full-access --provider codex/gpt-5.4 \
--name "impl-<scope>" \
"You are an implementation engineer on a team.
@@ -114,10 +114,10 @@ Pick the right provider for each role:
| Role | Provider | Why |
|---|---|---|
| Implementation | `codex` / `gpt-5.4` | Thorough, methodical, good at deep implementation |
| Review / Audit | `claude` / `opus` | Good design instinct, catches over-engineering |
| Investigation | `claude` / `opus` | Strong reasoning, good at tracing code paths |
| Planning | `claude` / `opus` with `--thinking on` | Extended thinking for complex problems |
| Implementation | `--provider codex/gpt-5.4` | Thorough, methodical, good at deep implementation |
| Review / Audit | `--provider claude/opus` | Good design instinct, catches over-engineering |
| Investigation | `--provider claude/opus` | Strong reasoning, good at tracing code paths |
| Planning | `--provider claude/opus --thinking on` | Extended thinking for complex problems |
Cross-provider review: Codex implements → Claude reviews. Claude implements → Codex reviews. Each catches the other's blind spots.
@@ -179,7 +179,7 @@ paseo stop <old-agent-id>
# (archiving happens automatically if the agent was part of a loop with --archive)
# Launch a fresh one
paseo run -d --mode full-access --provider codex \
paseo run -d --mode full-access --provider codex/gpt-5.4 \
--name "impl-<scope>-v2" \
"You are picking up work from a previous agent. Load the paseo-chat skill. Read room '<room>' from the beginning to catch up on the full history — the objective, what was done, what went wrong. Introduce yourself and continue from where the previous agent left off. @mention <orchestrator-id> when you've caught up." -q
```
@@ -191,7 +191,7 @@ The chat room has the full history. The new agent reads it and continues.
After implementation is done, launch a review agent (opposite provider):
```bash
paseo run -d --mode bypassPermissions --model opus \
paseo run -d --mode bypassPermissions --provider claude/opus \
--name "review-<scope>" \
"You are a reviewer on a team. Load the paseo-chat skill. Read room '<room>' to understand the objective and what was implemented.

View File

@@ -14,8 +14,8 @@ paseo ls --json # JSON output for parsing
# Create and run an agent (blocks until completion by default, no timeout)
paseo run --mode bypassPermissions "<prompt>"
paseo run --mode bypassPermissions --name "task-name" "<prompt>"
paseo run --mode bypassPermissions --model opus "<prompt>"
paseo run --mode full-access --provider codex "<prompt>"
paseo run --mode bypassPermissions --provider claude/opus "<prompt>"
paseo run --mode full-access --provider codex/gpt-5.4 "<prompt>"
# Wait timeout - limit how long run blocks (default: no limit)
paseo run --wait-timeout 30m "<prompt>" # Wait up to 30 minutes
@@ -86,10 +86,8 @@ paseo loop run "<worker prompt>" [options]
--sleep <duration> # Delay between iterations (30s, 5m)
--max-iterations <n> # Maximum number of iterations
--max-time <duration> # Maximum total runtime (1h, 30m)
--provider <provider> # Worker agent provider (claude, codex)
--model <model> # Worker agent model
--verify-provider <provider> # Verifier agent provider
--verify-model <model> # Verifier agent model
--provider <provider/model> # Worker agent provider/model (e.g. codex/gpt-5.4)
--verify-provider <provider/model> # Verifier agent provider/model (e.g. claude/opus)
--archive # Archive agents after each iteration
# Manage loops
@@ -209,18 +207,18 @@ paseo terminal kill "$id"
## Available Models
**Claude (default provider)** — use aliases, CLI resolves to latest version:
- `--model haiku` — Fast/cheap, ONLY for tests (not for real work)
- `--model sonnet`Default, good for most tasks
- `--model opus` — For harder reasoning, complex debugging
**Claude (default provider):**
- `--provider claude/haiku` — Fast/cheap, ONLY for tests (not for real work)
- `--provider claude/sonnet`Good for most tasks
- `--provider claude/opus` — For harder reasoning, complex debugging
**Codex** (`--provider codex`):
- `--model gpt-5.4` — Latest frontier agentic coding model (default, preferred for all engineering tasks)
- `--model gpt-5.1-codex-mini` — Cheaper, faster, but less capable
**Codex:**
- `--provider codex/gpt-5.4` — Latest frontier agentic coding model (preferred for all engineering tasks)
- `--provider codex/gpt-5.1-codex-mini` — Cheaper, faster, but less capable
## Permissions
Always launch agents fully permissioned. Use `--mode bypassPermissions` for Claude and `--mode full-access` for Codex. Control behavior through **strict prompting**, not permission modes.
Always launch agents fully permissioned. Use `--mode bypassPermissions` for Claude and `--mode full-access` for Codex. Always specify the model: `--provider claude/opus`, `--provider codex/gpt-5.4`, etc. Control behavior through **strict prompting**, not permission modes.
## Waiting for Agents