mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
29 Commits
v0.1.45-rc
...
v0.1.47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d9ca6983a | ||
|
|
e44e495481 | ||
|
|
bedf792616 | ||
|
|
5db9942125 | ||
|
|
a889dbcc28 | ||
|
|
5c1e869bc5 | ||
|
|
a693ff560c | ||
|
|
89baf7ff38 | ||
|
|
c65a851205 | ||
|
|
85acdbb05e | ||
|
|
4e32f9b7bd | ||
|
|
744ca7a2bc | ||
|
|
3110bae209 | ||
|
|
16efdb2c95 | ||
|
|
1e9b7f1157 | ||
|
|
018ebd5f29 | ||
|
|
4706d9e3bd | ||
|
|
ba0c4a3fff | ||
|
|
2a8da5d4c1 | ||
|
|
43542ac858 | ||
|
|
fefe260f0a | ||
|
|
a17f7d2d20 | ||
|
|
c5a69a1ad9 | ||
|
|
155c88254b | ||
|
|
22c83118d0 | ||
|
|
7e99529bde | ||
|
|
a6f6c169c8 | ||
|
|
d69fcb861d | ||
|
|
c3313ff82a |
49
CHANGELOG.md
49
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
38
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.45-rc.3",
|
||||
"version": "0.1.47",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
154
packages/app/src/components/plan-card.tsx
Normal file
154
packages/app/src/components/plan-card.tsx
Normal 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],
|
||||
},
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
134
packages/app/src/hooks/use-draft-agent-features.ts
Normal file
134
packages/app/src/hooks/use-draft-agent-features.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env -S node --disable-warning=DEP0040
|
||||
import '../dist/index.js'
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
188
packages/cli/src/classify.test.ts
Normal file
188
packages/cli/src/classify.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
70
packages/cli/src/classify.ts
Normal file
70
packages/cli/src/classify.ts
Normal 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 };
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}" "$@"
|
||||
|
||||
@@ -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%
|
||||
|
||||
@@ -7,6 +7,8 @@ directories:
|
||||
output: release
|
||||
files:
|
||||
- dist/**/*
|
||||
asarUnpack:
|
||||
- dist/daemon/node-entrypoint-runner.js
|
||||
extraResources:
|
||||
- from: ../app/dist
|
||||
to: app-dist
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
47
packages/desktop/src/integrations/cli-install-path.test.ts
Normal file
47
packages/desktop/src/integrations/cli-install-path.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
24
packages/desktop/src/integrations/cli-install-path.ts
Normal file
24
packages/desktop/src/integrations/cli-install-path.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
69
packages/desktop/src/open-project-routing.test.ts
Normal file
69
packages/desktop/src/open-project-routing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
packages/desktop/src/open-project-routing.ts
Normal file
45
packages/desktop/src/open-project-routing.ts
Normal 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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.45-rc.3",
|
||||
"version": "0.1.47",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } }> = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -590,6 +590,7 @@ export async function createPaseoDaemon(
|
||||
command: voiceMcpBridgeCommand.command,
|
||||
baseArgs: [...voiceMcpBridgeCommand.baseArgs],
|
||||
env: {
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
PASEO_HOME: config.paseoHome,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,6 +109,10 @@ function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay
|
||||
return {
|
||||
summary: input.detail.label,
|
||||
};
|
||||
case "plan":
|
||||
return {
|
||||
displayName: "Plan",
|
||||
};
|
||||
case "unknown":
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.45-rc.3",
|
||||
"version": "0.1.47",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user