Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-05-19 22:57:37 +07:00
83 changed files with 5932 additions and 4359 deletions
+2
View File
@@ -339,6 +339,8 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
### Adding a generic ACP provider
Set `extends: "acp"` and provide a `command`:
+1
View File
@@ -139,6 +139,7 @@ Single file, validated with `PersistedConfigSchema`.
listen: "127.0.0.1:6767",
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
mcp: { enabled: boolean, injectIntoAgents: boolean },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
auth: { password: string } // bcrypt hash, optional
+1 -1
View File
@@ -71,7 +71,7 @@ Anyone who builds software:
- Desktop (Electron), mobile (iOS/Android), web, CLI
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
- One-click ACP provider catalog: Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
+8 -2
View File
@@ -14,9 +14,15 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Pi direct embeds Pi's SDK through the `@earendil-works/pi-*` packages. Keep those dependencies in sync with the Pi package-manager behavior expected by current user installs: Pi 0.75+ loads user-scoped npm packages from `~/.pi/agent/npm/`, while older `@mariozechner/pi-coding-agent` releases looked in npm's global package root and can miss or load stale extensions.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
+56 -1527
View File
File diff suppressed because it is too large Load Diff
@@ -171,14 +171,24 @@ describe("resolveStartupRedirectRoute", () => {
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
});
it("redirects to the host root when the persisted workspace targets a different server", () => {
it("leaves persisted workspace navigation to the workspace navigator when another host is first online", () => {
const route = resolveStartupRedirectRoute({
...baseInput,
anyOnlineHostServerId: "server-2",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
});
expect(route).toBe("/h/server-2");
expect(route).toBeNull();
});
it("resolves the persisted workspace when another host is first online", () => {
const selection = resolveStartupWorkspaceSelection({
...baseInput,
anyOnlineHostServerId: "server-2",
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
});
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
});
it("redirects to the host root when no persisted workspace exists", () => {
@@ -79,11 +79,7 @@ export function resolveStartupWorkspaceSelection(
if (!input.isWorkspaceSelectionLoaded) {
return null;
}
if (
!input.anyOnlineHostServerId ||
!input.workspaceSelection ||
input.workspaceSelection.serverId !== input.anyOnlineHostServerId
) {
if (!input.workspaceSelection) {
return null;
}
return input.workspaceSelection;
+20 -3
View File
@@ -18,6 +18,7 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
storeReady: false,
} as HostRuntimeBootstrapState,
anyOnlineHostServerId: null as string | null,
isWorkspaceSelectionLoaded: true,
workspaceSelection: null as ActiveWorkspaceSelection | null,
};
@@ -51,7 +52,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({
vi.mock("@/stores/navigation-active-workspace-store", () => ({
navigateToWorkspace: navigateToWorkspaceMock,
useActiveWorkspaceSelection: () => state.workspaceSelection,
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
useLastWorkspaceSelection: () => state.workspaceSelection,
}));
describe("Index route startup navigation", () => {
@@ -68,6 +70,7 @@ describe("Index route startup navigation", () => {
storeReady: false,
};
state.anyOnlineHostServerId = null;
state.isWorkspaceSelectionLoaded = true;
state.workspaceSelection = null;
navigateToWorkspaceMock.mockReset();
redirectMock.mockReset();
@@ -98,6 +101,17 @@ describe("Index route startup navigation", () => {
expect(redirectMock).not.toHaveBeenCalled();
});
it("shows the startup splash while the persisted workspace selection has not loaded", async () => {
state.anyOnlineHostServerId = "server-1";
state.isWorkspaceSelectionLoaded = false;
await renderIndex();
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
expect(navigateToWorkspaceMock).not.toHaveBeenCalled();
expect(redirectMock).not.toHaveBeenCalled();
});
it("restores the persisted workspace when the online host matches its server id", async () => {
state.anyOnlineHostServerId = "server-1";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
@@ -111,13 +125,16 @@ describe("Index route startup navigation", () => {
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
});
it("navigates to the host root when the persisted workspace targets a different server", async () => {
it("restores the persisted workspace even when the first online host is different", async () => {
state.anyOnlineHostServerId = "server-2";
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
await renderIndex();
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
currentPathname: "/",
});
expect(redirectMock).not.toHaveBeenCalled();
});
it("navigates to the host root when no persisted workspace exists", async () => {
+6 -4
View File
@@ -8,7 +8,8 @@ import {
} from "@/app/host-runtime-bootstrap";
import {
navigateToWorkspace,
useActiveWorkspaceSelection,
useIsLastWorkspaceSelectionHydrated,
useLastWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
@@ -18,20 +19,21 @@ export default function Index() {
const pathname = usePathname();
const bootstrapState = useHostRuntimeBootstrapState();
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const workspaceSelection = useActiveWorkspaceSelection();
const workspaceSelection = useLastWorkspaceSelection();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
const redirectRoute = resolveStartupRedirectRoute({
pathname,
anyOnlineHostServerId,
workspaceSelection,
isWorkspaceSelectionLoaded: true,
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
pathname,
anyOnlineHostServerId,
workspaceSelection,
isWorkspaceSelectionLoaded: true,
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
@@ -33,9 +33,9 @@ import {
import { getProviderIcon } from "@/components/provider-icons";
import { CombinedModelSelector } from "@/components/combined-model-selector";
import {
buildModelSelectorProviders,
type ModelSelectorProvider,
} from "@/components/combined-model-selector.utils";
buildProviderSelectorProviders,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { useSessionStore } from "@/stores/session-store";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
@@ -97,7 +97,7 @@ interface ControlledAgentStatusBarProps {
disabled?: boolean;
isModelLoading?: boolean;
providerDefinitions: AgentProviderDefinition[];
modelSelectorProviders?: ModelSelectorProvider[];
modelSelectorProviders?: ProviderSelectorProvider[];
favoriteKeys?: Set<string>;
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
features?: AgentFeature[];
@@ -117,7 +117,7 @@ export interface DraftAgentStatusBarProps {
selectedModel: string;
onSelectModel: (modelId: string) => void;
isModelLoading: boolean;
modelSelectorProviders: ModelSelectorProvider[];
modelSelectorProviders: ProviderSelectorProvider[];
isAllModelsLoading: boolean;
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
@@ -237,7 +237,7 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[
function buildFallbackModelSelectorProviders(
provider: string,
modelOptions: StatusOption[] | undefined,
): ModelSelectorProvider[] {
): ProviderSelectorProvider[] {
if (!modelOptions || modelOptions.length === 0) {
return [];
}
@@ -245,13 +245,16 @@ function buildFallbackModelSelectorProviders(
{
id: provider,
label: provider,
rows: modelOptions.map((option) => ({
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
provider,
providerLabel: provider,
modelId: option.id,
modelLabel: option.label,
})),
modelSelection: {
kind: "models",
rows: modelOptions.map((option) => ({
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
provider,
providerLabel: provider,
modelId: option.id,
modelLabel: option.label,
})),
},
},
];
}
@@ -794,7 +797,7 @@ interface DesktopStatusBarContentProps {
canSelectMode: boolean;
canSelectModel: boolean;
canSelectThinking: boolean;
modelSelectorProviders: ModelSelectorProvider[];
modelSelectorProviders: ProviderSelectorProvider[];
modelDisabled: boolean;
comboboxProviderOptions: ComboboxOption[];
comboboxModeOptions: ComboboxOption[];
@@ -1058,7 +1061,7 @@ interface SheetStatusBarContentProps {
canSelectMode: boolean;
canSelectModel: boolean;
canSelectThinking: boolean;
modelSelectorProviders: ModelSelectorProvider[];
modelSelectorProviders: ProviderSelectorProvider[];
modelDisabled: boolean;
comboboxModeOptions: ComboboxOption[];
comboboxThinkingOptions: ComboboxOption[];
@@ -1662,7 +1665,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
[agent?.provider, models],
);
const agentModelSelectorProviders = useMemo(
() => buildModelSelectorProviders(agentProviderDefinitions, agentProviderModels),
() =>
buildProviderSelectorProviders({
providerDefinitions: agentProviderDefinitions,
modelsByProvider: agentProviderModels,
}),
[agentProviderDefinitions, agentProviderModels],
);
@@ -47,9 +47,13 @@ import { getProviderIcon } from "@/components/provider-icons";
import {
buildSelectedTriggerLabel,
filterAndRankModelRows,
type ModelSelectorProvider,
type SelectorModelRow,
} from "./combined-model-selector.utils";
getAllProviderModelRows,
getProviderDefaultLabel,
getProviderModelRows,
resolveSelectedModelLabel,
type ProviderSelectionModelRow,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
// TODO: this should be configured per provider in the provider manifest
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
@@ -59,7 +63,7 @@ type SelectorView =
| { kind: "provider"; providerId: string; providerLabel: string };
interface CombinedModelSelectorProps {
providers: ModelSelectorProvider[];
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
@@ -79,7 +83,7 @@ interface CombinedModelSelectorProps {
interface SelectorContentProps {
view: SelectorView;
providers: ModelSelectorProvider[];
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
searchQuery: string;
@@ -89,23 +93,16 @@ interface SelectorContentProps {
onDrillDown: (providerId: string, providerLabel: string) => void;
}
function resolveDefaultModelLabel(rows: SelectorModelRow[] | undefined): string {
if (!rows || rows.length === 0) {
return "Select model";
}
return (rows.find((row) => row.isDefault) ?? rows[0])?.modelLabel ?? "Select model";
}
function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
function sortFavoritesFirst(
rows: SelectorModelRow[],
rows: ProviderSelectionModelRow[],
favoriteKeys: Set<string>,
): SelectorModelRow[] {
const favorites: SelectorModelRow[] = [];
const rest: SelectorModelRow[] = [];
): ProviderSelectionModelRow[] {
const favorites: ProviderSelectionModelRow[] = [];
const rest: ProviderSelectionModelRow[] = [];
for (const row of rows) {
if (favoriteKeys.has(row.favoriteKey)) {
favorites.push(row);
@@ -124,7 +121,7 @@ function ModelRow({
onPress,
onToggleFavorite,
}: {
row: SelectorModelRow;
row: ProviderSelectionModelRow;
isSelected: boolean;
isFavorite: boolean;
elevated?: boolean;
@@ -200,7 +197,7 @@ function ModelRow({
}
interface SelectableModelRowProps {
row: SelectorModelRow;
row: ProviderSelectionModelRow;
isSelected: boolean;
isFavorite: boolean;
elevated?: boolean;
@@ -239,7 +236,7 @@ function FavoritesSection({
onSelect,
onToggleFavorite,
}: {
favoriteRows: SelectorModelRow[];
favoriteRows: ProviderSelectionModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
@@ -274,7 +271,7 @@ interface GroupProviderButtonProps {
providerId: string;
providerLabel: string;
rowCount: number;
hasNoModels: boolean;
defaultLabel: string | null;
onDrillDown: (providerId: string, providerLabel: string) => void;
onSelectDefault: (providerId: string) => void;
}
@@ -283,28 +280,28 @@ function GroupProviderButton({
providerId,
providerLabel,
rowCount,
hasNoModels,
defaultLabel,
onDrillDown,
onSelectDefault,
}: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(providerId);
const handlePress = useCallback(() => {
if (hasNoModels) {
if (defaultLabel) {
onSelectDefault(providerId);
return;
}
onDrillDown(providerId, providerLabel);
}, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]);
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
return (
<Pressable onPress={handlePress} style={drillDownRowStyle}>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>
{hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
</Text>
{hasNoModels ? null : (
{defaultLabel ? null : (
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</View>
@@ -317,22 +314,23 @@ function GroupedProviderRows({
onDrillDown,
onSelectDefault,
}: {
providers: ModelSelectorProvider[];
providers: ProviderSelectorProvider[];
onDrillDown: (providerId: string, providerLabel: string) => void;
onSelectDefault: (providerId: string) => void;
}) {
return (
<View>
{providers.map((provider, index) => {
const hasNoModels = provider.rows.length === 0;
const rows = getProviderModelRows(provider);
const defaultLabel = getProviderDefaultLabel(provider);
return (
<View key={provider.id}>
{index > 0 ? <View style={styles.separator} /> : null}
<GroupProviderButton
providerId={provider.id}
providerLabel={provider.label}
rowCount={provider.rows.length}
hasNoModels={hasNoModels}
rowCount={rows.length}
defaultLabel={defaultLabel}
onDrillDown={onDrillDown}
onSelectDefault={onSelectDefault}
/>
@@ -381,7 +379,7 @@ function ProviderModelRows({
onToggleFavorite,
normalizedQuery,
}: {
rows: SelectorModelRow[];
rows: ProviderSelectionModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
@@ -396,7 +394,7 @@ function ProviderModelRows({
[favoriteKeys, normalizedQuery, rows],
);
const renderItem = useCallback(
({ item }: { item: SelectorModelRow }) => (
({ item }: { item: ProviderSelectionModelRow }) => (
<SelectableModelRow
row={item}
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
@@ -407,7 +405,7 @@ function ProviderModelRows({
),
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
);
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []);
if (useVirtualizedList) {
return (
@@ -455,15 +453,12 @@ function SelectorContent({
const visibleRows = useMemo(
() =>
selectedViewProvider
? filterAndRankModelRows(selectedViewProvider.rows, normalizedQuery)
? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery)
: [],
[normalizedQuery, selectedViewProvider],
);
const favoriteRows = useMemo(
() =>
providers.flatMap((provider) =>
provider.rows.filter((row) => favoriteKeys.has(row.favoriteKey)),
),
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
[favoriteKeys, providers],
);
const handleSelectDefaultProvider = useCallback(
@@ -485,7 +480,7 @@ function SelectorContent({
return emptyState;
}
if (selectedViewProvider.rows.length === 0 && !normalizedQuery) {
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
return (
<DefaultProviderRow
providerId={view.providerId}
@@ -607,30 +602,20 @@ export function CombinedModelSelector({
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
const selectedModelLabel = useMemo(() => {
if (!selectedModel) {
if (!hasSelectedProvider) {
return "Select model";
}
const provider = providers.find((entry) => entry.id === selectedProvider);
if (provider?.rows.length === 0) {
return "Default";
}
return isLoading ? "Loading..." : "Select model";
}
const provider = providers.find((entry) => entry.id === selectedProvider);
if (!provider) {
return isLoading ? "Loading..." : "Select model";
}
const model = provider.rows.find((entry) => entry.modelId === selectedModel);
return model?.modelLabel ?? resolveDefaultModelLabel(provider.rows);
}, [hasSelectedProvider, isLoading, providers, selectedModel, selectedProvider]);
return resolveSelectedModelLabel({
providers,
selectedProvider,
selectedModel,
isLoading,
});
}, [isLoading, providers, selectedModel, selectedProvider]);
const desktopFixedHeight = useMemo(() => {
if (view.kind !== "provider") {
return undefined;
}
const modelCount =
providers.find((provider) => provider.id === view.providerId)?.rows.length ?? 0;
const provider = providers.find((entry) => entry.id === view.providerId);
const modelCount = provider ? getProviderModelRows(provider).length : 0;
return Math.min(80 + modelCount * 40, 400);
}, [providers, view]);
@@ -1,90 +0,0 @@
import type {
AgentModelDefinition,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
export type SelectorModelRow = FavoriteModelRow & { isDefault?: boolean };
export interface ModelSelectorProvider {
id: string;
label: string;
rows: SelectorModelRow[];
}
export function buildSelectedTriggerLabel(modelLabel: string): string {
return modelLabel;
}
export function buildModelSelectorProviders(
providerDefinitions: AgentProviderDefinition[],
allProviderModels: Map<string, AgentModelDefinition[]>,
): ModelSelectorProvider[] {
return providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
rows: (allProviderModels.get(definition.id) ?? []).map((model) => ({
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
provider: definition.id,
providerLabel: definition.label,
modelId: model.id,
modelLabel: model.label,
description: model.description,
isDefault: model.isDefault,
})),
}));
}
export function buildSelectableModelSelectorProviders(
entries: ProviderSnapshotEntry[] | undefined,
): ModelSelectorProvider[] {
return (entries ?? [])
.filter((entry) => entry.enabled && entry.status === "ready")
.map((entry) => ({
id: entry.provider,
label: entry.label ?? entry.provider,
rows: (entry.models ?? []).map((model) => ({
favoriteKey: buildFavoriteModelKey({ provider: entry.provider, modelId: model.id }),
provider: entry.provider,
providerLabel: entry.label ?? entry.provider,
modelId: model.id,
modelLabel: model.label,
description: model.description,
isDefault: model.isDefault,
})),
}));
}
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
return scoreModelRow(row, normalizedQuery) !== null;
}
function getModelRowSearchFields(row: SelectorModelRow): string[] {
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
}
export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) {
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
}
export function filterAndRankModelRows(
rows: SelectorModelRow[],
normalizedQuery: string,
): SelectorModelRow[] {
if (!normalizedQuery) return rows;
const scored = rows
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
.filter((entry): entry is { row: SelectorModelRow; score: NonNullable<typeof entry.score> } =>
Boolean(entry.score),
);
scored.sort((a, b) => {
const cmp = compareMatchScores(a.score, b.score);
if (cmp !== 0) return cmp;
return a.row.modelLabel.localeCompare(b.row.modelLabel);
});
return scored.map((entry) => entry.row);
}
@@ -0,0 +1,58 @@
import type { StyleProp, TextStyle } from "react-native";
import { useMemo } from "react";
import { TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
interface SettingsTextAreaProps {
accessibilityLabel: string;
value: string;
onChangeText: (text: string) => void;
placeholder?: string;
testID?: string;
style?: StyleProp<TextStyle>;
}
export function SettingsTextArea({
accessibilityLabel,
value,
onChangeText,
placeholder,
testID,
style,
}: SettingsTextAreaProps) {
const { theme } = useUnistyles();
const inputStyle = useMemo(() => [styles.input, style], [style]);
return (
<TextInput
testID={testID}
accessibilityLabel={accessibilityLabel}
multiline
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
style={inputStyle}
/>
);
}
export function SettingsTextAreaCard(props: SettingsTextAreaProps) {
return (
<View style={settingsStyles.card}>
<SettingsTextArea {...props} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
input: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
minHeight: 96,
textAlignVertical: "top",
},
}));
@@ -9,9 +9,9 @@ import type {
import { useHosts } from "@/runtime/host-runtime";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import {
buildSelectableModelSelectorProviders,
type ModelSelectorProvider,
} from "@/components/combined-model-selector.utils";
buildSelectableProviderSelectorProviders,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { useProvidersSnapshot } from "./use-providers-snapshot";
import {
useFormPreferences,
@@ -32,9 +32,9 @@ import {
SELECTABLE_PROVIDER_STATUSES,
type FormInitialValues,
type FormState,
} from "./resolve-agent-form";
} from "@/provider-selection/resolve-agent-form";
export type { FormInitialValues } from "./resolve-agent-form";
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
export interface UseAgentFormStateOptions {
initialServerId?: string | null;
@@ -67,7 +67,7 @@ export interface UseAgentFormStateResult {
modeOptions: AgentMode[];
availableModels: AgentModelDefinition[];
allProviderModels: Map<string, AgentModelDefinition[]>;
modelSelectorProviders: ModelSelectorProvider[];
modelSelectorProviders: ProviderSelectorProvider[];
isAllModelsLoading: boolean;
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
isModelLoading: boolean;
@@ -243,7 +243,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[snapshotEntries],
);
const snapshotModelSelectorProviders = useMemo(
() => buildSelectableModelSelectorProviders(snapshotEntries),
() => buildSelectableProviderSelectorProviders(snapshotEntries),
[snapshotEntries],
);
const snapshotSelectedEntry = useMemo(
@@ -1,8 +1,6 @@
import type { UserComposerAttachment } from "@/attachments/types";
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
export interface DraftKeyContext {
selectedServerId: string | null;
@@ -20,56 +18,6 @@ export function resolveDraftKey(input: {
return input.draftKey;
}
export function resolveEffectiveComposerModelId(input: {
selectedModel: string;
availableModels: AgentModelDefinition[];
}): string {
return input.selectedModel.trim();
}
export function resolveEffectiveComposerThinkingOptionId(input: {
selectedThinkingOptionId: string;
availableModels: AgentModelDefinition[];
effectiveModelId: string;
}): string {
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
if (selectedThinkingOptionId) {
return selectedThinkingOptionId;
}
const selectedModelDefinition =
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
export function buildDraftComposerCommandConfig(input: {
provider: AgentProvider | null;
cwd: string;
modeOptions: DraftAgentStatusBarProps["modeOptions"];
selectedMode: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
const cwd = input.cwd.trim();
if (!input.provider || !cwd) {
return undefined;
}
return {
provider: input.provider,
cwd,
...(input.modeOptions.length > 0 && input.selectedMode !== ""
? { modeId: input.selectedMode }
: {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
}
export function buildDraftStatusControls(input: {
formState: UseAgentFormStateResult;
features?: DraftAgentStatusBarProps["features"];
@@ -85,16 +85,19 @@ vi.mock("./use-agent-form-state", () => ({
{
id: "codex",
label: "Codex",
rows: [
{
favoriteKey: "codex:gpt-5.4",
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "gpt-5.4",
isDefault: true,
},
],
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: "codex:gpt-5.4",
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "gpt-5.4",
isDefault: true,
},
],
},
},
],
isAllModelsLoading: false,
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { resolveDraftKey } from "./use-agent-input-draft-core";
import {
buildDraftComposerCommandConfig,
resolveDraftKey,
buildDraftCommandConfig,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
} from "./use-agent-input-draft-core";
} from "@/provider-selection/provider-selection";
describe("resolveDraftKey", () => {
it("returns a string draft key unchanged", () => {
@@ -30,8 +30,12 @@ describe("resolveEffectiveComposerModelId", () => {
it("returns the selected model trimmed", () => {
expect(
resolveEffectiveComposerModelId({
selectedModel: " gpt-5.4-mini ",
provider: "codex",
modelId: " gpt-5.4-mini ",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
}),
).toBe("gpt-5.4-mini");
});
@@ -39,8 +43,12 @@ describe("resolveEffectiveComposerModelId", () => {
it("returns empty string when no model selected", () => {
expect(
resolveEffectiveComposerModelId({
selectedModel: "",
provider: "codex",
modelId: "",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
}),
).toBe("");
});
@@ -63,21 +71,33 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
it("prefers the selected thinking option when present", () => {
expect(
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "medium",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
resolveEffectiveComposerThinkingOptionId(
{
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "medium",
availableModels: models,
modeOptions: [],
},
"gpt-5.4",
),
).toBe("medium");
});
it("falls back to the model default thinking option", () => {
expect(
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: "",
availableModels: models,
effectiveModelId: "gpt-5.4",
}),
resolveEffectiveComposerThinkingOptionId(
{
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "",
availableModels: models,
modeOptions: [],
},
"gpt-5.4",
),
).toBe("high");
});
});
@@ -85,11 +105,16 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
describe("buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
expect(
buildDraftComposerCommandConfig({
provider: "codex",
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
},
cwd: " ",
modeOptions: [],
selectedMode: "",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
@@ -98,11 +123,16 @@ describe("buildDraftComposerCommandConfig", () => {
it("builds the draft command config from derived composer state", () => {
expect(
buildDraftComposerCommandConfig({
provider: "codex",
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "auto",
thinkingOptionId: "high",
availableModels: [],
modeOptions: [{ id: "auto", label: "Auto" }],
},
cwd: "/repo",
modeOptions: [{ id: "auto", label: "Auto" }],
selectedMode: "auto",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
+34 -24
View File
@@ -10,14 +10,17 @@ import {
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
areAttachmentsEqual,
buildDraftComposerCommandConfig,
buildDraftStatusControls,
hasDraftContent,
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type DraftKeyInput,
} from "@/hooks/use-agent-input-draft-core";
import {
buildDraftCommandConfig,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type ProviderSelectionState,
} from "@/provider-selection/provider-selection";
import { useDraftStore } from "@/stores/draft-store";
type AttachmentUpdater =
@@ -199,23 +202,33 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
formState.setWorkingDir(lockedWorkingDir);
}, [composerOptions, formState, lockedWorkingDir]);
const providerSelection = useMemo<ProviderSelectionState>(
() => ({
provider: formState.selectedProvider,
modelId: formState.selectedModel,
modeId: formState.selectedMode,
thinkingOptionId: formState.selectedThinkingOptionId,
availableModels: formState.availableModels,
modeOptions: formState.modeOptions,
}),
[
formState.availableModels,
formState.modeOptions,
formState.selectedMode,
formState.selectedModel,
formState.selectedProvider,
formState.selectedThinkingOptionId,
],
);
const effectiveModelId = useMemo(
() =>
resolveEffectiveComposerModelId({
selectedModel: formState.selectedModel,
availableModels: formState.availableModels,
}),
[formState.availableModels, formState.selectedModel],
() => resolveEffectiveComposerModelId(providerSelection),
[providerSelection],
);
const effectiveThinkingOptionId = useMemo(
() =>
resolveEffectiveComposerThinkingOptionId({
selectedThinkingOptionId: formState.selectedThinkingOptionId,
availableModels: formState.availableModels,
effectiveModelId,
}),
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
() => resolveEffectiveComposerThinkingOptionId(providerSelection, effectiveModelId),
[effectiveModelId, providerSelection],
);
const workingDir = lockedWorkingDir || formState.workingDir;
@@ -236,11 +249,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
const commandDraftConfig = useMemo(
() =>
composerOptions
? buildDraftComposerCommandConfig({
provider: formState.selectedProvider,
? buildDraftCommandConfig({
selection: providerSelection,
cwd: workingDir,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
@@ -251,10 +262,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
providerSelection,
workingDir,
formState.modeOptions,
formState.selectedMode,
formState.selectedProvider,
],
);
@@ -303,6 +312,7 @@ export const __private__ = {
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
buildDraftComposerCommandConfig,
buildDraftCommandConfig,
buildDraftComposerCommandConfig: buildDraftCommandConfig,
buildDraftStatusControls,
};
@@ -5,12 +5,14 @@ import type {
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import {
buildModelSelectorProviders,
buildSelectableModelSelectorProviders,
buildProviderSelectorProviders,
buildSelectableProviderSelectorProviders,
buildSelectedTriggerLabel,
filterAndRankModelRows,
matchesSearch,
} from "./combined-model-selector.utils";
matchesModelSearch,
resolveSelectedModelLabel,
resolveSubmissionReadiness,
} from "./provider-selection";
describe("combined model selector data", () => {
const codexModel: AgentModelDefinition = {
@@ -37,7 +39,7 @@ describe("combined model selector data", () => {
it("builds selector providers from ready enabled snapshot entries", () => {
expect(
buildSelectableModelSelectorProviders([
buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codex",
label: "Codex",
@@ -48,24 +50,27 @@ describe("combined model selector data", () => {
{
id: "codex",
label: "Codex",
rows: [
{
favoriteKey: "codex:gpt-5.4",
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
description: undefined,
isDefault: undefined,
},
],
modelSelection: {
kind: "models",
rows: [
{
favoriteKey: "codex:gpt-5.4",
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
description: undefined,
isDefault: undefined,
},
],
},
},
]);
});
it("keeps ready enabled providers with no models as model-less providers", () => {
it("represents ready enabled providers without explicit models as provider-default selection", () => {
expect(
buildSelectableModelSelectorProviders([
buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "deepseek-tui",
label: "DeepSeek TUI",
@@ -76,14 +81,14 @@ describe("combined model selector data", () => {
{
id: "deepseek-tui",
label: "DeepSeek TUI",
rows: [],
modelSelection: { kind: "providerDefault", label: "Default" },
},
]);
});
it("excludes disabled providers from selector data", () => {
expect(
buildSelectableModelSelectorProviders([
buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "deepseek-tui",
label: "DeepSeek TUI",
@@ -96,7 +101,7 @@ describe("combined model selector data", () => {
it("excludes providers that are not ready", () => {
expect(
buildSelectableModelSelectorProviders([
buildSelectableProviderSelectorProviders([
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
@@ -116,19 +121,25 @@ describe("combined model selector data", () => {
];
expect(
buildModelSelectorProviders(providerDefinitions, new Map([["codex", [codexModel]]])),
buildProviderSelectorProviders({
providerDefinitions,
modelsByProvider: new Map([["codex", [codexModel]]]),
}),
).toEqual([
{
id: "codex",
label: "Codex",
rows: [
expect.objectContaining({
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
}),
],
modelSelection: {
kind: "models",
rows: [
expect.objectContaining({
provider: "codex",
providerLabel: "Codex",
modelId: "gpt-5.4",
modelLabel: "GPT-5.4",
}),
],
},
},
]);
});
@@ -143,10 +154,10 @@ describe("combined model selector data", () => {
description: "OpenCode Zen - kimi",
};
expect(matchesSearch(row, "kimi zen")).toBe(true);
expect(matchesSearch(row, "zen kimi")).toBe(true);
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
expect(matchesSearch(row, "kimi gemini")).toBe(false);
expect(matchesModelSearch(row, "kimi zen")).toBe(true);
expect(matchesModelSearch(row, "zen kimi")).toBe(true);
expect(matchesModelSearch(row, "k2.5 zen")).toBe(true);
expect(matchesModelSearch(row, "kimi gemini")).toBe(false);
});
it("ranks model search results by fuzzy match quality", () => {
@@ -180,4 +191,75 @@ describe("combined model selector data", () => {
it("keeps the selected trigger label model-only", () => {
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
});
it("resolves selected labels from explicit provider model-selection state", () => {
const providers = buildSelectableProviderSelectorProviders([
snapshotEntry({
provider: "codex",
label: "Codex",
models: [codexModel],
}),
snapshotEntry({
provider: "deepseek-tui",
label: "DeepSeek TUI",
models: [],
}),
]);
expect(
resolveSelectedModelLabel({
providers,
selectedProvider: "codex",
selectedModel: "gpt-5.4",
isLoading: false,
}),
).toBe("GPT-5.4");
expect(
resolveSelectedModelLabel({
providers,
selectedProvider: "deepseek-tui",
selectedModel: "",
isLoading: false,
}),
).toBe("Default");
});
it("returns observable submission readiness reasons", () => {
expect(
resolveSubmissionReadiness({
text: "hello",
allowsEmptyAutoSubmit: false,
providerCount: 1,
selection: {
provider: "codex",
modelId: "",
availableModels: [codexModel],
isModelLoading: false,
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
hasClient: true,
}),
).toEqual({
ok: false,
reason: "No model is available for the selected provider",
});
expect(
resolveSubmissionReadiness({
text: "hello",
allowsEmptyAutoSubmit: false,
providerCount: 1,
selection: {
provider: "deepseek-tui",
modelId: "",
availableModels: [],
isModelLoading: false,
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
hasClient: true,
}),
).toEqual({ ok: true });
});
});
@@ -0,0 +1,284 @@
import type {
AgentMode,
AgentModelDefinition,
AgentProvider,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean };
export type ProviderModelSelection =
| { kind: "models"; rows: ProviderSelectionModelRow[] }
| { kind: "providerDefault"; label: string }
| { kind: "loading" }
| { kind: "unavailable"; reason: string };
export interface ProviderSelectorProvider {
id: string;
label: string;
modelSelection: ProviderModelSelection;
}
export interface ProviderSelectionState {
provider: AgentProvider | null;
modelId: string;
modeId: string;
thinkingOptionId: string;
availableModels: AgentModelDefinition[];
modeOptions: AgentMode[];
}
export interface ProviderSelectionReadiness {
ok: boolean;
reason?: string;
}
function buildModelRows(
provider: string,
providerLabel: string,
models: AgentModelDefinition[],
): ProviderSelectionModelRow[] {
return models.map((model) => ({
favoriteKey: buildFavoriteModelKey({ provider, modelId: model.id }),
provider,
providerLabel,
modelId: model.id,
modelLabel: model.label,
description: model.description,
isDefault: model.isDefault,
}));
}
function buildModelSelection(
provider: string,
providerLabel: string,
models: AgentModelDefinition[] | null,
): ProviderModelSelection {
if (models === null) {
return { kind: "loading" };
}
if (models.length === 0) {
return { kind: "providerDefault", label: "Default" };
}
return { kind: "models", rows: buildModelRows(provider, providerLabel, models) };
}
function isSelectableProvider(entry: ProviderSnapshotEntry): boolean {
return entry.enabled && entry.status === "ready";
}
export function buildProviderSelectorProviders(input: {
providerDefinitions: AgentProviderDefinition[];
modelsByProvider: Map<string, AgentModelDefinition[]>;
}): ProviderSelectorProvider[] {
return input.providerDefinitions.map((definition) => ({
id: definition.id,
label: definition.label,
modelSelection: buildModelSelection(
definition.id,
definition.label,
input.modelsByProvider.has(definition.id)
? (input.modelsByProvider.get(definition.id) ?? [])
: null,
),
}));
}
export function buildSelectableProviderSelectorProviders(
entries: ProviderSnapshotEntry[] | undefined,
): ProviderSelectorProvider[] {
return (entries ?? []).filter(isSelectableProvider).map((entry) => {
const label = entry.label ?? entry.provider;
return {
id: entry.provider,
label,
modelSelection: buildModelSelection(entry.provider, label, entry.models ?? []),
};
});
}
export function getProviderModelRows(
provider: ProviderSelectorProvider,
): ProviderSelectionModelRow[] {
return provider.modelSelection.kind === "models" ? provider.modelSelection.rows : [];
}
export function getAllProviderModelRows(
providers: ProviderSelectorProvider[],
): ProviderSelectionModelRow[] {
return providers.flatMap(getProviderModelRows);
}
export function getProviderDefaultLabel(provider: ProviderSelectorProvider): string | null {
return provider.modelSelection.kind === "providerDefault" ? provider.modelSelection.label : null;
}
export function resolveSelectedModelLabel(input: {
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
isLoading: boolean;
}): string {
const selectedProvider = input.selectedProvider.trim();
if (!selectedProvider) {
return "Select model";
}
const provider = input.providers.find((entry) => entry.id === selectedProvider);
if (!input.selectedModel) {
if (provider?.modelSelection.kind === "providerDefault") {
return provider.modelSelection.label;
}
if (provider?.modelSelection.kind === "loading") {
return "Loading...";
}
return input.isLoading ? "Loading..." : "Select model";
}
if (!provider) {
return input.isLoading ? "Loading..." : "Select model";
}
if (provider.modelSelection.kind !== "models") {
return provider.modelSelection.kind === "providerDefault"
? provider.modelSelection.label
: "Select model";
}
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
const defaultModel = provider.modelSelection.rows.find((row) => row.isDefault);
return (
model?.modelLabel ??
defaultModel?.modelLabel ??
provider.modelSelection.rows[0]?.modelLabel ??
"Select model"
);
}
export function buildSelectedTriggerLabel(modelLabel: string): string {
return modelLabel;
}
export function matchesModelSearch(
row: ProviderSelectionModelRow,
normalizedQuery: string,
): boolean {
return scoreModelRow(row, normalizedQuery) !== null;
}
function getModelRowSearchFields(row: ProviderSelectionModelRow): string[] {
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
}
export function scoreModelRow(row: ProviderSelectionModelRow, normalizedQuery: string) {
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
}
export function filterAndRankModelRows(
rows: ProviderSelectionModelRow[],
normalizedQuery: string,
): ProviderSelectionModelRow[] {
if (!normalizedQuery) return rows;
const scored = rows
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
.filter(
(
entry,
): entry is { row: ProviderSelectionModelRow; score: NonNullable<typeof entry.score> } =>
Boolean(entry.score),
);
scored.sort((a, b) => {
const cmp = compareMatchScores(a.score, b.score);
if (cmp !== 0) return cmp;
return a.row.modelLabel.localeCompare(b.row.modelLabel);
});
return scored.map((entry) => entry.row);
}
export function resolveEffectiveComposerModelId(selection: ProviderSelectionState): string {
return selection.modelId.trim();
}
export function resolveEffectiveComposerThinkingOptionId(
selection: ProviderSelectionState,
effectiveModelId: string,
): string {
const selectedThinkingOptionId = selection.thinkingOptionId.trim();
if (selectedThinkingOptionId) {
return selectedThinkingOptionId;
}
const selectedModelDefinition =
selection.availableModels.find((model) => model.id === effectiveModelId) ?? null;
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
}
export function buildDraftCommandConfig(input: {
selection: ProviderSelectionState;
cwd: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
featureValues?: Record<string, unknown>;
}): DraftCommandConfig | undefined {
const cwd = input.cwd.trim();
if (!input.selection.provider || !cwd) {
return undefined;
}
return {
provider: input.selection.provider,
cwd,
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
? { modeId: input.selection.modeId }
: {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
}
export function resolveSubmissionReadiness(input: {
text: string;
allowsEmptyAutoSubmit: boolean;
providerCount: number;
selection: {
provider: AgentProvider | string | null;
modelId: string;
availableModels: readonly unknown[];
isModelLoading: boolean;
};
autoSubmitConfig: { provider: string; model: string | null } | null;
workspaceDirectory: string | null;
hasClient: boolean;
}): ProviderSelectionReadiness {
if (!input.allowsEmptyAutoSubmit && !input.text.trim()) {
return { ok: false, reason: "Initial prompt is required" };
}
if (input.providerCount === 0) {
return { ok: false, reason: "No available providers on the selected host" };
}
if (!(input.autoSubmitConfig?.provider ?? input.selection.provider)) {
return { ok: false, reason: "Select a model" };
}
if (input.selection.isModelLoading) {
return { ok: false, reason: "Model defaults are still loading" };
}
const hasSelectedModel = Boolean(input.autoSubmitConfig?.model ?? input.selection.modelId);
if (!hasSelectedModel && input.selection.availableModels.length > 0) {
return { ok: false, reason: "No model is available for the selected provider" };
}
if (!input.workspaceDirectory) {
return { ok: false, reason: "Workspace directory not found" };
}
if (!input.hasClient) {
return { ok: false, reason: "Host is not connected" };
}
return { ok: true };
}
@@ -8,7 +8,7 @@ import {
mergeProviderPreferences,
type FormPreferences,
type ProviderPreferences,
} from "./use-form-preferences";
} from "@/hooks/use-form-preferences";
export interface FormInitialValues {
serverId?: string | null;
@@ -23,6 +23,7 @@ import { ExternalLink } from "@/components/ui/external-link";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
@@ -621,18 +622,13 @@ function ProjectConfigForm({
testID="worktree-group"
>
<SettingsSection title="Setup" testID="worktree-setup-section" trailing={setupDocsLink}>
<View style={settingsStyles.card}>
<TextInput
testID="worktree-setup-input"
accessibilityLabel="Worktree setup commands"
multiline
value={draft.setupText}
onChangeText={handleSetupChange}
placeholder="npm install"
placeholderTextColor={styles.placeholderColor.color}
style={styles.lifecycleInput}
/>
</View>
<SettingsTextAreaCard
testID="worktree-setup-input"
accessibilityLabel="Worktree setup commands"
value={draft.setupText}
onChangeText={handleSetupChange}
placeholder="npm install"
/>
</SettingsSection>
<SettingsSection
@@ -641,18 +637,13 @@ function ProjectConfigForm({
trailing={teardownDocsLink}
flush
>
<View style={settingsStyles.card}>
<TextInput
testID="worktree-teardown-input"
accessibilityLabel="Worktree teardown commands"
multiline
value={draft.teardownText}
onChangeText={handleTeardownChange}
placeholder="docker compose down"
placeholderTextColor={styles.placeholderColor.color}
style={styles.lifecycleInput}
/>
</View>
<SettingsTextAreaCard
testID="worktree-teardown-input"
accessibilityLabel="Worktree teardown commands"
value={draft.teardownText}
onChangeText={handleTeardownChange}
placeholder="docker compose down"
/>
</SettingsSection>
</SettingsGroup>
@@ -1008,18 +999,13 @@ function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPr
);
return (
<SettingsSection title={meta.title} testID={meta.sectionTestID} flush={flush}>
<View style={settingsStyles.card}>
<TextInput
testID={meta.inputTestID}
accessibilityLabel={meta.title}
multiline
value={value}
onChangeText={handleChange}
placeholder={meta.placeholder}
placeholderTextColor={styles.placeholderColor.color}
style={styles.lifecycleInput}
/>
</View>
<SettingsTextAreaCard
testID={meta.inputTestID}
accessibilityLabel={meta.title}
value={value}
onChangeText={handleChange}
placeholder={meta.placeholder}
/>
</SettingsSection>
);
}
@@ -1344,14 +1330,6 @@ const styles = StyleSheet.create((theme) => ({
errorBlock: {
marginTop: theme.spacing[2],
},
lifecycleInput: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
minHeight: 96,
textAlignVertical: "top",
},
emptyScripts: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
+117 -4
View File
@@ -4,6 +4,7 @@ import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
@@ -379,9 +380,9 @@ function ConnectionRow({
function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) {
return (
<>
<SettingsSection title="Operations">
<RestartDaemonCard host={host} />
<SettingsSection title="Daemon settings">
<InjectPaseoToolsCard serverId={host.serverId} />
<AppendSystemPromptCard serverId={host.serverId} />
</SettingsSection>
{isLocalDaemon ? (
<SettingsSection title="Pair devices">
@@ -544,9 +545,9 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
<View style={settingsStyles.card} testID="host-page-inject-mcp-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Inject Paseo tools</Text>
<Text style={settingsStyles.rowTitle}>Enable Paseo tools</Text>
<Text style={settingsStyles.rowHint}>
Automatically inject Paseo MCP tools into new agents
Agents will be able to manage worktrees, agents and schedules
</Text>
</View>
<Switch
@@ -559,6 +560,111 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
);
}
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
const isConnected = useHostRuntimeIsConnected(serverId);
const { config, patchConfig } = useDaemonConfig(serverId);
const persistedPrompt = config?.appendSystemPrompt ?? "";
const [draft, setDraft] = useState(persistedPrompt);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const header = useMemo<SheetHeader>(() => ({ title: "Append system prompt" }), []);
useEffect(() => {
setDraft(persistedPrompt);
}, [persistedPrompt]);
const hasChanges = draft !== persistedPrompt;
const handleOpen = useCallback(() => {
setDraft(persistedPrompt);
setIsEditing(true);
}, [persistedPrompt]);
const handleClose = useCallback(() => {
if (isSaving) return;
setDraft(persistedPrompt);
setIsEditing(false);
}, [isSaving, persistedPrompt]);
const handleSave = useCallback(() => {
setIsSaving(true);
void patchConfig({ appendSystemPrompt: draft })
.then(() => {
setIsEditing(false);
return;
})
.catch((error) => {
console.error("[HostPage] Failed to save append system prompt", error);
})
.finally(() => setIsSaving(false));
}, [draft, patchConfig]);
const handleReset = useCallback(() => {
setDraft(persistedPrompt);
}, [persistedPrompt]);
if (!isConnected) return null;
return (
<>
<View style={settingsStyles.card} testID="host-page-append-system-prompt-card">
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>System prompt</Text>
<Text style={settingsStyles.rowHint}>Added a system prompt to all agents</Text>
</View>
<Button
variant="outline"
size="sm"
onPress={handleOpen}
testID="host-page-append-system-prompt-edit"
>
Edit
</Button>
</View>
</View>
{isEditing ? (
<AdaptiveModalSheet
header={header}
visible
onClose={handleClose}
testID="host-page-append-system-prompt-sheet"
desktopMaxWidth={560}
>
<SettingsTextAreaCard
testID="host-page-append-system-prompt-input"
accessibilityLabel="Append system prompt"
value={draft}
onChangeText={setDraft}
placeholder="Always keep replies concise."
/>
<View style={styles.appendPromptActions}>
<Button
variant="ghost"
size="sm"
onPress={handleReset}
disabled={!hasChanges || isSaving}
testID="host-page-append-system-prompt-reset"
>
Reset
</Button>
<Button
variant="default"
size="sm"
onPress={handleSave}
disabled={!hasChanges || isSaving}
testID="host-page-append-system-prompt-save"
>
{isSaving ? "Saving..." : "Save"}
</Button>
</View>
</AdaptiveModalSheet>
) : null}
</>
);
}
function PairDeviceRow() {
const { theme } = useUnistyles();
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -631,6 +737,8 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
return (
<SettingsSection title="Danger zone" testID="host-page-remove-host-card">
<RestartDaemonCard host={host} />
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
@@ -755,6 +863,11 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
appendPromptActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: theme.spacing[2],
},
emptyCard: {
padding: theme.spacing[4],
alignItems: "center",
@@ -202,7 +202,12 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
};
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false };
return {
mcp: { injectIntoAgents: false },
providers,
autoArchiveAfterMerge: false,
appendSystemPrompt: "",
};
}
function descendants(el: HTMLElement): HTMLElement[] {
@@ -1,3 +1,5 @@
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
export interface WorkspaceDraftAutoSubmitConfig {
provider: string;
model: string | null;
@@ -25,27 +27,19 @@ export function validateDraftSubmission(input: {
workspaceDirectory,
hasClient,
} = input;
if (!allowsEmptyAutoSubmit && !text.trim()) {
return "Initial prompt is required";
}
if (composerState.providerDefinitions.length === 0) {
return "No available providers on the selected host";
}
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
return "Select a model";
}
if (composerState.isModelLoading) {
return "Model defaults are still loading";
}
const hasSelectedModel = Boolean(autoSubmitConfig?.model ?? composerState.effectiveModelId);
if (!hasSelectedModel && composerState.availableModels.length > 0) {
return "No model is available for the selected provider";
}
if (!workspaceDirectory) {
return "Workspace directory not found";
}
if (!hasClient) {
return "Host is not connected";
}
return null;
const readiness = resolveSubmissionReadiness({
text,
allowsEmptyAutoSubmit,
providerCount: composerState.providerDefinitions.length,
selection: {
provider: composerState.selectedProvider,
modelId: composerState.effectiveModelId ?? "",
availableModels: composerState.availableModels,
isModelLoading: composerState.isModelLoading,
},
autoSubmitConfig,
workspaceDirectory,
hasClient,
});
return readiness.ok ? null : (readiness.reason ?? null);
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
createLastWorkspaceSelectionStore,
type ActiveWorkspaceSelection,
type LastWorkspaceSelectionStorage,
} from "./last-workspace-selection";
class DelayedWorkspaceSelectionStorage implements LastWorkspaceSelectionStorage {
private finishRead: (value: string | null) => void = () => {};
private readonly pendingRead = new Promise<string | null>((resolve) => {
this.finishRead = resolve;
});
private saved: string | null = null;
read(): Promise<string | null> {
return this.pendingRead;
}
async write(value: string): Promise<void> {
this.saved = value;
}
finishHydrationWith(selection: ActiveWorkspaceSelection | null) {
this.finishRead(selection ? JSON.stringify(selection) : null);
}
getSavedSelection(): ActiveWorkspaceSelection | null {
return this.saved ? (JSON.parse(this.saved) as ActiveWorkspaceSelection) : null;
}
}
describe("last workspace selection", () => {
it("hydrates the saved workspace selection", async () => {
const storage = new DelayedWorkspaceSelectionStorage();
const store = createLastWorkspaceSelectionStore(storage);
const hydration = store.hydrate();
storage.finishHydrationWith({ serverId: "server-saved", workspaceId: "workspace-saved" });
await hydration;
expect(store.getSelection()).toEqual({
serverId: "server-saved",
workspaceId: "workspace-saved",
});
expect(store.isHydrated()).toBe(true);
});
it("keeps a newer workspace selection when storage hydration finishes late", async () => {
const storage = new DelayedWorkspaceSelectionStorage();
const store = createLastWorkspaceSelectionStore(storage);
const hydration = store.hydrate();
store.remember({ serverId: "server-new", workspaceId: "workspace-new" });
storage.finishHydrationWith({ serverId: "server-old", workspaceId: "workspace-old" });
await hydration;
expect(store.getSelection()).toEqual({
serverId: "server-new",
workspaceId: "workspace-new",
});
expect(storage.getSavedSelection()).toEqual({
serverId: "server-new",
workspaceId: "workspace-new",
});
});
});
@@ -0,0 +1,102 @@
export interface ActiveWorkspaceSelection {
serverId: string;
workspaceId: string;
}
export interface LastWorkspaceSelectionStorage {
read(): Promise<string | null>;
write(value: string): Promise<void>;
}
function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return null;
}
const record = input as Record<string, unknown>;
const serverId = typeof record.serverId === "string" ? record.serverId.trim() : "";
const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId.trim() : "";
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
}
function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null {
if (!stored) {
return null;
}
try {
return normalizeWorkspaceSelection(JSON.parse(stored));
} catch {
return null;
}
}
export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectionStorage) {
let selection: ActiveWorkspaceSelection | null = null;
let hydrated = false;
let hydrationPromise: Promise<void> | null = null;
let revision = 0;
const listeners = new Set<() => void>();
function notifyListeners() {
for (const listener of listeners) {
listener();
}
}
function remember(next: ActiveWorkspaceSelection) {
const normalized = normalizeWorkspaceSelection(next);
if (!normalized) {
return;
}
if (
selection?.serverId === normalized.serverId &&
selection.workspaceId === normalized.workspaceId
) {
return;
}
selection = normalized;
revision += 1;
notifyListeners();
void storage.write(JSON.stringify(normalized)).catch(() => {});
}
function hydrate(): Promise<void> {
if (hydrationPromise) {
return hydrationPromise;
}
const hydrationRevision = revision;
hydrationPromise = storage
.read()
.then((stored) => {
if (revision === hydrationRevision) {
selection = parseStoredWorkspaceSelection(stored);
}
return undefined;
})
.catch(() => {
if (revision === hydrationRevision) {
selection = null;
}
})
.finally(() => {
hydrated = true;
notifyListeners();
});
return hydrationPromise;
}
return {
getSelection: () => selection,
hydrate,
isHydrated: () => hydrated,
remember,
subscribe: (listener: () => void): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}
@@ -13,6 +13,10 @@ const pathnameState = vi.hoisted(() => ({
const localParamsState = vi.hoisted(() => ({
value: {} as { serverId?: string | string[]; workspaceId?: string | string[] },
}));
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn(async () => null as string | null),
setItem: vi.fn(async () => undefined),
}));
vi.mock("expo-router", () => ({
router: routerMock,
@@ -20,6 +24,10 @@ vi.mock("expo-router", () => ({
usePathname: () => pathnameState.value,
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
import {
navigateToLastWorkspace,
navigateToWorkspace,
@@ -29,6 +37,7 @@ import {
describe("workspace navigation", () => {
beforeEach(() => {
routerMock.dismissTo.mockReset();
asyncStorageMock.setItem.mockClear();
pathnameState.value = "/";
localParamsState.value = {};
});
@@ -41,6 +50,10 @@ describe("workspace navigation", () => {
navigateToWorkspace("server-1", "workspace-a");
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"paseo:last-workspace-route-selection",
JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }),
);
});
it("reads the active workspace from the current route", () => {
@@ -1,4 +1,11 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { router, useLocalSearchParams, usePathname, type Href } from "expo-router";
import { useEffect, useSyncExternalStore } from "react";
import {
createLastWorkspaceSelectionStore,
type ActiveWorkspaceSelection,
type LastWorkspaceSelectionStorage,
} from "@/stores/last-workspace-selection";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { pickAttentionAgent } from "@/utils/agent-attention";
@@ -12,16 +19,34 @@ import {
resolveWorkspaceMapKeyByIdentity,
} from "@/utils/workspace-execution";
export interface ActiveWorkspaceSelection {
serverId: string;
workspaceId: string;
}
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
interface NavigateToWorkspaceOptions {
currentPathname?: string | null;
}
let lastWorkspaceSelection: ActiveWorkspaceSelection | null = null;
const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
};
const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
lastWorkspaceSelectionStorage,
);
export function hydrateLastWorkspaceSelection(): Promise<void> {
return lastWorkspaceSelectionStore.hydrate();
}
export function getLastWorkspaceSelection(): ActiveWorkspaceSelection | null {
return lastWorkspaceSelectionStore.getSelection();
}
export function getIsLastWorkspaceSelectionHydrated(): boolean {
return lastWorkspaceSelectionStore.isHydrated();
}
function getParamValue(value: string | string[] | undefined): string {
if (typeof value === "string") {
@@ -74,16 +99,17 @@ export function navigateToWorkspace(
});
}
lastWorkspaceSelection = { serverId, workspaceId };
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href;
router.dismissTo(route);
}
export function navigateToLastWorkspace(): boolean {
if (!lastWorkspaceSelection) {
const selection = lastWorkspaceSelectionStore.getSelection();
if (!selection) {
return false;
}
navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId);
navigateToWorkspace(selection.serverId, selection.workspaceId);
return true;
}
@@ -95,8 +121,31 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
const selection =
parseHostWorkspaceRouteFromPathname(usePathname()) ??
parseWorkspaceSelectionFromRouteParams(params);
if (selection) {
lastWorkspaceSelection = selection;
}
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
useEffect(() => {
if (!serverId || !workspaceId) {
return;
}
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
}, [serverId, workspaceId]);
return selection;
}
export function useLastWorkspaceSelection(): ActiveWorkspaceSelection | null {
return useSyncExternalStore(
lastWorkspaceSelectionStore.subscribe,
getLastWorkspaceSelection,
getLastWorkspaceSelection,
);
}
export function useIsLastWorkspaceSelectionHydrated(): boolean {
return useSyncExternalStore(
lastWorkspaceSelectionStore.subscribe,
getIsLastWorkspaceSelectionHydrated,
getIsLastWorkspaceSelectionHydrated,
);
}
void hydrateLastWorkspaceSelection();
+39 -2
View File
@@ -1,5 +1,16 @@
import { describe, expect, it } from "vitest";
import { resolveImportCwd } from "./import.js";
import { describe, expect, it, vi } from "vitest";
import { resolveImportCwd, runImportCommand } from "./import.js";
const importAgent = vi.fn();
const close = vi.fn();
vi.mock("../../utils/client.js", () => ({
connectToDaemon: vi.fn(async () => ({
importAgent,
close,
})),
getDaemonHost: vi.fn(() => "ws://127.0.0.1:6767"),
}));
describe("resolveImportCwd", () => {
it("uses the invoking process cwd when --cwd is omitted", () => {
@@ -19,4 +30,30 @@ describe("resolveImportCwd", () => {
}),
);
});
it("accepts pi as an import provider", async () => {
importAgent.mockResolvedValueOnce({
id: "agent-1",
status: "idle",
provider: "pi",
cwd: "/tmp/project",
title: "Imported Pi session",
});
const result = await runImportCommand(
"pi-session-1",
{
provider: "pi",
cwd: "/tmp/project",
},
{} as never,
);
expect(importAgent).toHaveBeenCalledWith({
provider: "pi",
sessionId: "pi-session-1",
cwd: "/tmp/project",
});
expect(result.data.provider).toBe("pi");
});
});
+5 -3
View File
@@ -5,13 +5,15 @@ import type { CommandError, CommandOptions, SingleResult } from "../../output/in
import { agentRunSchema, type AgentRunResult } from "./run.js";
import type { AgentSnapshotPayload } from "@getpaseo/server";
const IMPORT_PROVIDERS = new Set(["claude", "codex", "opencode", "acp"]);
const IMPORT_PROVIDER_LIST = ["claude", "codex", "opencode", "pi", "acp"] as const;
const IMPORT_PROVIDERS = new Set<string>(IMPORT_PROVIDER_LIST);
const IMPORT_PROVIDER_HELP = IMPORT_PROVIDER_LIST.join(", ");
export function addImportOptions(cmd: Command): Command {
return cmd
.description("Import an existing provider session as a Paseo agent")
.argument("<id>", "Provider session/thread ID to import")
.requiredOption("--provider <provider>", "Agent provider: claude, codex, opencode, or acp")
.requiredOption("--provider <provider>", `Agent provider: ${IMPORT_PROVIDER_HELP}`)
.option("--cwd <path>", "Working directory for providers that require it")
.option(
"--label <key=value>",
@@ -54,7 +56,7 @@ function parseImportProvider(provider: string | undefined): string {
throw {
code: "INVALID_PROVIDER",
message: `Unsupported provider: ${normalizedProvider}`,
details: "Supported providers: claude, codex, opencode, acp",
details: `Supported providers: ${IMPORT_PROVIDER_HELP}`,
} satisfies CommandError;
}
-3
View File
@@ -63,9 +63,6 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@earendil-works/pi-agent-core": "^0.75.3",
"@earendil-works/pi-ai": "^0.75.3",
"@earendil-works/pi-coding-agent": "^0.75.3",
"@getpaseo/highlight": "0.1.78",
"@getpaseo/relay": "0.1.78",
"@isaacs/ttlcache": "^2.1.4",
@@ -100,12 +100,15 @@ function expectArchivedAgentRecord(
class TestAgentClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
readonly createdConfigs: AgentSessionConfig[] = [];
readonly resumeOverrides: Array<Partial<AgentSessionConfig> | undefined> = [];
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.createdConfigs.push(config);
return new TestAgentSession(config);
}
@@ -135,9 +138,11 @@ class TestAgentClient implements AgentClient {
config?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.resumeOverrides.push(config);
return new TestAgentSession({
provider: "codex",
cwd: config?.cwd ?? process.cwd(),
daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt,
});
}
}
@@ -391,6 +396,62 @@ test("normalizeConfig strips legacy 'default' model id", async () => {
expect(snapshot.config.modeId).toBe("auto");
});
test("createAgent injects daemon append system prompt at runtime only", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new TestAgentClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
appendSystemPrompt: " Daemon instructions. ",
idFactory: () => "00000000-0000-4000-8000-000000000103",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
systemPrompt: "Agent instructions.",
});
const record = await storage.get(snapshot.id);
expect(client.createdConfigs[0]?.systemPrompt).toBe("Agent instructions.");
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions.");
expect(snapshot.config.daemonAppendSystemPrompt).toBe("Daemon instructions.");
expect(record?.config?.systemPrompt).toBe("Agent instructions.");
expect(record?.config).not.toHaveProperty("daemonAppendSystemPrompt");
});
test("daemon append system prompt is injected into Pi configs", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const client = new TestAgentClient();
const manager = new AgentManager({
clients: {
pi: client as unknown as AgentClient,
},
providerDefinitions: {
pi: { enabled: true },
},
registry: storage,
logger,
appendSystemPrompt: "Daemon instructions.",
idFactory: () => "00000000-0000-4000-8000-000000000104",
});
await manager.createAgent({
provider: "pi",
cwd: workdir,
systemPrompt: "Agent instructions.",
});
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions.");
});
test("setAgentMode persists the selected mode across session reload", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -173,6 +173,7 @@ export interface AgentManagerOptions {
durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
appendSystemPrompt?: string;
agentStreamCoalesceWindowMs?: number;
rescueTimeouts?: AgentManagerRescueTimeouts;
logger: Logger;
@@ -438,6 +439,7 @@ export class AgentManager {
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private appendSystemPrompt: string;
private onAgentAttention?: AgentAttentionCallback;
private logger: Logger;
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
@@ -448,6 +450,7 @@ export class AgentManager {
this.durableTimelineStore = options?.durableTimelineStore;
this.onAgentAttention = options?.onAgentAttention;
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
this.rescueTimeouts = {
reloadSessionCloseMs:
@@ -502,6 +505,10 @@ export class AgentManager {
this.mcpBaseUrl = url;
}
setAppendSystemPrompt(prompt: string | null | undefined): void {
this.appendSystemPrompt = prompt ?? "";
}
public getMetricsSnapshot(): AgentMetricsSnapshot {
const byLifecycle: Record<string, number> = {};
let withActiveForegroundTurn = 0;
@@ -816,7 +823,9 @@ export class AgentManager {
},
};
this.requireEnabledProvider(injectedConfig.provider);
const normalizedConfig = await this.normalizeConfig(injectedConfig);
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
await this.normalizeConfig(injectedConfig),
);
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = await this.requireAvailableClient({
provider: normalizedConfig.provider,
@@ -860,7 +869,9 @@ export class AgentManager {
...overrides,
provider: handle.provider,
} as AgentSessionConfig;
const normalizedConfig = await this.normalizeConfig(mergedConfig);
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
await this.normalizeConfig(mergedConfig),
);
const resumeOverrides: Partial<AgentSessionConfig> = { ...overrides };
let hasResumeOverrides = overrides !== undefined;
@@ -874,6 +885,11 @@ export class AgentManager {
hasResumeOverrides = true;
}
if (metadata.daemonAppendSystemPrompt !== normalizedConfig.daemonAppendSystemPrompt) {
resumeOverrides.daemonAppendSystemPrompt = normalizedConfig.daemonAppendSystemPrompt;
hasResumeOverrides = true;
}
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
@@ -919,7 +935,9 @@ export class AgentManager {
...overrides,
provider,
} as AgentSessionConfig;
const normalizedConfig = await this.normalizeConfig(refreshConfig);
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
await this.normalizeConfig(refreshConfig),
);
const launchContext = this.buildLaunchContext(agentId);
const session = handle
@@ -1055,6 +1073,7 @@ export class AgentManager {
}
const { archivedAt } = await this.markRecordArchived(stored);
agent.updatedAt = new Date(archivedAt);
await this.closeAgent(agentId);
await this.cascadeArchiveChildren(agentId);
@@ -3437,6 +3456,19 @@ export class AgentManager {
return normalized;
}
private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig {
const daemonAppendSystemPrompt = this.appendSystemPrompt.trim();
const next = { ...config };
delete next.daemonAppendSystemPrompt;
return daemonAppendSystemPrompt
? {
...next,
daemonAppendSystemPrompt,
}
: next;
}
private buildLaunchContext(agentId: string): AgentLaunchContext {
return {
agentId,
@@ -473,6 +473,11 @@ export interface AgentSessionConfig {
* Mapped by each provider to its native instruction field.
*/
systemPrompt?: string;
/**
* Daemon-level instructions appended at runtime. This is deliberately not
* persisted into agent config so daemon setting changes apply cleanly.
*/
daemonAppendSystemPrompt?: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
@@ -1,4 +1,7 @@
import { beforeEach, expect, test, vi } from "vitest";
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js";
@@ -10,6 +13,8 @@ import {
normalizeImportAgentRequest,
} from "./import-sessions.js";
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
const TEST_CAPABILITIES = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -275,6 +280,39 @@ test("listImportableProviderSessions filters out metadata generation sessions",
expect(result.filteredAlreadyImportedCount).toBe(0);
});
test("listImportableProviderSessions keeps realpath-equivalent cwd matches", async () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-import-cwd-"));
const realCwd = path.join(root, "real-project");
const linkedCwd = path.join(root, "linked-project");
mkdirSync(realCwd, { recursive: true });
symlinkSync(realCwd, linkedCwd, directorySymlinkType);
const persistedCwd = realpathSync(linkedCwd);
const result = await listImportableProviderSessions({
request: makeRequest({ cwd: linkedCwd, providers: ["pi"] }),
agentManager: {
listAgents: () => [],
listImportablePersistedAgents: async () => [
makeDescriptor({
provider: "pi",
sessionId: "pi-session",
nativeHandle: "pi-handle",
cwd: persistedCwd,
title: "Pi session",
lastActivityAt: "2026-04-30T12:00:00.000Z",
firstPrompt: "remember this",
}),
],
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
providerRegistry: { pi: { label: "Pi" } },
});
expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["pi-handle"]);
});
test("listImportableProviderSessions rejects invalid since values", async () => {
await expect(
listImportableProviderSessions({
@@ -20,7 +20,7 @@ import type {
RecentProviderSessionDescriptorPayload,
} from "../../shared/messages.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import { createPathEquivalenceMatcher } from "../../utils/path.js";
import { createRealpathAwarePathMatcher } from "../../utils/path.js";
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
@@ -112,7 +112,7 @@ export async function listImportableProviderSessions(
});
let filteredAlreadyImportedCount = 0;
const candidates: PersistedAgentDescriptor[] = [];
const matchesRequestCwd = request.cwd ? createPathEquivalenceMatcher(request.cwd) : null;
const matchesRequestCwd = request.cwd ? createRealpathAwarePathMatcher(request.cwd) : null;
for (const descriptor of descriptors) {
if (matchesRequestCwd && !matchesRequestCwd(descriptor.cwd)) {
continue;
@@ -1987,6 +1987,7 @@ describe("provider listing MCP tool", () => {
});
const tool = registeredTool(server, "list_providers");
const response = await tool.handler({});
const modelVisibleText = String(response.content[0]?.text);
expect(response.structuredContent).toEqual({
providers: [
@@ -2008,6 +2009,9 @@ describe("provider listing MCP tool", () => {
},
],
});
expect(modelVisibleText).toContain("providers_count=2");
expect(modelVisibleText).toContain("providers_ids=claude,zai");
expect(modelVisibleText).toContain('"providers"');
});
it("returns disabled providers with metadata without checking availability", async () => {
+90 -34
View File
@@ -3,7 +3,11 @@ import { z } from "zod";
import { ensureValidJson } from "../json-utils.js";
import type { Logger } from "pino";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
import type {
CallToolResult,
ServerNotification,
ServerRequest,
} from "@modelcontextprotocol/sdk/types.js";
import type { AgentProvider } from "./agent-sdk-types.js";
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
@@ -154,6 +158,54 @@ function mapModeAcrossProviders(
return sourceMode;
}
function addModelVisibleStructuredContent(result: CallToolResult): CallToolResult {
if (result.structuredContent === undefined || result.content.length > 0) {
return result;
}
return {
...result,
content: [
{
type: "text",
text: formatStructuredContentForModel(result.structuredContent),
},
],
};
}
function formatStructuredContentForModel(structuredContent: unknown): string {
if (
!structuredContent ||
typeof structuredContent !== "object" ||
Array.isArray(structuredContent)
) {
return JSON.stringify(structuredContent, null, 2);
}
const record = structuredContent as Record<string, unknown>;
const summary: string[] = [];
for (const [key, value] of Object.entries(record)) {
if (!Array.isArray(value)) {
continue;
}
summary.push(`${key}_count=${value.length}`);
const ids = value
.map((item) =>
item && typeof item === "object" && !Array.isArray(item)
? (item as Record<string, unknown>).id
: null,
)
.filter((id): id is string => typeof id === "string" && id.length > 0);
if (ids.length === value.length && ids.length > 0) {
summary.push(`${key}_ids=${ids.join(",")}`);
}
}
const json = JSON.stringify(structuredContent, null, 2);
return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json;
}
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
function parseTimestamp(value: string | null | undefined): number {
@@ -454,6 +506,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
name: "agent-mcp",
version: "2.0.0",
});
const registerRawTool = server.registerTool.bind(server);
const registerTool: McpServer["registerTool"] = (name, config, handler) =>
registerRawTool(name, config, (async (args: never, extra: never) =>
addModelVisibleStructuredContent(await handler(args, extra))) as typeof handler);
const resolveCallerAgent = () => {
if (!callerAgentId) {
@@ -750,7 +806,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
};
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
server.registerTool(
registerTool(
"speak",
{
title: "Speak",
@@ -954,7 +1010,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
};
};
server.registerTool(
registerTool(
"create_agent",
{
title: "Create agent",
@@ -1099,7 +1155,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"wait_for_agent",
{
title: "Wait for agent",
@@ -1176,7 +1232,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"send_agent_prompt",
{
title: "Send agent prompt",
@@ -1277,7 +1333,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"get_agent_status",
{
title: "Get agent status",
@@ -1327,7 +1383,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_agents",
{
title: "List agents",
@@ -1386,7 +1442,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"cancel_agent",
{
title: "Cancel agent run",
@@ -1410,7 +1466,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"archive_agent",
{
title: "Archive agent",
@@ -1433,7 +1489,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"kill_agent",
{
title: "Kill agent",
@@ -1455,7 +1511,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"update_agent",
{
title: "Update agent",
@@ -1513,7 +1569,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_terminals",
{
title: "List terminals",
@@ -1561,7 +1617,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"create_terminal",
{
title: "Create terminal",
@@ -1596,7 +1652,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"kill_terminal",
{
title: "Kill terminal",
@@ -1627,7 +1683,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"capture_terminal",
{
title: "Capture terminal",
@@ -1671,7 +1727,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"send_terminal_keys",
{
title: "Send terminal keys",
@@ -1707,7 +1763,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"create_schedule",
{
title: "Create schedule",
@@ -1788,7 +1844,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_schedules",
{
title: "List schedules",
@@ -1813,7 +1869,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"inspect_schedule",
{
title: "Inspect schedule",
@@ -1836,7 +1892,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"pause_schedule",
{
title: "Pause schedule",
@@ -1861,7 +1917,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"resume_schedule",
{
title: "Resume schedule",
@@ -1886,7 +1942,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"delete_schedule",
{
title: "Delete schedule",
@@ -1911,7 +1967,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"update_schedule",
{
title: "Update schedule",
@@ -1973,7 +2029,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"schedule_logs",
{
title: "Schedule logs",
@@ -1998,7 +2054,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_providers",
{
title: "List providers",
@@ -2021,7 +2077,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_models",
{
title: "List models",
@@ -2058,7 +2114,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"inspect_provider",
{
title: "Inspect provider",
@@ -2122,7 +2178,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_worktrees",
{
title: "List worktrees",
@@ -2153,7 +2209,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"create_worktree",
{
title: "Create worktree",
@@ -2218,7 +2274,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"archive_worktree",
{
title: "Archive worktree",
@@ -2310,7 +2366,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"get_agent_activity",
{
title: "Get agent activity",
@@ -2366,7 +2422,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"set_agent_mode",
{
title: "Set agent session mode",
@@ -2390,7 +2446,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"list_pending_permissions",
{
title: "List pending permissions",
@@ -2424,7 +2480,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
registerTool(
"respond_to_permission",
{
title: "Respond to permission",
@@ -1,50 +0,0 @@
/**
* Returns orchestrator mode instructions to append to the system prompt.
* These instructions are from CLAUDE.md and guide agents on how to work
* effectively in this repository.
*/
export function getOrchestratorModeInstructions(): string {
return `
<orchestrator-mode>
Activation:
- Only activate if the user explicitly says "go into orchestrator mode" (or similar).
- Otherwise, do work directly yourself; do not spawn agents.
Core rules:
- In orchestrator mode, you accomplish tasks only by managing agents; do not perform the work yourself.
- Always prefix agent titles (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
- Set cwd to the repository root and choose the most permissive mode available.
- If an agent control tool call fails, list agents before launching another; it may just be a wait timeout.
Context management:
- Reuse an existing agent when the next step needs the same context (same files/module/folder or immediate follow-up like investigate → fix in the same area).
- Start a new agent when switching to a different area/module, or when an agent has run long and its context feels stale.
- Prefer sending follow-up prompts to an existing agent to avoid reloading context.
- Use multiple agents when roles diverge (e.g., one for refactor, one for external validation), but default to reuse when context overlaps.
Conversation with agents:
- Engage actively: ask pointed questions, probe risks, and request clarifications before accepting proposals.
- Encourage agents to validate assumptions, consider edge cases, and describe how they will test/verify.
Agent selection guidance:
- Codex: methodical and slower; great for deep debugging, tracing code paths, refactoring, complex features, and design discussions.
- Claude: fast; strong at tool use, agentic control, and managing other agents; may jump to conclusions—ask it to verify.
Clarifying ambiguous requests:
- Research first to understand the current state.
- Ask clarifying questions about what the user wants.
- Present options with trade-offs.
- Get explicit confirmation; never assume.
Investigation vs Implementation:
- Investigate only unless explicitly asked to implement.
- Report findings clearly.
- After investigation, ask for direction before implementing.
Tool usage discipline:
- Do not ask users to run commands—run them yourself.
- Do not repeat the user’s instructions verbatim—summarize them in your own words.
- Be explicit about results—tell the user what happened after every command.
</orchestrator-mode>
`;
}
@@ -193,8 +193,8 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
},
}));
vi.mock("./providers/pi-direct-agent.js", () => ({
PiDirectAgentClient: class PiDirectAgentClient {
vi.mock("./providers/pi/agent.js", () => ({
PiRpcAgentClient: class PiRpcAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -29,7 +29,7 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
import { PiDirectAgentClient } from "./providers/pi-direct-agent.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
import {
AGENT_PROVIDER_DEFINITIONS,
@@ -121,7 +121,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
}),
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
pi: (logger, runtimeSettings) =>
new PiDirectAgentClient({
new PiRpcAgentClient({
logger,
runtimeSettings,
}),
@@ -36,7 +36,7 @@ import {
transformCopilotSessionResponse,
writeCopilotProviderMode,
} from "./copilot-acp-agent.js";
import { transformPiModels } from "./pi-direct-agent.js";
import { transformPiModels } from "./pi/agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals } from "../../test-utils/class-mocks.js";
@@ -81,7 +81,7 @@ import {
import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js";
import { withTimeout } from "../../../../utils/promise-timeout.js";
import { execCommand } from "../../../../utils/spawn.js";
import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js";
import { composeSystemPromptParts } from "../../system-prompt.js";
const fsPromises = promises;
const CLAUDE_SETTING_SOURCES: NonNullable<ClaudeOptions["settingSources"]> = [
@@ -2314,9 +2314,9 @@ class ClaudeAgentSession implements AgentSession {
}
private buildAppendedSystemPrompt(): string {
return [getOrchestratorModeInstructions(), this.config.systemPrompt?.trim()]
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.join("\n\n");
return (
composeSystemPromptParts(this.config.systemPrompt, this.config.daemonAppendSystemPrompt) ?? ""
);
}
private buildSdkEnv(extraClaudeOptions: Partial<ClaudeOptions> | undefined): NodeJS.ProcessEnv {
@@ -2365,7 +2365,7 @@ class ClaudeAgentSession implements AgentSession {
canUseTool: this.handlePermissionRequest,
pathToClaudeCodeExecutable: claudeBinary,
// Use Claude Code preset system prompt and load CLAUDE.md files
// Append provider-agnostic system prompt and orchestrator instructions for agents.
// Append provider-agnostic system prompts for agents.
systemPrompt: {
type: "preset",
preset: "claude_code",
@@ -41,6 +41,7 @@ import os from "node:os";
import path from "node:path";
import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { curateAgentActivity } from "../activity-curator.js";
import {
mapCodexRolloutToolCall,
@@ -3026,12 +3027,11 @@ class CodexAppServerAgentSession implements AgentSession {
const settings: Record<string, unknown> = {};
if (match.model) settings.model = match.model;
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
const developerInstructions = [
match.developer_instructions?.trim(),
this.config.systemPrompt?.trim(),
]
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
.join("\n\n");
const developerInstructions = composeSystemPromptParts(
match.developer_instructions,
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) settings.developer_instructions = developerInstructions;
if (this.config.model) settings.model = this.config.model;
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
@@ -3161,8 +3161,12 @@ class CodexAppServerAgentSession implements AgentSession {
return;
}
const params: Record<string, unknown> = { threadId: this.currentThreadId };
if (this.config.systemPrompt?.trim()) {
params.developerInstructions = this.config.systemPrompt.trim();
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) {
params.developerInstructions = developerInstructions;
}
const codexConfig = this.buildCodexInnerConfig();
if (codexConfig) {
@@ -3257,6 +3261,7 @@ class CodexAppServerAgentSession implements AgentSession {
approvalPolicy: string;
sandboxPolicyType: string;
hasOutputSchema: boolean;
hasDeveloperInstructions: boolean;
hasCodexConfig: boolean;
}> {
const input = await this.buildUserInput(prompt);
@@ -3299,8 +3304,12 @@ class CodexAppServerAgentSession implements AgentSession {
if (options?.outputSchema) {
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
}
if (this.config.systemPrompt?.trim()) {
params.developerInstructions = this.config.systemPrompt.trim();
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) {
params.developerInstructions = developerInstructions;
}
const codexConfig = this.buildCodexInnerConfig();
if (codexConfig) {
@@ -3313,6 +3322,7 @@ class CodexAppServerAgentSession implements AgentSession {
approvalPolicy,
sandboxPolicyType,
hasOutputSchema: Boolean(options?.outputSchema),
hasDeveloperInstructions: Boolean(developerInstructions),
hasCodexConfig: Boolean(codexConfig),
};
}
@@ -3323,6 +3333,7 @@ class CodexAppServerAgentSession implements AgentSession {
approvalPolicy,
sandboxPolicyType,
hasOutputSchema,
hasDeveloperInstructions,
hasCodexConfig,
}: {
turnId: string;
@@ -3330,6 +3341,7 @@ class CodexAppServerAgentSession implements AgentSession {
approvalPolicy: string;
sandboxPolicyType: string;
hasOutputSchema: boolean;
hasDeveloperInstructions: boolean;
hasCodexConfig: boolean;
}): void {
this.logger.info(
@@ -3345,7 +3357,7 @@ class CodexAppServerAgentSession implements AgentSession {
sandboxPolicyType,
hasCollaborationMode: Boolean(this.resolvedCollaborationMode),
hasOutputSchema,
hasDeveloperInstructions: Boolean(this.config.systemPrompt?.trim()),
hasDeveloperInstructions,
hasCodexConfig,
},
"Starting Codex app-server turn",
@@ -3407,6 +3419,7 @@ class CodexAppServerAgentSession implements AgentSession {
approvalPolicy: turnStart.approvalPolicy,
sandboxPolicyType: turnStart.sandboxPolicyType,
hasOutputSchema: turnStart.hasOutputSchema,
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
hasCodexConfig: turnStart.hasCodexConfig,
});
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
@@ -3952,14 +3965,16 @@ class CodexAppServerAgentSession implements AgentSession {
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
const sandbox = this.config.sandboxMode ?? preset.sandbox;
const innerConfig = this.buildCodexInnerConfig();
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
const params: Record<string, unknown> = {
model,
cwd: this.config.cwd ?? null,
approvalPolicy,
sandbox,
...(this.config.systemPrompt?.trim()
? { developerInstructions: this.config.systemPrompt.trim() }
: {}),
...(developerInstructions ? { developerInstructions } : {}),
...(innerConfig ? { config: innerConfig } : {}),
...(this.ephemeral ? { ephemeral: true } : {}),
};
@@ -60,6 +60,7 @@ import {
} from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import {
createSdkOpenCodeClient,
type OpenCodeRuntime,
@@ -2589,6 +2590,10 @@ class OpenCodeAgentSession implements AgentSession {
partTypes: parts.map((p) => p.type),
});
try {
const systemPrompt = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
directory: this.config.cwd,
@@ -2601,7 +2606,7 @@ class OpenCodeAgentSession implements AgentSession {
},
}
: {}),
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
...(systemPrompt ? { system: systemPrompt } : {}),
...(model ? { model } : {}),
...(effectiveMode ? { agent: effectiveMode } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
@@ -1,373 +0,0 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test, vi } from "vitest";
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
import pino from "pino";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import {
PiDirectAgentClient,
PiDirectAgentSession,
type PiDirectSessionRuntimeAdapter,
type PiDirectSessionAdapter,
} from "./pi-direct-agent.js";
function createPiAssistantErrorMessage(errorMessage: string): AssistantMessage {
return {
role: "assistant",
content: [],
api: "openai-responses",
provider: "github-copilot",
model: "gpt-5.4",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
stopReason: "error",
errorMessage,
timestamp: Date.now(),
};
}
function createPiSession(
prompt: () => Promise<void>,
options: {
compact?: () => Promise<void>;
messages?: PiDirectSessionAdapter["messages"];
errorMessage?: string | null;
} = {},
): PiDirectSessionAdapter {
return {
sessionId: "pi-session-1",
thinkingLevel: "medium",
model: undefined,
messages: options.messages ?? [],
extensionRunner: undefined,
promptTemplates: [],
resourceLoader: {
getSkills: () => ({ skills: [] }),
},
agent: {
state: {
systemPrompt: "",
errorMessage: options.errorMessage ?? null,
},
},
sessionManager: {
getSessionFile: () => "/tmp/pi-session.json",
getCwd: () => "/tmp/paseo-pi-test",
},
subscribe: vi.fn(),
prompt,
compact: options.compact ?? vi.fn(async () => undefined),
abort: vi.fn(),
dispose: vi.fn(),
getSessionStats: vi.fn(() => ({})),
setModel: vi.fn(),
setThinkingLevel: vi.fn(),
};
}
function createPiRuntime(
session: PiDirectSessionAdapter,
dispose: () => Promise<void> = vi.fn(async () => undefined),
): PiDirectSessionRuntimeAdapter {
return {
session,
dispose,
};
}
function createPiModel(provider: string, id: string): Model<Api> {
return {
provider,
id,
name: id,
api: "openai-completions",
baseUrl: "https://example.invalid/v1",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
} as Model<Api>;
}
describe("PiDirectAgentSession", () => {
test("treats SDK request abort rejections as turn cancellations", async () => {
const session = new PiDirectAgentSession(
createPiRuntime(createPiSession(() => Promise.reject(new Error("Request was aborted.")))),
{ find: vi.fn(), getAll: vi.fn(() => []) },
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const { turnId } = await session.startTurn("hello");
await Promise.resolve();
expect(events).toEqual([
{
type: "turn_canceled",
provider: "pi",
turnId,
reason: "Request was aborted.",
},
]);
});
test("compacts stale Copilot 413 sessions before prompting again", async () => {
const callOrder: string[] = [];
const sdkSession = createPiSession(
vi.fn(async () => {
callOrder.push("prompt");
}),
{
messages: [createPiAssistantErrorMessage("413 failed to parse request")],
errorMessage: "413 failed to parse request",
compact: vi.fn(async () => {
callOrder.push("compact");
}),
},
);
const session = new PiDirectAgentSession(
createPiRuntime(sdkSession),
{ find: vi.fn(), getAll: vi.fn(() => []) },
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
await session.startTurn("continue");
expect(sdkSession.compact).toHaveBeenCalledTimes(1);
expect(sdkSession.prompt).toHaveBeenCalledTimes(1);
expect(callOrder).toEqual(["compact", "prompt"]);
});
test("does not compact other Pi errors before prompting", async () => {
const sdkSession = createPiSession(
vi.fn(async () => undefined),
{
messages: [createPiAssistantErrorMessage("413 unrelated provider error")],
compact: vi.fn(async () => {
throw new Error("should not compact");
}),
},
);
const session = new PiDirectAgentSession(
createPiRuntime(sdkSession),
{ find: vi.fn(), getAll: vi.fn(() => []) },
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
await session.startTurn("continue");
expect(sdkSession.compact).not.toHaveBeenCalled();
expect(sdkSession.prompt).toHaveBeenCalledTimes(1);
});
test("setModel creates a minimal model for new ids under a known provider", async () => {
const sdkSession = createPiSession(async () => undefined);
const session = new PiDirectAgentSession(
createPiRuntime(sdkSession),
{
find: vi.fn(() => undefined),
getAll: vi.fn(() => [createPiModel("openrouter", "known-model")]),
},
{
provider: "pi",
cwd: "/tmp/paseo-pi-test",
},
);
await session.setModel("openrouter/blabal");
expect(sdkSession.setModel).toHaveBeenCalledWith({
id: "blabal",
name: "blabal",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://example.invalid/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
compat: undefined,
});
});
});
describe("PiDirectAgentClient", () => {
test("lists only Pi models with configured auth", async () => {
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const registry = {
find: vi.fn(),
getAll: vi.fn(() => [createPiModel("amazon-bedrock", "claude-sonnet-4")]),
getAvailable: vi.fn(() => [createPiModel("anthropic", "claude-opus-4-5")]),
};
(client as unknown as { modelRegistry: typeof registry }).modelRegistry = registry;
const models = await client.listModels({ cwd: "/tmp/paseo-pi-test", force: false });
expect(registry.getAvailable).toHaveBeenCalledTimes(1);
expect(registry.getAll).not.toHaveBeenCalled();
expect(models.map((model) => model.id)).toEqual(["anthropic/claude-opus-4-5"]);
});
test("loads project extensions before listing available models", async () => {
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = join(testRoot, "agent");
const cwd = join(testRoot, "project");
const extensionDir = join(cwd, ".pi", "extensions");
process.env.PI_CODING_AGENT_DIR = agentDir;
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "dummy-provider.ts"),
`
export default function(pi) {
pi.registerProvider("paseo-dummy", {
baseUrl: "https://example.invalid/v1",
apiKey: "paseo-test-key",
api: "openai-responses",
models: [
{
id: "extension-model",
name: "Extension Model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096
}
]
});
}
`,
"utf-8",
);
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const models = await client.listModels({ cwd, force: false });
expect(models.map((model) => model.id)).toContain("paseo-dummy/extension-model");
} finally {
if (previousAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
}
await rm(testRoot, { recursive: true, force: true });
}
});
test("creates sessions with project extension models and exposes extension commands", async () => {
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-session-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
try {
const agentDir = join(testRoot, "agent");
const cwd = join(testRoot, "project");
const extensionDir = join(cwd, ".pi", "extensions");
const shutdownMarker = join(testRoot, "shutdown.txt");
process.env.PI_CODING_AGENT_DIR = agentDir;
await mkdir(extensionDir, { recursive: true });
await writeFile(
join(extensionDir, "dummy-command.ts"),
`
import { writeFileSync } from "node:fs";
export default function(pi) {
pi.registerProvider("paseo-dummy", {
baseUrl: "https://example.invalid/v1",
apiKey: "paseo-test-key",
api: "openai-responses",
models: [
{
id: "extension-model",
name: "Extension Model",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096
}
]
});
pi.registerCommand("dummy-command", {
description: "Dummy extension command",
handler: async () => {}
});
pi.on("session_shutdown", async () => {
writeFileSync(${JSON.stringify(shutdownMarker)}, "closed");
});
}
`,
"utf-8",
);
const client = new PiDirectAgentClient({
logger: pino({ level: "silent" }),
});
const session = await client.createSession({
provider: "pi",
cwd,
model: "paseo-dummy/extension-model",
});
let closed = false;
try {
await expect(session.listCommands()).resolves.toContainEqual({
name: "dummy-command",
description: "Dummy extension command",
argumentHint: "",
});
await session.close();
closed = true;
await expect(readFile(shutdownMarker, "utf-8")).resolves.toBe("closed");
} finally {
if (!closed) {
await session.close();
}
}
} finally {
if (previousAgentDir === undefined) {
delete process.env.PI_CODING_AGENT_DIR;
} else {
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
}
await rm(testRoot, { recursive: true, force: true });
}
});
});
File diff suppressed because it is too large Load Diff
@@ -1,115 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import {
applyPiSessionRecoveryPolicy,
type PiSessionRecoveryPolicySession,
} from "./pi-session-recovery-policy.js";
function createRecoverySession(
options: {
messages?: PiSessionRecoveryPolicySession["messages"];
errorMessage?: string | null;
compact?: () => Promise<void>;
} = {},
): PiSessionRecoveryPolicySession {
return {
messages: options.messages ?? [],
agent: {
state: {
errorMessage: options.errorMessage ?? null,
},
},
compact: options.compact ?? vi.fn(async () => undefined),
};
}
describe("applyPiSessionRecoveryPolicy", () => {
test("compacts sessions that ended on Pi Copilot short 413 overflow", async () => {
const compact = vi.fn(async () => undefined);
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 failed to parse request",
},
],
compact,
});
const result = await applyPiSessionRecoveryPolicy(session);
expect(result).toEqual({ applied: true, policyId: "piCopilot413" });
expect(compact).toHaveBeenCalledTimes(1);
});
test("uses Pi agent error state when message history has no matching assistant error", async () => {
const compact = vi.fn(async () => undefined);
const session = createRecoverySession({
messages: [{ role: "user" }],
errorMessage: " 413 failed to parse request ",
compact,
});
const result = await applyPiSessionRecoveryPolicy(session);
expect(result).toEqual({ applied: true, policyId: "piCopilot413" });
expect(compact).toHaveBeenCalledTimes(1);
});
test("does not compact unrelated Pi errors", async () => {
const compact = vi.fn(async () => undefined);
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 unrelated provider error",
},
],
compact,
});
const result = await applyPiSessionRecoveryPolicy(session);
expect(result).toEqual({ applied: false });
expect(compact).not.toHaveBeenCalled();
});
test("treats already-compacted sessions as recovered", async () => {
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 failed to parse request",
},
],
compact: vi.fn(async () => {
throw new Error("Already compacted");
}),
});
await expect(applyPiSessionRecoveryPolicy(session)).resolves.toEqual({
applied: true,
policyId: "piCopilot413",
});
});
test("surfaces unexpected compaction failures", async () => {
const session = createRecoverySession({
messages: [
{
role: "assistant",
stopReason: "error",
errorMessage: "413 failed to parse request",
},
],
compact: vi.fn(async () => {
throw new Error("disk exploded");
}),
});
await expect(applyPiSessionRecoveryPolicy(session)).rejects.toThrow("disk exploded");
});
});
@@ -1,91 +0,0 @@
import { toDiagnosticErrorMessage } from "./diagnostic-utils.js";
export interface PiSessionRecoveryPolicySession {
readonly messages: readonly PiRecoveryMessage[];
readonly agent: {
readonly state: {
readonly errorMessage?: string | null;
};
};
compact(): Promise<unknown>;
}
interface PiRecoveryMessage {
readonly role: string;
readonly stopReason?: string;
readonly errorMessage?: string | null;
}
type PiSessionRecoveryPolicyId = "piCopilot413";
interface PiSessionRecoveryPolicy {
readonly id: PiSessionRecoveryPolicyId;
shouldRecover(session: PiSessionRecoveryPolicySession): boolean;
recover(session: PiSessionRecoveryPolicySession): Promise<void>;
}
export interface PiSessionRecoveryResult {
readonly applied: boolean;
readonly policyId?: PiSessionRecoveryPolicyId;
}
// COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal
// 2026-11-13, once upstream @earendil-works/pi-ai recognizes this overflow.
const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i;
const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [
{
id: "piCopilot413",
shouldRecover: shouldCompactForPiCopilot413,
recover: compactPiSession,
},
];
export async function applyPiSessionRecoveryPolicy(
session: PiSessionRecoveryPolicySession,
): Promise<PiSessionRecoveryResult> {
const policy = PI_SESSION_RECOVERY_POLICIES.find((entry) => entry.shouldRecover(session));
if (!policy) {
return { applied: false };
}
await policy.recover(session);
return { applied: true, policyId: policy.id };
}
function shouldCompactForPiCopilot413(session: PiSessionRecoveryPolicySession): boolean {
return (
isPiCopilotShort413Overflow(getLatestAssistantErrorMessage(session)) ||
isPiCopilotShort413Overflow(session.agent.state.errorMessage)
);
}
function isPiCopilotShort413Overflow(errorMessage: string | null | undefined): boolean {
const normalized = errorMessage?.trim();
return normalized ? PI_COPILOT_SHORT_413_OVERFLOW_PATTERN.test(normalized) : false;
}
function getLatestAssistantErrorMessage(session: PiSessionRecoveryPolicySession): string | null {
for (let index = session.messages.length - 1; index >= 0; index -= 1) {
const message = session.messages[index];
if (message.role !== "assistant") {
continue;
}
return message.stopReason === "error" ? (message.errorMessage?.trim() ?? null) : null;
}
return null;
}
async function compactPiSession(session: PiSessionRecoveryPolicySession): Promise<void> {
try {
await session.compact();
} catch (error) {
if (!isHarmlessPiCompactionError(error)) {
throw error;
}
}
}
function isHarmlessPiCompactionError(error: unknown): boolean {
return /already compacted|nothing to compact/i.test(toDiagnosticErrorMessage(error));
}
@@ -0,0 +1,489 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { describe, expect, test } from "vitest";
import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
import { FakePi } from "./test-utils/fake-pi.js";
function createClient(pi = new FakePi()): PiRpcAgentClient {
return new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: pi,
});
}
function createClientWithPiAgentDir(agentDir: string): PiRpcAgentClient {
return new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: new FakePi(),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
});
}
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
return {
provider: "pi",
cwd: "/tmp/paseo-pi-rpc-test",
...overrides,
};
}
async function createSession(pi = new FakePi()): Promise<{
pi: FakePi;
session: PiRpcAgentSession;
events: SessionEvents;
}> {
const client = createClient(pi);
const session = (await client.createSession(createConfig())) as PiRpcAgentSession;
const events = new SessionEvents(session);
return { pi, session, events };
}
class SessionEvents {
private readonly events: AgentStreamEvent[] = [];
private readonly waiters: Array<{
predicate: (event: AgentStreamEvent) => boolean;
resolve: (event: AgentStreamEvent) => void;
}> = [];
constructor(session: PiRpcAgentSession) {
session.subscribe((event) => {
this.events.push(event);
for (let index = 0; index < this.waiters.length; index += 1) {
const waiter = this.waiters[index];
if (waiter.predicate(event)) {
this.waiters.splice(index, 1);
index -= 1;
waiter.resolve(event);
}
}
});
}
timelineItems() {
return this.events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline",
)
.map((event) => event.item);
}
nextTurnCompletion(): Promise<Extract<AgentStreamEvent, { type: "turn_completed" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
event.type === "turn_completed",
);
}
nextTurnFailure(): Promise<Extract<AgentStreamEvent, { type: "turn_failed" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
event.type === "turn_failed",
);
}
private nextEvent<T extends AgentStreamEvent>(
predicate: (event: AgentStreamEvent) => event is T,
): Promise<T> {
const existing = this.events.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise((resolve) => {
this.waiters.push({
predicate,
resolve: (event) => resolve(event as T),
});
});
}
}
describe("PiRpcAgentSession", () => {
test("streams assistant text, reasoning, and tool calls from Pi events", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
await session.startTurn("hello");
fakeSession.emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: { type: "text_delta", delta: "hello" },
});
fakeSession.emit({
type: "message_update",
message: { role: "assistant", content: [] },
assistantMessageEvent: { type: "thinking_delta", delta: "thinking" },
});
fakeSession.emit({
type: "tool_execution_start",
toolCallId: "tool-1",
toolName: "bash",
args: { command: "echo hi" },
});
fakeSession.emit({
type: "tool_execution_end",
toolCallId: "tool-1",
toolName: "bash",
result: { output: "hi\n", exitCode: 0 },
isError: false,
});
fakeSession.finishTurn();
await events.nextTurnCompletion();
expect(events.timelineItems()).toEqual([
{ type: "assistant_message", text: "hello" },
{ type: "reasoning", text: "thinking" },
{
type: "tool_call",
callId: "tool-1",
name: "bash",
status: "running",
detail: { type: "shell", command: "echo hi" },
error: null,
},
{
type: "tool_call",
callId: "tool-1",
name: "bash",
status: "completed",
detail: { type: "shell", command: "echo hi", output: "hi\n", exitCode: 0 },
error: null,
},
]);
});
test("resumes by launching Pi with the persisted session file and cwd metadata", async () => {
const pi = new FakePi();
const client = createClient(pi);
await client.resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session.jsonl",
metadata: {
cwd: "/workspace/project",
model: "openrouter/model-a",
thinkingOptionId: "high",
},
},
{},
);
expect(pi.recordedLaunches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
session: "/tmp/native-pi-session.jsonl",
argv: [
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
"/tmp/native-pi-session.jsonl",
],
}),
]);
});
test("creates Pi sessions with agent and daemon system prompts appended", async () => {
const pi = new FakePi();
const client = createClient(pi);
await client.createSession(
createConfig({
systemPrompt: "Agent prompt",
daemonAppendSystemPrompt: "Daemon prompt",
}),
);
expect(pi.recordedLaunches[0]).toEqual(
expect.objectContaining({
cwd: "/tmp/paseo-pi-rpc-test",
systemPrompt: "Agent prompt\n\nDaemon prompt",
argv: [
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
],
}),
);
});
test("resumes Pi sessions with daemon system prompts appended", async () => {
const pi = new FakePi();
const client = createClient(pi);
await client.resumeSession(
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session.jsonl",
metadata: {
cwd: "/workspace/project",
model: "openrouter/model-a",
thinkingOptionId: "high",
systemPrompt: "Agent prompt",
},
},
{
daemonAppendSystemPrompt: "Daemon prompt",
},
);
expect(pi.recordedLaunches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
session: "/tmp/native-pi-session.jsonl",
systemPrompt: "Agent prompt\n\nDaemon prompt",
argv: [
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
"/tmp/native-pi-session.jsonl",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
],
}),
]);
});
test("updates model and thinking through Pi runtime commands", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.setModelResult = { provider: "openrouter", id: "model-a", name: "Model A" };
await session.setModel("openrouter/model-a");
await session.setThinkingOption("high");
expect(fakeSession.setModelRequests).toEqual([{ provider: "openrouter", modelId: "model-a" }]);
expect(fakeSession.setThinkingLevelRequests).toEqual(["high"]);
});
test("fails the active turn when the Pi process exits mid-turn", async () => {
const { pi, session, events } = await createSession();
await session.startTurn("hello");
pi.latestSession().emit({ type: "process_exit", error: "Pi exited" });
await expect(events.nextTurnFailure()).resolves.toMatchObject({
error: "Pi exited",
});
});
});
describe("PiRpcAgentClient", () => {
test("lists models from a short-lived Pi session in the requested cwd", async () => {
const pi = new FakePi();
const client = createClient(pi);
const modelsPromise = client.listModels({ cwd: "/workspace/with-extension", force: false });
pi.latestSession().models = [
{
provider: "openrouter",
id: "google/gemini-2.5-flash-lite",
name: "google/gemini-2.5-flash-lite",
reasoning: true,
},
];
await expect(modelsPromise).resolves.toMatchObject([
{
provider: "pi",
id: "openrouter/google/gemini-2.5-flash-lite",
label: "gemini-2.5-flash-lite",
defaultThinkingOptionId: "medium",
},
]);
expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" });
});
test("maps extension, prompt, and skill commands to Paseo slash commands", async () => {
const { pi, session } = await createSession();
pi.latestSession().commands = [
{ name: "review", description: "Review changes", source: "extension" },
{ name: "fix-tests", description: "Fix tests", source: "prompt" },
{ name: "skill:docs", description: "Read docs", source: "skill" },
];
await expect(session.listCommands()).resolves.toEqual([
{ name: "review", description: "Review changes", argumentHint: "" },
{ name: "fix-tests", description: "Fix tests", argumentHint: "" },
{ name: "skill:docs", description: "Read docs", argumentHint: "" },
]);
});
test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => {
const pi = new FakePi();
pi.queueCommands([
{
name: "mcp",
description: "Show MCP server status",
source: "extension",
sourceInfo: { source: "npm:pi-mcp-adapter" },
},
]);
const client = createClient(pi);
const session = await client.createSession(
createConfig({
mcpServers: {
paseo: {
type: "http",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
},
localSecret: {
type: "stdio",
command: "node",
args: ["secret-server.js"],
env: { SECRET_NUMBER: "314159" },
},
},
}),
);
expect(pi.recordedLaunches).toHaveLength(2);
expect(pi.recordedLaunches[0]).toMatchObject({
cwd: "/tmp/paseo-pi-rpc-test",
argv: ["pi", "--mode", "rpc"],
});
const actualLaunch = pi.recordedLaunches[1];
expect(actualLaunch.argv).toEqual([
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--mcp-config",
actualLaunch.mcpConfigPath,
]);
expect(session.capabilities.supportsMcpServers).toBe(true);
const configPath = actualLaunch.mcpConfigPath;
expect(configPath).toEqual(expect.any(String));
const injectedConfig = JSON.parse(readFileSync(configPath!, "utf8")) as {
mcpServers: Record<string, unknown>;
};
expect(injectedConfig).toEqual({
mcpServers: {
paseo: {
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
auth: false,
oauth: false,
},
localSecret: {
command: "node",
args: ["secret-server.js"],
env: { SECRET_NUMBER: "314159" },
},
},
});
await session.close();
expect(existsSync(configPath!)).toBe(false);
});
test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => {
const pi = new FakePi();
pi.queueCommands([]);
const client = createClient(pi);
const session = await client.createSession(
createConfig({
mcpServers: {
paseo: {
type: "http",
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
},
},
}),
);
expect(pi.recordedLaunches).toHaveLength(2);
expect(pi.recordedLaunches[1]?.argv).toEqual(["pi", "--mode", "rpc", "--thinking", "medium"]);
expect(pi.recordedLaunches[1]?.mcpConfigPath).toBeUndefined();
expect(session.capabilities.supportsMcpServers).toBe(false);
});
test("lists persisted Pi sessions from the configured Pi agent directory", async () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-client-"));
const cwd = path.join(root, "workspace");
const agentDir = path.join(root, "agent");
const sessionsDir = path.join(agentDir, "sessions", "--workspace--");
mkdirSync(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "20260101_session.jsonl");
writeFileSync(
sessionFile,
[
JSON.stringify({
type: "session",
version: 3,
id: "pi-session",
timestamp: "2026-01-01T00:00:00.000Z",
cwd,
}),
JSON.stringify({
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:01.000Z",
message: { role: "user", content: "remember this" },
}),
].join("\n") + "\n",
"utf8",
);
const client = createClientWithPiAgentDir(agentDir);
await expect(client.listPersistedAgents({ cwd })).resolves.toMatchObject([
{
provider: "pi",
sessionId: "pi-session",
cwd,
persistence: {
provider: "pi",
sessionId: "pi-session",
nativeHandle: sessionFile,
metadata: { provider: "pi", cwd },
},
timeline: [{ type: "user_message", text: "remember this" }],
},
]);
});
});
describe("transformPiModels", () => {
test("normalizes labels that include the upstream provider prefix", () => {
expect(
transformPiModels([
{
provider: "pi",
id: "openrouter/google/gemini-2.5-flash-lite",
label: "openrouter/google/gemini_2.5 flash lite",
},
]),
).toEqual([
{
provider: "pi",
id: "openrouter/google/gemini-2.5-flash-lite",
label: "gemini 2.5 flash lite",
description: "openrouter/google/gemini_2.5 flash lite",
},
]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import pino from "pino";
import { describe, expect, test } from "vitest";
import { PiCliRuntime } from "./cli-runtime.js";
import type { PiRuntimeLaunch } from "./runtime.js";
type PiChild = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
killedSignals: Array<NodeJS.Signals | number | undefined>;
};
function createPiChild(): PiChild {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
exitCode: null,
signalCode: null,
killedSignals: [],
}) as PiChild;
child.kill = ((signal?: NodeJS.Signals | number) => {
child.killedSignals.push(signal);
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
}) as ChildProcessWithoutNullStreams["kill"];
return child;
}
function createRuntime(child: PiChild, launches: PiRuntimeLaunch[] = []): PiCliRuntime {
return new PiCliRuntime({
logger: pino({ level: "silent" }),
command: ["pi"],
spawnProcess: (launch) => {
launches.push(launch);
return child;
},
});
}
function replyToCommands(
child: PiChild,
handler: (command: Record<string, unknown>) => unknown,
): void {
let buffer = "";
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const command = JSON.parse(line) as Record<string, unknown>;
const result = handler(command);
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: result,
})}\n`,
);
}
});
}
describe("PiCliRuntime", () => {
test("starts pi in rpc mode and resolves command responses", async () => {
const child = createPiChild();
replyToCommands(child, (command) =>
command.type === "get_state"
? {
sessionId: "pi-session-1",
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
messageCount: 0,
pendingMessageCount: 0,
}
: {},
);
const launches: PiRuntimeLaunch[] = [];
const runtime = createRuntime(child, launches);
const session = await runtime.startSession({ cwd: "/workspace/project" });
await expect(session.getState()).resolves.toMatchObject({
sessionId: "pi-session-1",
thinkingLevel: "medium",
});
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
argv: ["pi", "--mode", "rpc"],
}),
]);
});
test("passes an MCP config path to Pi", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const launches: PiRuntimeLaunch[] = [];
const runtime = createRuntime(child, launches);
await runtime.startSession({
cwd: "/workspace/project",
mcpConfigPath: "/tmp/paseo-pi-mcp/mcp.json",
});
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
mcpConfigPath: "/tmp/paseo-pi-mcp/mcp.json",
argv: ["pi", "--mode", "rpc", "--mcp-config", "/tmp/paseo-pi-mcp/mcp.json"],
}),
]);
});
test("passes an appended system prompt to Pi", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const launches: PiRuntimeLaunch[] = [];
const runtime = createRuntime(child, launches);
await runtime.startSession({
cwd: "/workspace/project",
systemPrompt: " Use the daemon prompt. ",
});
expect(launches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
systemPrompt: "Use the daemon prompt.",
argv: ["pi", "--mode", "rpc", "--append-system-prompt", "Use the daemon prompt."],
}),
]);
});
test("delivers events separately from command responses", async () => {
const child = createPiChild();
replyToCommands(child, () => ({ models: [] }));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const events: unknown[] = [];
session.onEvent((event) => events.push(event));
child.stdout.write(`${JSON.stringify({ type: "turn_start" })}\n`);
await session.getAvailableModels();
expect(events).toEqual([{ type: "turn_start" }]);
});
test("keeps unicode line separators inside one JSONL record", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const events: unknown[] = [];
session.onEvent((event) => events.push(event));
child.stdout.write(`${JSON.stringify({ type: "message", text: "a\u2028b\u2029c" })}\n`);
expect(events).toEqual([{ type: "message", text: "a\u2028b\u2029c" }]);
});
test("rejects pending commands when the Pi process exits", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const state = session.getState();
child.stderr.write("boom");
child.emit("exit", 1, null);
await expect(state).rejects.toThrow("boom");
});
test("disposes the Pi process", async () => {
const child = createPiChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await session.close();
expect(child.killedSignals).toContain("SIGTERM");
});
});
@@ -0,0 +1,282 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import {
buildPiLaunch,
type PiRuntime,
type PiRuntimeLaunch,
type PiRuntimeSession,
type PiStartSessionInput,
} from "./runtime.js";
import type {
PiAgentMessage,
PiModel,
PiRpcCommand,
PiRpcResponse,
PiRpcSlashCommand,
PiRuntimeEvent,
PiSessionState,
PiSessionStats,
} from "./rpc-types.js";
const DEFAULT_PI_COMMAND: [string, ...string[]] = [
process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi",
];
const DEFAULT_TIMEOUT_MS = 30_000;
const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
export interface PiCliRuntimeOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
command?: [string, ...string[]];
spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
}
export class PiCliRuntime implements PiRuntime {
private readonly command: [string, ...string[]];
private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
constructor(private readonly options: PiCliRuntimeOptions) {
this.command = options.command ?? DEFAULT_PI_COMMAND;
this.spawnProcess =
options.spawnProcess ??
((launch) => {
const [command, ...args] = launch.argv;
return spawn(command, args, {
cwd: launch.cwd,
env: launch.env ? { ...process.env, ...launch.env } : process.env,
stdio: ["pipe", "pipe", "pipe"],
});
});
}
async startSession(input: PiStartSessionInput): Promise<PiRuntimeSession> {
const launch = buildPiLaunch({
command: this.command,
runtimeSettings: this.options.runtimeSettings,
session: input,
});
return new PiCliRuntimeSession(launch, this.spawnProcess(launch), this.options.logger);
}
}
class PiCliRuntimeSession implements PiRuntimeSession {
private readonly pending = new Map<string, PendingRequest>();
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private stdoutBuffer = "";
constructor(
_launch: PiRuntimeLaunch,
private readonly child: ChildProcessWithoutNullStreams,
private readonly logger: Logger,
) {
child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());
});
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
}
});
child.on("error", (error) => {
this.failAll(error instanceof Error ? error : new Error(String(error)));
});
child.on("exit", (code, signal) => {
const error = new Error(
`Pi RPC process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
this.emit({ type: "process_exit", error: error.message });
this.failAll(error);
});
}
onEvent(callback: (event: PiRuntimeEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> {
await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) });
}
async abort(): Promise<void> {
await this.request({ type: "abort" });
}
async getState(): Promise<PiSessionState> {
return (await this.request({ type: "get_state" })) as PiSessionState;
}
async getMessages(): Promise<PiAgentMessage[]> {
const data = (await this.request({ type: "get_messages" })) as { messages?: PiAgentMessage[] };
return data.messages ?? [];
}
async getAvailableModels(): Promise<PiModel[]> {
const data = (await this.request({ type: "get_available_models" })) as { models?: PiModel[] };
return data.models ?? [];
}
async setModel(provider: string, modelId: string): Promise<PiModel> {
return (await this.request({ type: "set_model", provider, modelId })) as PiModel;
}
async setThinkingLevel(level: string): Promise<void> {
await this.request({ type: "set_thinking_level", level: level as never });
}
async getSessionStats(): Promise<PiSessionStats> {
return (await this.request({ type: "get_session_stats" })) as PiSessionStats;
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
const data = (await this.request({ type: "get_commands" })) as {
commands?: PiRpcSlashCommand[];
};
return data.commands ?? [];
}
cancelExtensionUiRequest(id: string): void {
this.writeJsonLine({ type: "extension_ui_response", id, cancelled: true });
}
async close(): Promise<void> {
if (this.disposed) return;
this.disposed = true;
try {
this.child.stdin.end();
} catch {
// ignore
}
const result = await terminateWithTreeKill(this.child, {
gracefulTimeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS,
forceTimeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS,
onForceSignal: () => {
this.logger.warn(
{ timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS },
"Pi RPC process did not exit after SIGTERM; sending SIGKILL",
);
},
});
if (result === "kill-timeout") {
this.logger.warn(
{ timeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS },
"Pi RPC process did not report exit after SIGKILL",
);
}
}
private request(command: PiRpcCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
if (this.disposed) {
return Promise.reject(new Error("Pi RPC session is closed"));
}
const id = `req_${this.nextRequestId}`;
this.nextRequestId += 1;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(
new Error(`Pi RPC request timed out for ${command.type}\n${this.stderrBuffer}`.trim()),
);
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.writeJsonLine({ ...command, id });
});
}
private writeJsonLine(value: unknown): void {
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
return;
}
this.child.stdin.write(`${JSON.stringify(value)}\n`);
}
private handleStdoutChunk(chunk: string): void {
this.stdoutBuffer += chunk;
for (;;) {
const newlineIndex = this.stdoutBuffer.indexOf("\n");
if (newlineIndex === -1) {
break;
}
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
if (line.trim()) {
this.handleLine(line);
}
}
}
private handleLine(line: string): void {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
this.logger.warn({ error, line }, "Ignoring non-JSON Pi RPC stdout line");
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return;
}
const message = parsed as Record<string, unknown>;
if (message.type === "response") {
this.handleResponse(message as unknown as PiRpcResponse);
return;
}
this.emit(message as PiRuntimeEvent);
}
private handleResponse(response: PiRpcResponse): void {
const id = response.id;
if (!id) {
return;
}
const pending = this.pending.get(id);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pending.delete(id);
if (!response.success) {
pending.reject(new Error(response.error ?? `Pi RPC ${response.command} failed`));
return;
}
pending.resolve(response.data);
}
private emit(event: PiRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
private failAll(error: Error): void {
if (this.disposed) {
return;
}
this.disposed = true;
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}
@@ -0,0 +1,127 @@
import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { PiAgentMessage } from "./rpc-types.js";
import { streamPiHistory } from "./history-mapper.js";
async function collectHistory(messages: PiAgentMessage[]): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of streamPiHistory("pi", messages)) {
events.push(event);
}
return events;
}
describe("Pi history mapper", () => {
test("replays user, assistant, reasoning, and completed tool calls", async () => {
await expect(
collectHistory([
{
role: "user",
content: [
{ type: "text", text: "read this" },
{ type: "image", data: "base64", mimeType: "image/png" },
{ type: "text", text: "then answer" },
],
},
{
role: "assistant",
content: [
{ type: "thinking", thinking: "checking file" },
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "note.txt" } },
{ type: "text", text: "done" },
],
},
{
role: "toolResult",
toolCallId: "tool-1",
toolName: "read",
content: [{ type: "text", text: "file contents" }],
},
]),
).resolves.toEqual([
{
type: "timeline",
provider: "pi",
item: {
type: "user_message",
text: "read this\n\nthen answer",
messageId: "pi-user-0",
},
},
{
type: "timeline",
provider: "pi",
item: { type: "reasoning", text: "checking file" },
},
{
type: "timeline",
provider: "pi",
item: {
type: "tool_call",
callId: "tool-1",
name: "read",
status: "running",
detail: {
type: "read",
filePath: "note.txt",
content: undefined,
offset: undefined,
limit: undefined,
},
error: null,
},
},
{
type: "timeline",
provider: "pi",
item: { type: "assistant_message", text: "done" },
},
{
type: "timeline",
provider: "pi",
item: {
type: "tool_call",
callId: "tool-1",
name: "read",
status: "completed",
detail: {
type: "read",
filePath: "note.txt",
content: "file contents",
offset: undefined,
limit: undefined,
},
error: null,
},
},
]);
});
test("replays bash execution records as completed shell calls", async () => {
await expect(
collectHistory([
{
role: "bashExecution",
command: "echo hi",
output: "hi\n",
exitCode: 0,
timestamp: 123,
},
]),
).resolves.toEqual([
{
type: "timeline",
provider: "pi",
item: {
type: "tool_call",
callId: "pi-bash-123",
name: "bash",
status: "completed",
detail: { type: "shell", command: "echo hi", output: "hi\n", exitCode: 0 },
error: null,
},
},
]);
});
});
@@ -0,0 +1,169 @@
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
import type { PiAgentMessage, PiImageContent, PiTextContent } from "./rpc-types.js";
import {
extractTextFromToolResult,
mapToolDetail,
parseToolArgs,
parseToolResult,
type PiTrackedToolCall,
} from "./tool-call-mapper.js";
function isTextContentBlock(block: unknown): block is PiTextContent {
return (
typeof block === "object" &&
block !== null &&
!Array.isArray(block) &&
Reflect.get(block, "type") === "text" &&
typeof Reflect.get(block, "text") === "string"
);
}
export function getUserMessageText(content: string | (PiTextContent | PiImageContent)[]): string {
if (typeof content === "string") {
return content;
}
const textParts: string[] = [];
for (const block of content) {
if (isTextContentBlock(block)) {
textParts.push(block.text);
}
}
return textParts.join("\n\n");
}
export async function* streamPiHistory(
provider: string,
messages: PiAgentMessage[],
): AsyncGenerator<AgentStreamEvent> {
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
let userIndex = 0;
for (const message of messages) {
if (message.role === "user") {
const text = getUserMessageText(message.content);
if (text) {
yield {
type: "timeline",
provider,
item: {
type: "user_message",
text,
messageId: `pi-user-${userIndex}`,
},
};
}
userIndex += 1;
continue;
}
if (message.role === "assistant") {
for (const content of message.content) {
if (content.type === "text" && content.text) {
yield {
type: "timeline",
provider,
item: { type: "assistant_message", text: content.text },
};
continue;
}
if (content.type === "thinking" && content.thinking) {
yield {
type: "timeline",
provider,
item: { type: "reasoning", text: content.thinking },
};
continue;
}
if (content.type === "toolCall") {
const tracked = parseToolArgs(content.name, content.arguments);
pendingToolCalls.set(content.id, tracked);
yield {
type: "timeline",
provider,
item: {
type: "tool_call",
callId: content.id,
name: tracked.toolName,
status: "running",
detail: mapToolDetail(tracked, null),
error: null,
},
};
}
}
continue;
}
if (message.role === "toolResult") {
const tracked =
pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
pendingToolCalls.delete(message.toolCallId);
const result = parseToolResult({ content: message.content });
const detail = mapToolDetail(tracked, result);
yield {
type: "timeline",
provider,
item: toToolResultTimelineItem({
callId: message.toolCallId,
name: tracked.toolName,
isError: Boolean(message.isError),
detail,
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
}),
};
continue;
}
if (message.role === "bashExecution") {
const callId = `pi-bash-${message.timestamp}`;
const detail: ToolCallDetail = {
type: "shell",
command: message.command,
output: message.output,
exitCode: message.exitCode ?? null,
};
yield {
type: "timeline",
provider,
item: {
type: "tool_call",
callId,
name: "bash",
status: message.cancelled ? "canceled" : "completed",
detail,
error: null,
},
};
}
}
}
function toToolResultTimelineItem(input: {
callId: string;
name: string;
isError: boolean;
detail: ToolCallDetail;
errorText: string;
}): AgentTimelineItem {
if (input.isError) {
return {
type: "tool_call",
callId: input.callId,
name: input.name,
status: "failed",
detail: input.detail,
error: input.errorText,
};
}
return {
type: "tool_call",
callId: input.callId,
name: input.name,
status: "completed",
detail: input.detail,
error: null,
};
}
@@ -0,0 +1,172 @@
export type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export interface PiImageContent {
type: "image";
data: string;
mimeType: string;
}
export interface PiTextContent {
type: "text";
text: string;
}
export interface PiThinkingContent {
type: "thinking";
thinking: string;
}
export interface PiToolCallContent {
type: "toolCall";
id: string;
name: string;
arguments: unknown;
}
export type PiAssistantContent = PiTextContent | PiThinkingContent | PiToolCallContent;
export type PiAgentMessage =
| {
role: "user";
content: string | Array<PiTextContent | PiImageContent>;
}
| {
role: "assistant";
content: PiAssistantContent[];
errorMessage?: string | null;
stopReason?: string;
}
| {
role: "toolResult";
toolCallId: string;
toolName: string;
content: unknown;
isError?: boolean;
}
| {
role: "bashExecution";
command: string;
output?: string;
exitCode?: number | null;
cancelled?: boolean;
timestamp: number;
};
export interface PiModel {
provider: string;
id: string;
name?: string;
reasoning?: boolean;
contextWindow?: number;
maxTokens?: number;
api?: string;
baseUrl?: string;
input?: string[];
cost?: Record<string, unknown>;
compat?: unknown;
}
export interface PiSessionState {
model?: PiModel | null;
thinkingLevel: PiThinkingLevel;
isStreaming: boolean;
isCompacting: boolean;
sessionFile?: string;
sessionId: string;
sessionName?: string;
messageCount: number;
pendingMessageCount: number;
}
export interface PiSessionStats {
tokens?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
cost?: number;
contextUsage?: {
tokens?: number | null;
contextWindow?: number | null;
percent?: number | null;
};
}
export interface PiRpcSlashCommand {
name: string;
description?: string;
source: "extension" | "prompt" | "skill";
sourceInfo?: Record<string, unknown>;
}
export type PiRpcCommand =
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
| { id?: string; type: "abort" }
| { id?: string; type: "get_state" }
| { id?: string; type: "get_messages" }
| { id?: string; type: "get_available_models" }
| { id?: string; type: "set_model"; provider: string; modelId: string }
| { id?: string; type: "set_thinking_level"; level: PiThinkingLevel }
| { id?: string; type: "get_session_stats" }
| { id?: string; type: "get_commands" };
export interface PiRpcResponse {
id?: string;
type: "response";
command: string;
success: boolean;
data?: unknown;
error?: string;
}
export type PiAssistantMessageEvent =
| { type: "text_delta"; delta?: string }
| { type: "thinking_delta"; delta?: string }
| { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" };
export type PiAgentSessionEvent =
| { type: "agent_start" }
| { type: "turn_start" }
| {
type: "message_update";
message: PiAgentMessage;
assistantMessageEvent: PiAssistantMessageEvent;
}
| {
type: "tool_execution_start";
toolCallId: string;
toolName: string;
args: unknown;
}
| {
type: "tool_execution_update";
toolCallId: string;
toolName: string;
args?: unknown;
partialResult: unknown;
}
| {
type: "tool_execution_end";
toolCallId: string;
toolName: string;
result: unknown;
isError?: boolean;
}
| { type: "compaction_start"; reason?: "manual" | "threshold" | "overflow" | string }
| { type: "compaction_end"; reason?: string; errorMessage?: string; aborted?: boolean }
| { type: "agent_end"; messages?: PiAgentMessage[] };
export type PiRuntimeEvent =
| PiAgentSessionEvent
| {
type: "extension_ui_request";
id: string;
method: string;
[key: string]: unknown;
}
| {
type: "process_exit";
error: string;
};
@@ -0,0 +1,106 @@
import type {
PiAgentMessage,
PiModel,
PiRpcSlashCommand,
PiRuntimeEvent,
PiSessionState,
PiSessionStats,
} from "./rpc-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
export interface PiRuntimeLaunch {
cwd: string;
argv: string[];
env?: Record<string, string>;
model?: string;
thinkingOptionId?: string;
session?: string;
systemPrompt?: string;
mcpConfigPath?: string;
}
export interface PiStartSessionInput {
cwd: string;
model?: string;
thinkingOptionId?: string;
session?: string;
systemPrompt?: string;
mcpConfigPath?: string;
}
export interface PiRuntimeSession {
onEvent(callback: (event: PiRuntimeEvent) => void): () => void;
prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void>;
abort(): Promise<void>;
getState(): Promise<PiSessionState>;
getMessages(): Promise<PiAgentMessage[]>;
getAvailableModels(): Promise<PiModel[]>;
setModel(provider: string, modelId: string): Promise<PiModel>;
setThinkingLevel(level: string): Promise<void>;
getSessionStats(): Promise<PiSessionStats>;
getCommands(): Promise<PiRpcSlashCommand[]>;
cancelExtensionUiRequest(id: string): void;
close(): Promise<void>;
}
export interface PiRuntime {
startSession(input: PiStartSessionInput): Promise<PiRuntimeSession>;
}
export function buildPiLaunch(input: {
command: [string, ...string[]];
runtimeSettings?: ProviderRuntimeSettings;
session: PiStartSessionInput;
}): PiRuntimeLaunch {
const command =
input.runtimeSettings?.command?.mode === "replace" && input.runtimeSettings.command.argv[0]
? input.runtimeSettings.command.argv
: input.command;
const argv = [...command];
if (!hasModeRpc(argv)) {
argv.push("--mode", "rpc");
}
if (input.session.model) {
argv.push("--model", input.session.model);
}
if (input.session.thinkingOptionId) {
argv.push("--thinking", input.session.thinkingOptionId);
}
if (input.session.session) {
argv.push("--session", input.session.session);
}
const systemPrompt = input.session.systemPrompt?.trim();
if (systemPrompt) {
argv.push("--append-system-prompt", systemPrompt);
}
if (input.session.mcpConfigPath) {
argv.push("--mcp-config", input.session.mcpConfigPath);
}
return {
cwd: input.session.cwd,
argv,
env: input.runtimeSettings?.env,
model: input.session.model,
thinkingOptionId: input.session.thinkingOptionId,
session: input.session.session,
systemPrompt,
mcpConfigPath: input.session.mcpConfigPath,
};
}
function hasModeRpc(argv: string[]): boolean {
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--mode" && argv[i + 1] === "rpc") {
return true;
}
if (argv[i] === "--mode=rpc") {
return true;
}
}
return false;
}
@@ -0,0 +1,297 @@
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { listPiPersistedAgents } from "./session-descriptor.js";
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
function tempRoot(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-pi-sessions-"));
}
function writeJsonl(filePath: string, entries: unknown[]): string {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
return filePath;
}
function piSession(overrides: {
id: string;
cwd: string;
timestamp?: string;
entries?: unknown[];
}): unknown[] {
return [
{
type: "session",
version: 3,
id: overrides.id,
timestamp: overrides.timestamp ?? "2026-01-01T00:00:00.000Z",
cwd: overrides.cwd,
},
...(overrides.entries ?? []),
];
}
function message(input: {
role: "user" | "assistant";
content: unknown;
timestamp: string;
}): unknown {
return {
type: "message",
id: `entry-${input.timestamp}`,
parentId: null,
timestamp: input.timestamp,
message: {
role: input.role,
content: input.content,
},
};
}
function sessionInfo(name: string, timestamp: string): unknown {
return {
type: "session_info",
id: `info-${timestamp}`,
parentId: null,
timestamp,
name,
};
}
describe("listPiPersistedAgents", () => {
test("lists Pi sessions from the configured agent dir", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const sessionsDir = path.join(root, "agent", "sessions", "--project--");
const sessionFile = writeJsonl(
path.join(sessionsDir, "20260101_session-a.jsonl"),
piSession({
id: "session-a",
cwd,
entries: [
message({
role: "user",
content: "first prompt",
timestamp: "2026-01-01T00:00:01.000Z",
}),
message({
role: "assistant",
content: [{ type: "text", text: "answer" }],
timestamp: "2026-01-01T00:00:02.000Z",
}),
message({
role: "user",
content: [{ type: "text", text: "last prompt" }],
timestamp: "2026-01-01T00:00:03.000Z",
}),
sessionInfo("Named Pi Session", "2026-01-01T00:00:10.000Z"),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors).toEqual([
{
provider: "pi",
sessionId: "session-a",
cwd,
title: "Named Pi Session",
lastActivityAt: new Date("2026-01-01T00:00:03.000Z"),
persistence: {
provider: "pi",
sessionId: "session-a",
nativeHandle: sessionFile,
metadata: {
provider: "pi",
cwd,
},
},
timeline: [
{ type: "user_message", text: "first prompt" },
{ type: "user_message", text: "last prompt" },
],
},
]);
});
test("uses the runtime session-dir override before Pi settings", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const agentDir = path.join(root, "agent");
const runtimeSessionsDir = path.join(root, "runtime-sessions");
const settingsSessionsDir = path.join(root, "settings-sessions");
mkdirSync(agentDir, { recursive: true });
writeFileSync(
path.join(agentDir, "settings.json"),
JSON.stringify({ sessionDir: settingsSessionsDir }),
"utf8",
);
writeJsonl(
path.join(settingsSessionsDir, "ignored.jsonl"),
piSession({ id: "settings-session", cwd }),
);
writeJsonl(
path.join(runtimeSessionsDir, "selected.jsonl"),
piSession({
id: "runtime-session",
cwd,
entries: [
message({
role: "user",
content: "from runtime dir",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
runtimeSettings: {
env: {
PI_CODING_AGENT_DIR: agentDir,
PI_CODING_AGENT_SESSION_DIR: runtimeSessionsDir,
},
},
env: {},
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["runtime-session"]);
});
test("resolves project settings sessionDir relative to the requested cwd", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const projectSettingsDir = path.join(cwd, ".pi");
const sessionsDir = path.join(cwd, "relative-sessions");
mkdirSync(projectSettingsDir, { recursive: true });
writeFileSync(
path.join(projectSettingsDir, "settings.json"),
JSON.stringify({ sessionDir: "relative-sessions" }),
"utf8",
);
writeJsonl(
path.join(sessionsDir, "relative.jsonl"),
piSession({
id: "relative-session",
cwd,
entries: [
message({
role: "user",
content: "relative settings",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["relative-session"]);
});
test("matches sessions stored with the real cwd when the requested cwd is symlinked", async () => {
const root = tempRoot();
const realCwd = path.join(root, "real-project");
const linkedCwd = path.join(root, "linked-project");
mkdirSync(realCwd, { recursive: true });
symlinkSync(realCwd, linkedCwd, directorySymlinkType);
const persistedCwd = realpathSync(linkedCwd);
const sessionsDir = path.join(root, "agent", "sessions");
writeJsonl(
path.join(sessionsDir, "symlinked.jsonl"),
piSession({
id: "symlinked-session",
cwd: persistedCwd,
entries: [
message({
role: "user",
content: "through symlink",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd: linkedCwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["symlinked-session"]);
});
test("filters by cwd, sorts by activity, and ignores invalid session files", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const otherCwd = path.join(root, "other");
const sessionsDir = path.join(root, "agent", "sessions");
writeJsonl(
path.join(sessionsDir, "older.jsonl"),
piSession({
id: "older",
cwd,
entries: [
message({
role: "user",
content: "older",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
writeJsonl(
path.join(sessionsDir, "newer.jsonl"),
piSession({
id: "newer",
cwd,
entries: [
message({
role: "user",
content: "newer",
timestamp: "2026-01-01T00:00:02.000Z",
}),
],
}),
);
writeJsonl(
path.join(sessionsDir, "outside.jsonl"),
piSession({
id: "outside",
cwd: otherCwd,
entries: [
message({
role: "user",
content: "outside",
timestamp: "2026-01-01T00:00:03.000Z",
}),
],
}),
);
writeFileSync(path.join(sessionsDir, "broken.jsonl"), '{"type":"nope"}\n', "utf8");
const descriptors = await listPiPersistedAgents({
cwd,
limit: 1,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["newer"]);
});
});
@@ -0,0 +1,372 @@
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type {
AgentPersistenceHandle,
AgentTimelineItem,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
const PI_PROVIDER = "pi";
const PI_CONFIG_DIR_NAME = ".pi";
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
interface PiSessionDescriptorOptions extends ListPersistedAgentsOptions {
runtimeSettings?: ProviderRuntimeSettings;
env?: NodeJS.ProcessEnv;
homeDir?: string;
}
interface PiSessionHeader {
sessionId: string;
cwd: string;
createdAt: Date | null;
}
interface PiSessionTail {
title: string | null;
lastActivityAt: Date | null;
lastUserMessage: string | null;
}
export async function listPiPersistedAgents(
options: PiSessionDescriptorOptions = {},
): Promise<PersistedAgentDescriptor[]> {
const sessionsDir = await resolvePiSessionsDir(options);
const files = await walkJsonlFiles(sessionsDir);
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
const limit = options.limit ?? 20;
const descriptors: PersistedAgentDescriptor[] = [];
for (const file of files) {
const descriptor = await readPiSessionDescriptor(file);
if (!descriptor) continue;
if (matchesCwd && !matchesCwd(descriptor.cwd)) continue;
descriptors.push(descriptor);
}
return descriptors
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
.slice(0, limit);
}
async function resolvePiSessionsDir(options: PiSessionDescriptorOptions): Promise<string> {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
const baseDir = options.cwd ?? process.cwd();
const agentDir = resolvePiAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
const envSessionDir =
options.runtimeSettings?.env?.[PI_SESSION_DIR_ENV] ?? env[PI_SESSION_DIR_ENV];
if (envSessionDir?.trim()) {
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
}
const settingsSessionDir = await readConfiguredSessionDir({
agentDir,
cwd: options.cwd,
});
if (settingsSessionDir?.trim()) {
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
}
return path.join(agentDir, "sessions");
}
function resolvePiAgentDir(input: {
runtimeSettings?: ProviderRuntimeSettings;
env: NodeJS.ProcessEnv;
homeDir: string;
}): string {
const configured = input.runtimeSettings?.env?.[PI_AGENT_DIR_ENV] ?? input.env[PI_AGENT_DIR_ENV];
if (configured?.trim()) {
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
}
return path.join(input.homeDir, PI_CONFIG_DIR_NAME, "agent");
}
async function readConfiguredSessionDir(input: {
agentDir: string;
cwd: string | undefined;
}): Promise<string | null> {
const values = await Promise.all([
readSessionDirFromSettings(path.join(input.agentDir, "settings.json")),
input.cwd
? readSessionDirFromSettings(path.join(input.cwd, PI_CONFIG_DIR_NAME, "settings.json"))
: null,
]);
return values[1] ?? values[0] ?? null;
}
async function readSessionDirFromSettings(settingsPath: string): Promise<string | null> {
try {
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const sessionDir = Reflect.get(parsed, "sessionDir");
return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null;
} catch {
return null;
}
}
function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string {
if (value === "~") {
return options.homeDir;
}
if (value.startsWith("~/")) {
return path.join(options.homeDir, value.slice(2));
}
return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value);
}
async function walkJsonlFiles(root: string): Promise<string[]> {
let entries: import("node:fs").Dirent[];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
return await walkJsonlFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : [];
}),
);
return files.flat();
}
async function readPiSessionDescriptor(filePath: string): Promise<PersistedAgentDescriptor | null> {
const firstLine = await readFirstLine(filePath);
if (!firstLine) return null;
const header = parseSessionHeader(firstLine);
if (!header) return null;
const tail = await readTail(filePath).catch(() => "");
const tailInfo = parseSessionTail(tail);
const headInfo = await scanSessionHead(filePath);
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
const lastActivityAt =
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0);
const timeline = buildPreviewTimeline({
firstUserMessage: headInfo.firstUserMessage,
lastUserMessage: tailInfo.lastUserMessage,
});
const persistence: AgentPersistenceHandle = {
provider: PI_PROVIDER,
sessionId: header.sessionId,
nativeHandle: filePath,
metadata: {
provider: PI_PROVIDER,
cwd: header.cwd,
},
};
return {
provider: PI_PROVIDER,
sessionId: header.sessionId,
cwd: header.cwd,
title,
lastActivityAt,
persistence,
timeline,
};
}
async function readFirstLine(filePath: string): Promise<string | null> {
const handle = await open(filePath, "r").catch(() => null);
if (!handle) return null;
try {
const buffer = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
if (bytesRead <= 0) return null;
const chunk = buffer.subarray(0, bytesRead).toString("utf8");
const newlineIndex = chunk.indexOf("\n");
return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim();
} finally {
await handle.close().catch(() => undefined);
}
}
async function readTail(filePath: string): Promise<string> {
const fileStats = await stat(filePath);
const start = Math.max(0, fileStats.size - TAIL_BYTES);
const length = fileStats.size - start;
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close().catch(() => undefined);
}
}
async function readFileMtime(filePath: string): Promise<Date | null> {
try {
return (await stat(filePath)).mtime;
} catch {
return null;
}
}
function parseSessionHeader(firstLine: string): PiSessionHeader | null {
const entry = parseJsonRecord(firstLine);
if (!entry || entry.type !== "session") return null;
const sessionId = typeof entry.id === "string" ? entry.id : null;
const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
if (!sessionId || !cwd) return null;
const createdAt = parseDate(entry.timestamp);
return { sessionId, cwd, createdAt };
}
function parseSessionTail(tail: string): PiSessionTail {
const lines = tail.split(/\r?\n/);
let title: string | null = null;
let lastActivityAt: Date | null = null;
let fallbackTimestamp: Date | null = null;
let lastUserMessage: string | null = null;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const entry = parseJsonRecord(lines[index].trim());
if (!entry) continue;
if (!title && entry.type === "session_info") {
title = readNonEmptyString(entry.name);
}
const entryTimestamp = parseDate(entry.timestamp);
if (!fallbackTimestamp && entryTimestamp) {
fallbackTimestamp = entryTimestamp;
}
if (entry.type !== "message") {
continue;
}
if (!lastActivityAt && entryTimestamp) {
lastActivityAt = entryTimestamp;
}
if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") {
lastUserMessage = extractMessageText(entry.message.content);
}
if (title && lastActivityAt && lastUserMessage) {
break;
}
}
return { title, lastActivityAt: lastActivityAt ?? fallbackTimestamp, lastUserMessage };
}
async function scanSessionHead(filePath: string): Promise<{
title: string | null;
firstUserMessage: string | null;
}> {
let content: string;
try {
content = await readFile(filePath, "utf8");
} catch {
return { title: null, firstUserMessage: null };
}
let title: string | null = null;
let firstUserMessage: string | null = null;
let lineCount = 0;
for (const rawLine of content.split(/\r?\n/)) {
lineCount += 1;
const entry = parseJsonRecord(rawLine.trim());
if (!entry) continue;
if (entry.type === "session_info") {
title = readNonEmptyString(entry.name) ?? title;
}
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
if (entry.message.role === "user") {
firstUserMessage = extractMessageText(entry.message.content);
}
}
if (title && firstUserMessage) {
break;
}
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
break;
}
}
return { title, firstUserMessage };
}
function buildPreviewTimeline(input: {
firstUserMessage: string | null;
lastUserMessage: string | null;
}): AgentTimelineItem[] {
const items: AgentTimelineItem[] = [];
if (input.firstUserMessage) {
items.push({ type: "user_message", text: input.firstUserMessage });
}
if (input.lastUserMessage && input.lastUserMessage !== input.firstUserMessage) {
items.push({ type: "user_message", text: input.lastUserMessage });
}
return items;
}
function parseJsonRecord(line: string): Record<string, unknown> | null {
if (!line) return null;
try {
const parsed = JSON.parse(line) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function parseDate(value: unknown): Date | null {
if (typeof value !== "string" && typeof value !== "number") {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function extractMessageText(content: unknown): string | null {
if (typeof content === "string") {
return content.trim() || null;
}
if (!Array.isArray(content)) {
return null;
}
const text = content
.flatMap((part) =>
isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [],
)
.join("\n\n")
.trim();
return text || null;
}
@@ -0,0 +1,149 @@
import type {
PiRuntime,
PiRuntimeLaunch,
PiRuntimeSession,
PiStartSessionInput,
} from "../runtime.js";
import type {
PiAgentMessage,
PiModel,
PiRpcSlashCommand,
PiRuntimeEvent,
PiSessionState,
PiSessionStats,
} from "../rpc-types.js";
import { buildPiLaunch } from "../runtime.js";
export class FakePi implements PiRuntime {
readonly recordedLaunches: PiRuntimeLaunch[] = [];
private readonly sessions: FakePiSession[] = [];
private readonly command: [string, ...string[]];
private readonly queuedCommands: PiRpcSlashCommand[][] = [];
constructor(command: [string, ...string[]] = ["pi"]) {
this.command = command;
}
async startSession(input: PiStartSessionInput): Promise<FakePiSession> {
const launch = buildPiLaunch({
command: this.command,
session: input,
});
this.recordedLaunches.push(launch);
const session = new FakePiSession(launch);
session.commands = this.queuedCommands.shift() ?? [];
this.sessions.push(session);
return session;
}
queueCommands(commands: PiRpcSlashCommand[]): void {
this.queuedCommands.push(commands);
}
latestSession(): FakePiSession {
const session = this.sessions.at(-1);
if (!session) {
throw new Error("FakePi has no sessions");
}
return session;
}
}
export class FakePiSession implements PiRuntimeSession {
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
readonly setThinkingLevelRequests: string[] = [];
abortRequested = false;
readonly canceledExtensionUiRequests: string[] = [];
setModelResult: PiModel | null = null;
models: PiModel[] = [];
messages: PiAgentMessage[] = [];
stats: PiSessionStats = {
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
commands: PiRpcSlashCommand[] = [];
state: PiSessionState;
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
constructor(launch: PiRuntimeLaunch) {
this.state = {
model: null,
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
sessionFile: launch.session ?? "/tmp/pi-session.jsonl",
sessionId: "pi-session-1",
messageCount: 0,
pendingMessageCount: 0,
};
}
onEvent(callback: (event: PiRuntimeEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async prompt(
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> {
this.prompts.push({ message, imageCount: images?.length ?? 0 });
}
async abort(): Promise<void> {
this.abortRequested = true;
}
async getState(): Promise<PiSessionState> {
return this.state;
}
async getMessages(): Promise<PiAgentMessage[]> {
return this.messages;
}
async getAvailableModels(): Promise<PiModel[]> {
return this.models;
}
async setModel(provider: string, modelId: string): Promise<PiModel> {
this.setModelRequests.push({ provider, modelId });
if (!this.setModelResult) {
throw new Error("FakePi setModel requires setModelResult to be scripted");
}
return this.setModelResult;
}
async setThinkingLevel(level: string): Promise<void> {
this.setThinkingLevelRequests.push(level);
}
async getSessionStats(): Promise<PiSessionStats> {
return this.stats;
}
async getCommands(): Promise<PiRpcSlashCommand[]> {
return this.commands;
}
cancelExtensionUiRequest(id: string): void {
this.canceledExtensionUiRequests.push(id);
}
async close(): Promise<void> {}
emit(event: PiRuntimeEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
finishTurn(message: PiAgentMessage = { role: "assistant", content: [] }): void {
this.messages = [...this.messages, message];
this.emit({ type: "agent_end", messages: this.messages });
}
}
@@ -0,0 +1,76 @@
import { describe, expect, test } from "vitest";
import {
mapToolDetail,
parseToolArgs,
parseToolResult,
resolveToolCallName,
} from "./tool-call-mapper.js";
describe("Pi tool call mapper", () => {
test("maps bash args and result to shell detail", () => {
const toolCall = parseToolArgs("bash", { command: "echo hello" });
const result = parseToolResult({ output: "hello\n", exitCode: 0 });
expect(mapToolDetail(toolCall, result)).toEqual({
type: "shell",
command: "echo hello",
output: "hello\n",
exitCode: 0,
});
});
test("maps legacy edit args to edit detail with diff", () => {
const toolCall = parseToolArgs("edit", {
path: "app.ts",
old_string: "before",
new_string: "after",
});
const result = parseToolResult({ details: { diff: "-before\n+after" } });
expect(mapToolDetail(toolCall, result)).toEqual({
type: "edit",
filePath: "app.ts",
oldString: "before",
newString: "after",
unifiedDiff: "-before\n+after",
});
});
test("preserves unknown tool input and parsed output", () => {
const toolCall = parseToolArgs("custom_tool", { value: 42 });
const result = parseToolResult({ text: "custom result" });
expect(mapToolDetail(toolCall, result)).toEqual({
type: "unknown",
input: { value: 42 },
output: { text: "custom result" },
});
});
test("normalizes Pi MCP proxy calls from requested tool args while running", () => {
const toolCall = parseToolArgs("mcp", {
tool: "paseo_list_models",
args: '{"provider":"pi"}',
});
expect(resolveToolCallName(toolCall, null)).toBe("paseo.list_models");
});
test("normalizes Pi MCP proxy calls from result details when completed", () => {
const toolCall = parseToolArgs("mcp", {
tool: "paseo_list_models",
args: '{"provider":"pi"}',
});
const result = parseToolResult({
content: [{ type: "text", text: "(empty result)" }],
details: {
mode: "call",
server: "paseo",
tool: "list_models",
},
});
expect(resolveToolCallName(toolCall, result)).toBe("paseo.list_models");
});
});
@@ -0,0 +1,469 @@
import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
interface BashToolInput {
command: string;
timeout?: number;
}
interface ReadToolInput {
path: string;
offset?: number;
limit?: number;
}
interface EditToolInput {
path: string;
edits: Array<{
oldText: string;
newText: string;
}>;
}
interface WriteToolInput {
path: string;
content: string;
}
interface FindToolInput {
pattern: string;
path?: string;
limit?: number;
}
interface GrepToolInput {
pattern: string;
path?: string;
glob?: string;
ignoreCase?: boolean;
literal?: boolean;
context?: number;
limit?: number;
}
interface LsToolInput {
path?: string;
limit?: number;
}
interface PiToolResultObject {
output?: string;
stdout?: string;
text?: string;
content?: PiToolResultContent[];
exitCode?: number;
code?: number;
details?: PiToolResultDetails;
}
interface PiToolResultDetails {
diff?: string;
mode?: string;
server?: string;
tool?: string;
mcpResult?: unknown;
}
interface PiToolResultTextContent {
type: "text";
text: string;
}
interface PiToolResultUnknownContent {
type: string;
}
type PiToolResultContent = PiToolResultTextContent | PiToolResultUnknownContent;
export type PiToolResult = string | PiToolResultObject | null;
interface PiBashToolCall {
kind: "bash";
toolName: "bash";
args: BashToolInput;
}
interface PiReadToolCall {
kind: "read";
toolName: "read";
args: ReadToolInput;
}
interface PiEditToolCall {
kind: "edit";
toolName: "edit";
args: EditToolInput;
}
interface PiWriteToolCall {
kind: "write";
toolName: "write";
args: WriteToolInput;
}
interface PiFindToolCall {
kind: "find";
toolName: "find";
args: FindToolInput;
}
interface PiGrepToolCall {
kind: "grep";
toolName: "grep";
args: GrepToolInput;
}
interface PiLsToolCall {
kind: "ls";
toolName: "ls";
args: LsToolInput;
}
interface PiUnknownToolCall {
kind: "unknown";
toolName: string;
args: unknown;
}
export type PiTrackedToolCall =
| PiBashToolCall
| PiReadToolCall
| PiEditToolCall
| PiWriteToolCall
| PiFindToolCall
| PiGrepToolCall
| PiLsToolCall
| PiUnknownToolCall;
interface ToolCallOutputSummary {
output?: string;
exitCode?: number | null;
}
const PiToolResultTextContentSchema = z.object({
type: z.literal("text"),
text: z.string(),
});
const PiToolResultUnknownContentSchema = z
.object({
type: z.string(),
})
.passthrough();
const PiToolResultContentSchema = z.union([
PiToolResultTextContentSchema,
PiToolResultUnknownContentSchema,
]);
const PiToolResultDetailsSchema = z
.object({
diff: z.string().optional(),
})
.passthrough();
const PiToolResultObjectSchema = z
.object({
output: z.string().optional(),
stdout: z.string().optional(),
text: z.string().optional(),
content: z.array(PiToolResultContentSchema).optional(),
exitCode: z.number().optional(),
code: z.number().optional(),
details: PiToolResultDetailsSchema.optional(),
})
.passthrough();
const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
const BashToolInputSchema: z.ZodType<BashToolInput> = z.object({
command: z.string(),
timeout: z.number().optional(),
});
const ReadToolInputSchema: z.ZodType<ReadToolInput> = z.object({
path: z.string(),
offset: z.number().optional(),
limit: z.number().optional(),
});
const EditToolInputSchema: z.ZodType<EditToolInput> = z.object({
path: z.string(),
edits: z.array(
z.object({
oldText: z.string(),
newText: z.string(),
}),
),
});
const LegacyEditToolInputSchema = z.object({
path: z.string(),
old_string: z.string().optional(),
oldString: z.string().optional(),
new_string: z.string().optional(),
newString: z.string().optional(),
});
const WriteToolInputSchema: z.ZodType<WriteToolInput> = z.object({
path: z.string(),
content: z.string(),
});
const FindToolInputSchema: z.ZodType<FindToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
limit: z.number().optional(),
});
const GrepToolInputSchema: z.ZodType<GrepToolInput> = z.object({
pattern: z.string(),
path: z.string().optional(),
glob: z.string().optional(),
ignoreCase: z.boolean().optional(),
literal: z.boolean().optional(),
context: z.number().optional(),
limit: z.number().optional(),
});
const LsToolInputSchema: z.ZodType<LsToolInput> = z.object({
path: z.string().optional(),
limit: z.number().optional(),
});
export function parseToolResult(rawResult: unknown): PiToolResult {
const parsed = PiToolResultSchema.safeParse(rawResult);
if (parsed.success) {
return parsed.data;
}
return null;
}
export function extractTextFromToolResult(result: PiToolResult): string | undefined {
if (typeof result === "string") {
return result;
}
if (!result) {
return undefined;
}
const directText = result.output ?? result.stdout ?? result.text;
if (directText) {
return directText;
}
if (!result.content) {
return undefined;
}
const textParts: string[] = [];
for (const block of result.content) {
if (block.type === "text" && "text" in block) {
textParts.push(block.text);
}
}
return textParts.length > 0 ? textParts.join("\n") : undefined;
}
export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedToolCall {
if (toolName === "edit") {
return parseEditToolArgs(rawArgs);
}
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
if (schema) {
const parsed = schema.safeParse(rawArgs);
if (parsed.success) {
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
}
}
return { kind: "unknown", toolName, args: rawArgs ?? null };
}
function stripMcpProxyPrefix(toolName: string, serverName: string): string {
const prefix = `${serverName}_`;
return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
}
export function resolveToolCallName(toolCall: PiTrackedToolCall, result?: PiToolResult): string {
if (toolCall.toolName !== "mcp") {
return toolCall.toolName;
}
if (result && typeof result !== "string") {
const serverName = readNonEmptyString(result.details?.server);
const toolName = readNonEmptyString(result.details?.tool);
if (serverName && toolName) {
return `${serverName}.${toolName}`;
}
}
if (isRecord(toolCall.args)) {
const requestedTool = readNonEmptyString(toolCall.args.tool);
const requestedServer = readNonEmptyString(toolCall.args.server);
if (requestedTool && requestedServer) {
return `${requestedServer}.${stripMcpProxyPrefix(requestedTool, requestedServer)}`;
}
if (requestedTool) {
const [serverName, ...toolParts] = requestedTool.split("_");
if (serverName && toolParts.length > 0) {
return `${serverName}.${toolParts.join("_")}`;
}
}
}
return toolCall.toolName;
}
export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): ToolCallDetail {
const parsedResult = result ?? null;
switch (toolCall.kind) {
case "bash": {
const summary = resolveToolCallOutput(parsedResult);
return {
type: "shell",
command: toolCall.args.command,
output: summary.output,
exitCode: summary.exitCode,
};
}
case "read":
return {
type: "read",
filePath: toolCall.args.path,
content: extractTextFromToolResult(parsedResult),
offset: toolCall.args.offset,
limit: toolCall.args.limit,
};
case "edit": {
const firstEdit = toolCall.args.edits[0];
const unifiedDiff =
parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined;
return {
type: "edit",
filePath: toolCall.args.path,
oldString: firstEdit?.oldText,
newString: firstEdit?.newText,
unifiedDiff,
};
}
case "write":
return {
type: "write",
filePath: toolCall.args.path,
content: toolCall.args.content,
};
case "find":
return mapFindToolDetail(toolCall.args, parsedResult);
case "grep":
return mapGrepToolDetail(toolCall.args, parsedResult);
case "ls":
return mapLsToolDetail(toolCall.args, parsedResult);
default:
return {
type: "unknown",
input: toolCall.args,
output: parsedResult,
};
}
}
function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary {
if (typeof result === "string") {
return { output: result };
}
if (!result) {
return {};
}
const summary: ToolCallOutputSummary = {
output: extractTextFromToolResult(result),
};
if (typeof result.exitCode === "number") {
summary.exitCode = result.exitCode;
return summary;
}
if (typeof result.code === "number") {
summary.exitCode = result.code;
return summary;
}
summary.exitCode = null;
return summary;
}
function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null {
const parsed = LegacyEditToolInputSchema.safeParse(rawArgs);
if (!parsed.success) {
return null;
}
const oldText = parsed.data.old_string ?? parsed.data.oldString;
const newText = parsed.data.new_string ?? parsed.data.newString;
if (!oldText || newText === undefined) {
return null;
}
return {
path: parsed.data.path,
edits: [{ oldText, newText }],
};
}
function parseEditToolArgs(rawArgs: unknown): PiTrackedToolCall {
const parsed = EditToolInputSchema.safeParse(rawArgs);
if (parsed.success) {
return { kind: "edit", toolName: "edit", args: parsed.data };
}
const legacyArgs = normalizeLegacyEditArgs(rawArgs);
if (legacyArgs) {
return { kind: "edit", toolName: "edit", args: legacyArgs };
}
return { kind: "unknown", toolName: "edit", args: rawArgs ?? null };
}
type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls";
const SIMPLE_TOOL_SCHEMAS: {
[K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } };
} = {
bash: BashToolInputSchema,
read: ReadToolInputSchema,
write: WriteToolInputSchema,
find: FindToolInputSchema,
grep: GrepToolInputSchema,
ls: LsToolInputSchema,
};
function mapFindToolDetail(args: FindToolInput, result: PiToolResult): ToolCallDetail {
return {
type: "search",
query: args.pattern,
toolName: "search",
content: typeof result === "string" ? result : undefined,
};
}
function mapGrepToolDetail(args: GrepToolInput, result: PiToolResult): ToolCallDetail {
return {
type: "search",
query: args.pattern,
toolName: "grep",
content: typeof result === "string" ? result : undefined,
};
}
function mapLsToolDetail(args: LsToolInput, result: PiToolResult): ToolCallDetail {
return {
type: "search",
query: args.path ?? "ls",
content: typeof result === "string" ? result : undefined,
};
}
@@ -0,0 +1,10 @@
export function composeSystemPromptParts(
...parts: Array<string | null | undefined>
): string | undefined {
const prompt = parts
.map((part) => part?.trim())
.filter((part): part is string => typeof part === "string" && part.length > 0)
.join("\n\n");
return prompt.length > 0 ? prompt : undefined;
}
+6
View File
@@ -230,6 +230,7 @@ export interface PaseoDaemonConfig {
mcpEnabled?: boolean;
mcpInjectIntoAgents?: boolean;
autoArchiveAfterMerge?: boolean;
appendSystemPrompt?: string;
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -290,6 +291,7 @@ export async function createPaseoDaemon(
]),
),
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
appendSystemPrompt: config.appendSystemPrompt ?? "",
},
logger,
);
@@ -516,6 +518,7 @@ export async function createPaseoDaemon(
},
providerDefinitions: providerRegistry,
registry: agentStorage,
appendSystemPrompt: config.appendSystemPrompt,
logger,
});
@@ -862,6 +865,9 @@ export async function createPaseoDaemon(
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
});
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
});
const relayEnabled = config.relayEnabled ?? true;
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
+14 -2
View File
@@ -256,6 +256,10 @@ function resolveAuthConfig(
: undefined;
}
function resolveAppendSystemPrompt(persisted: ReturnType<typeof loadPersistedConfig>): string {
return persisted.daemon?.appendSystemPrompt ?? "";
}
function resolveStaticLoadConfigSettings(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
@@ -266,6 +270,7 @@ function resolveStaticLoadConfigSettings(
mcpInjectIntoAgents:
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
appendSystemPrompt: resolveAppendSystemPrompt(persisted),
hostnames: mergeHostnames([
persisted.daemon?.hostnames,
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
@@ -286,8 +291,14 @@ export function loadConfig(
const persisted = loadPersistedConfig(paseoHome);
const listen = resolveListenAddress(env, options?.cli, persisted);
const { mcpEnabled, mcpInjectIntoAgents, autoArchiveAfterMerge, hostnames, appBaseUrl } =
resolveStaticLoadConfigSettings(env, options?.cli, persisted);
const {
mcpEnabled,
mcpInjectIntoAgents,
autoArchiveAfterMerge,
appendSystemPrompt,
hostnames,
appBaseUrl,
} = resolveStaticLoadConfigSettings(env, options?.cli, persisted);
const relay = resolveRelayConfig({
env,
@@ -315,6 +326,7 @@ export function loadConfig(
mcpEnabled,
mcpInjectIntoAgents,
autoArchiveAfterMerge,
appendSystemPrompt,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -107,6 +107,28 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists append system prompt into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
paseoHome,
{
mcp: { injectIntoAgents: false },
providers: {},
appendSystemPrompt: "",
},
undefined,
);
store.patch({
appendSystemPrompt: "Prefer terse replies.",
});
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
test("patch persists provider additional models into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -144,6 +166,28 @@ describe("DaemonConfigStore", () => {
});
});
test("patch persists daemon append system prompt into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(
paseoHome,
{
mcp: { injectIntoAgents: false },
providers: {},
appendSystemPrompt: "",
},
undefined,
);
store.patch({
appendSystemPrompt: "Prefer terse replies.",
});
const persisted = loadPersistedConfig(paseoHome);
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
test("patch persists custom ACP provider overrides into config.json", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -186,6 +186,7 @@ function mergeMutableConfigIntoPersistedConfig(params: {
injectIntoAgents: mutable.mcp.injectIntoAgents,
},
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
appendSystemPrompt: mutable.appendSystemPrompt,
},
agents:
providerOverrides && Object.keys(providerOverrides).length > 0
@@ -10,7 +10,7 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
} from "../agent/agent-sdk-types.js";
import { PiDirectAgentClient } from "../agent/providers/pi-direct-agent.js";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { isProviderAvailable } from "./agent-configs.js";
@@ -26,28 +26,18 @@ function tmpCwd(prefix = "daemon-real-pi-"): string {
return mkdtempSync(path.join(tmpdir(), prefix));
}
function createPiClient(): PiDirectAgentClient {
return new PiDirectAgentClient({ logger: pino({ level: "silent" }) });
function createPiClient(): PiRpcAgentClient {
return new PiRpcAgentClient({ logger: pino({ level: "silent" }) });
}
function createPiToolDaemon() {
const logger = pino({ level: "silent" });
return createTestPaseoDaemon({
agentClients: { pi: new PiDirectAgentClient({ logger }) },
agentClients: { pi: new PiRpcAgentClient({ logger }) },
logger,
});
}
function extractAssistantText(items: AgentTimelineItem[]): string {
return items
.filter(
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
item.type === "assistant_message",
)
.map((item) => item.text)
.join("\n");
}
function extractCompletedToolCalls(items: AgentTimelineItem[]): ToolCallItem[] {
return items.filter(
(item): item is ToolCallItem => item.type === "tool_call" && item.status === "completed",
@@ -77,7 +67,10 @@ async function withConnectedPiDaemon(
run: (context: { client: DaemonClient }) => Promise<void>,
): Promise<void> {
const daemon = await createPiToolDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.45",
});
try {
await client.connect();
@@ -344,14 +337,16 @@ test(
await client.deleteAgent(agent.id);
const resumed = await client.resumeAgent(handle);
await client.sendMessage(resumed.id, "What was the code I asked you to remember?");
expect(resumed.provider).toBe("pi");
expect(resumed.cwd).toBe(cwd);
await client.sendMessage(resumed.id, "Reply with exactly: resumed");
const resumedFinish = await client.waitForFinish(resumed.id, PI_TEST_TIMEOUT_MS);
expect(resumedFinish.status).toBe("idle");
const items = await fetchCanonicalTimeline(client, resumed.id);
const assistantText = extractAssistantText(items).toUpperCase();
expect(assistantText.includes(rememberedToken) || assistantText.includes("42")).toBe(true);
expect(resumedFinish.final?.persistence).toBeTruthy();
expect(resumedFinish.final?.persistence?.provider).toBe("pi");
expect(resumedFinish.final?.persistence?.nativeHandle).toBe(handle.nativeHandle);
});
} finally {
rmSync(cwd, { recursive: true, force: true });
@@ -361,7 +356,7 @@ test(
);
test(
"streamHistory replays user/assistant/tool_call timeline after resume",
"streamHistory replays user and assistant timeline after resume",
async () => {
const cwd = tmpCwd("pi-history-prime-");
const marker = "HISTORY_PRIME_MARKER_4242";
@@ -376,18 +371,8 @@ test(
let handle: AgentPersistenceHandle | null = null;
try {
const result = await session.run(
`Use the bash tool to run this exact bash command: echo ${marker}`,
);
const hadShellToolCall = result.timeline.some(
(item) =>
item.type === "tool_call" &&
item.status === "completed" &&
item.detail.type === "shell" &&
item.detail.command.includes(`echo ${marker}`),
);
expect(hadShellToolCall).toBe(true);
const result = await session.run(`Reply with exactly this token and nothing else: ${marker}`);
expect(result.finalText).toContain(marker);
handle = session.describePersistence();
expect(handle).toBeTruthy();
@@ -423,15 +408,7 @@ test(
item.type === "assistant_message",
);
expect(assistantItems.length).toBeGreaterThan(0);
const toolCallItem = findCompletedToolCall(
items,
(item) => item.detail.type === "shell" && item.detail.command.includes(`echo ${marker}`),
);
expect(toolCallItem).toBeDefined();
if (toolCallItem?.detail.type === "shell") {
expect(toolCallItem.detail.output).toContain(marker);
}
expect(assistantItems.some((item) => item.text.includes(marker))).toBe(true);
} finally {
await resumed.close();
rmSync(cwd, { recursive: true, force: true });
@@ -441,7 +418,7 @@ test(
);
test(
"PiDirectAgentClient.listModels returns non-empty Pi model definitions",
"PiRpcAgentClient.listModels returns non-empty Pi model definitions",
async () => {
const client = createPiClient();
const cwd = tmpCwd("pi-list-models-");
@@ -34,6 +34,18 @@ describe("PersistedConfigSchema daemon auth config", () => {
});
});
describe("PersistedConfigSchema daemon append system prompt config", () => {
test("accepts optional append system prompt", () => {
const parsed = PersistedConfigSchema.parse({
daemon: {
appendSystemPrompt: "Prefer terse replies.",
},
});
expect(parsed.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
});
describe("PersistedConfigSchema daemon relay config", () => {
test("accepts optional relay TLS setting", () => {
const parsed = PersistedConfigSchema.parse({
@@ -51,6 +63,18 @@ describe("PersistedConfigSchema daemon relay config", () => {
});
});
describe("PersistedConfigSchema daemon append system prompt", () => {
test("accepts optional append system prompt", () => {
const parsed = PersistedConfigSchema.parse({
daemon: {
appendSystemPrompt: "Prefer terse replies.",
},
});
expect(parsed.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
});
});
describe("PersistedConfigSchema agent provider runtime settings", () => {
test("legacy append entries are skipped during migration", () => {
const parsed = PersistedConfigSchema.parse({
@@ -251,6 +251,7 @@ export const PersistedConfigSchema = z
.passthrough()
.optional(),
autoArchiveAfterMerge: z.boolean().optional(),
appendSystemPrompt: z.string().optional(),
cors: z
.object({
allowedOrigins: z.array(z.string()).optional(),
@@ -269,7 +269,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
vi.useRealTimers();
});
test("getSnapshot returns cached warm value without shelling out within consumer TTL", async () => {
test("getSnapshot returns the current snapshot without shelling out", async () => {
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({
@@ -411,7 +411,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose();
});
test("warm non-forced getSnapshot calls attach to an in-flight refresh instead of returning stale cache", async () => {
test("non-forced getSnapshot returns the current snapshot during an in-flight refresh", async () => {
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
const refreshStatus = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi
@@ -434,24 +434,34 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
}),
);
nowMs += 16_000;
const first = service.getSnapshot(REPO_CWD);
await flushPromises();
const second = service.getSnapshot(REPO_CWD);
const initialSnapshot = createSnapshot(REPO_CWD, {
git: { diffStat: { additions: 4, deletions: 2 } },
});
nowMs += 3_000;
const refresh = service.refresh(REPO_CWD);
await flushPromises();
const directRead = service.getSnapshot(REPO_CWD);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
await expect(directRead).resolves.toEqual(initialSnapshot);
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
const freshSnapshot = createSnapshot(REPO_CWD, {
git: {
currentBranch: "feature",
diffStat: { additions: 4, deletions: 2 },
},
});
await expect(Promise.all([first, second])).resolves.toEqual([freshSnapshot, freshSnapshot]);
await refresh;
expect(service.peekSnapshot(REPO_CWD)).toEqual(
createSnapshot(REPO_CWD, {
git: {
currentBranch: "feature",
diffStat: { additions: 4, deletions: 2 },
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
}),
);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
@@ -543,10 +553,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
.fn<() => Promise<CheckoutStatusGit>>()
.mockImplementationOnce(async () => forcedGitRefresh.promise)
.mockImplementation(async () => createCheckoutStatus(REPO_CWD));
const getPullRequestStatus = vi
.fn()
.mockResolvedValueOnce(createPullRequestStatusResult("Stale cached PR"))
.mockResolvedValueOnce(createPullRequestStatusResult("Fresh validation PR"));
const getPullRequestStatus = vi.fn(async () =>
createPullRequestStatusResult("Fresh validation PR"),
);
const service = createService({ getCheckoutStatus, getPullRequestStatus });
const gitRefresh = service.getSnapshot(REPO_CWD, {
@@ -584,7 +593,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await gitRefresh;
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getPullRequestStatus).toHaveBeenNthCalledWith(2, REPO_CWD, expect.anything(), {
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), {
force: true,
reason: "merge-pr-validation",
});
@@ -634,7 +644,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose();
});
test("consumer TTL triggers cold-load after expiry on getSnapshot", async () => {
test("non-forced getSnapshot keeps returning the current snapshot after time passes", async () => {
let nowMs = 0;
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const service = createService({
@@ -646,12 +656,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
nowMs = 16_000;
await service.getSnapshot(REPO_CWD);
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
service.dispose();
});
test("self-heal timer fires at the interval and calls getSnapshot without force", async () => {
test("self-heal timer refreshes git without refreshing GitHub", async () => {
let nowMs = 0;
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
@@ -668,9 +678,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
expect(getPullRequestStatus).toHaveBeenLastCalledWith(REPO_CWD, expect.anything(), {
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), {
force: false,
reason: "self-heal-git",
reason: "initial",
});
subscription.unsubscribe();
@@ -803,8 +814,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
github,
now: () => new Date(nowMs),
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
const listener = vi.fn();
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
await flushPromises();
await vi.advanceTimersByTimeAsync(0);
await flushPromises();
const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length;
nowMs = 20_000;
await vi.advanceTimersByTimeAsync(20_000);
@@ -814,6 +829,73 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
reason: "self-heal-github",
tickMs: 20_000,
});
expect(getCheckoutStatus).toHaveBeenCalledTimes(gitReadsAfterInitialSnapshot);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
github: expect.objectContaining({
pullRequest: expect.objectContaining({
checksStatus: "pending",
}),
}),
}),
);
subscription.unsubscribe();
service.dispose();
github.dispose?.();
});
test("settled GitHub self-heal reads stay on the slow poll window without refreshing git", async () => {
let nowMs = 0;
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];
const github = createGitHubService({
ttlMs: 0,
runner: vi.fn(async () => ({
stdout: currentPullRequestJson(),
stderr: "",
})),
resolveGhPath: async () => "/usr/bin/gh",
now: () => nowMs,
});
const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github);
github.getCurrentPullRequestStatus = vi.fn(
async (options): Promise<GitHubCurrentPullRequestStatus | null> => {
githubReadCalls.push({ reason: options.reason, tickMs: nowMs });
return getCurrentPullRequestStatus(options);
},
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
createCheckoutStatus(cwd, { currentBranch: "feature" }),
);
const service = createService({
getCheckoutStatus,
github,
now: () => new Date(nowMs),
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
await vi.advanceTimersByTimeAsync(0);
await flushPromises();
const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length;
nowMs = 20_000;
await vi.advanceTimersByTimeAsync(20_000);
await flushPromises();
expect(githubReadCalls).not.toContainEqual({
reason: "self-heal-github",
tickMs: 20_000,
});
expect(getCheckoutStatus).toHaveBeenCalledTimes(gitReadsAfterInitialSnapshot);
nowMs = 120_000;
await vi.advanceTimersByTimeAsync(100_000);
await flushPromises();
expect(githubReadCalls).toContainEqual({
reason: "self-heal-github",
tickMs: 120_000,
});
subscription.unsubscribe();
service.dispose();
@@ -929,7 +1011,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
});
test("self-heal poll coalesces with a concurrent direct getSnapshot call", async () => {
test("direct getSnapshot returns current snapshot during a self-heal refresh", async () => {
let nowMs = 0;
const selfHealRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi
@@ -950,9 +1032,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
await expect(directRead).resolves.toEqual(createSnapshot(REPO_CWD));
selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD));
await directRead;
await flushPromises();
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
@@ -341,7 +341,7 @@ describe("WorkspaceGitServiceImpl", () => {
);
});
test("non-forced GitHub refresh does not emit when pull request state is unchanged", async () => {
test("non-forced workspace refresh does not reload GitHub or emit when state is unchanged", async () => {
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const service = createService({
@@ -355,7 +355,7 @@ describe("WorkspaceGitServiceImpl", () => {
nowMs += 3_000;
await service.refresh(REPO_CWD);
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(listener).not.toHaveBeenCalled();
subscription.unsubscribe();
@@ -401,7 +401,7 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
test("multiple listeners on the same workspace share one observation setup", async () => {
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
@@ -416,7 +416,7 @@ describe("WorkspaceGitServiceImpl", () => {
const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
first.unsubscribe();
@@ -448,7 +448,7 @@ describe("WorkspaceGitServiceImpl", () => {
createSnapshot(REPO_CWD),
);
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
@@ -489,7 +489,7 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
test("explicit refresh recomputes github state and notifies listeners", async () => {
test("explicit forced snapshot refresh recomputes github state and notifies listeners", async () => {
const getPullRequestStatus = vi
.fn<() => Promise<PullRequestStatusResult>>()
.mockResolvedValueOnce(
@@ -529,7 +529,10 @@ describe("WorkspaceGitServiceImpl", () => {
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
await service.refresh(REPO_CWD);
await service.getSnapshot(REPO_CWD, {
force: true,
reason: "test-force-github-refresh",
});
await flushPromises();
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
@@ -41,8 +41,8 @@ const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
// Consumer reads may reuse cached values within this window; older peeks cold-load through the service.
const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000;
// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read.
const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000;
// Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it.
const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000;
const LINUX_WATCH_MAX_DIRS = 5_000;
@@ -262,6 +262,10 @@ interface WorkspaceGitTarget {
githubPollSubscription: { unsubscribe: () => void } | null;
githubPollHeadRef: string | null;
refreshState: WorkspaceGitRefreshState;
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
latestGitLoadedAtMs: number | null;
latestGithub: WorkspaceGitRuntimeSnapshot["github"] | null;
latestGithubLoadedAtMs: number | null;
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
latestSnapshotLoadedAtMs: number | null;
latestFingerprint: string | null;
@@ -409,7 +413,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd = normalizeWorkspaceId(cwd);
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
const target = this.ensureWorkspaceTarget(cwd);
if (!request.force && this.isSnapshotWarm(target)) {
if (!request.force && target.latestSnapshot) {
return target.latestSnapshot;
}
@@ -698,7 +702,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const nowMs = this.deps.now().getTime();
if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) {
const ageMs = nowMs - entry.loadedAtMs;
if (ageMs <= WORKSPACE_GIT_CONSUMER_TTL_MS) {
if (ageMs <= WORKSPACE_GIT_AUXILIARY_READ_TTL_MS) {
return Promise.resolve(entry.value);
}
if (
@@ -773,6 +777,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
githubPollSubscription: null,
githubPollHeadRef: null,
refreshState: { status: "idle" },
latestGit: null,
latestGitLoadedAtMs: null,
latestGithub: null,
latestGithubLoadedAtMs: null,
latestSnapshot: null,
latestSnapshotLoadedAtMs: null,
latestFingerprint: null,
@@ -1045,7 +1053,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (!target.selfHealTimer) {
target.selfHealTimer = setInterval(() => {
this.scheduleWorkspaceObservationSetup(target);
this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => {
this.refreshWorkspaceTarget(target, {
force: false,
includeGitHub: false,
reason: "self-heal-git",
notify: true,
}).catch((error) => {
this.logger.warn(
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
"Failed to run workspace git self-heal refresh",
@@ -1063,15 +1076,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return;
}
const snapshot = target.latestSnapshot;
if (!snapshot || !this.deps.github.retainCurrentPullRequestStatusPoll) {
const git = target.latestGit;
if (!git || !this.deps.github.retainCurrentPullRequestStatusPoll) {
this.stopGitHubPollForTarget(target);
return;
}
const headRef = snapshot.git.currentBranch;
const headRef = git.currentBranch;
const hasGitHubRemote =
target.cachedGitHubRemote?.remoteUrl === snapshot.git.remoteUrl &&
target.cachedGitHubRemote?.remoteUrl === git.remoteUrl &&
target.cachedGitHubRemote.identity !== null;
if (!headRef || !hasGitHubRemote) {
this.stopGitHubPollForTarget(target);
@@ -1086,14 +1099,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
cwd: target.cwd,
headRef,
onStatus: () => {
onStatus: (status) => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
}
void this.refreshWorkspaceTarget(target, {
force: false,
includeGitHub: true,
reason: "self-heal-github",
this.rememberGitHubSnapshot(target, buildGitHubSnapshotFromStatus(status), {
notify: true,
});
},
@@ -1381,25 +1391,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const force = options?.force === true;
return {
force,
includeGitHub: options?.includeGitHub ?? force,
includeGitHub: options?.includeGitHub ?? true,
reason: options?.reason ?? defaultReason,
notify,
};
}
private isSnapshotWarm(target: WorkspaceGitTarget): target is WorkspaceGitTarget & {
latestSnapshot: WorkspaceGitRuntimeSnapshot;
latestSnapshotLoadedAtMs: number;
} {
if (!target.latestSnapshot || target.latestSnapshotLoadedAtMs === null) {
return false;
}
return (
this.deps.now().getTime() - target.latestSnapshotLoadedAtMs <= WORKSPACE_GIT_CONSUMER_TTL_MS
);
}
private async resolveGitHubRemoteForTarget(
target: WorkspaceGitTarget,
remoteUrl: string | null,
@@ -1484,57 +1481,128 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> {
await this.refreshGitSnapshot(target, request);
if (request.includeGitHub) {
await this.refreshGitHubSnapshot(target, request);
}
const snapshot = this.combineSnapshot(target);
target.latestSnapshotLoadedAtMs = this.deps.now().getTime();
return snapshot;
}
private async refreshGitSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<void> {
const now = this.deps.now();
target.lastShellOutAtMs = now.getTime();
const cwd = target.cwd;
const previousGitHubPollKey = this.getGitHubPollKey(target);
const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
if (!checkoutStatus.isGit) {
target.latestGit = buildNotGitSnapshot(cwd).git;
target.latestGitLoadedAtMs = this.deps.now().getTime();
target.cachedGitHubRemote = null;
target.latestGithub = buildGitHubUnavailableSnapshot();
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
return;
}
await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
const diffStat = await this.deps
.getCheckoutShortstat(cwd, context, { force: request.force })
.catch(() => null);
target.latestGit = {
isGit: true,
repoRoot: checkoutStatus.repoRoot,
mainRepoRoot: checkoutStatus.mainRepoRoot,
currentBranch: checkoutStatus.currentBranch,
remoteUrl: checkoutStatus.remoteUrl,
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
isDirty: checkoutStatus.isDirty,
baseRef: checkoutStatus.baseRef,
aheadBehind: checkoutStatus.aheadBehind,
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
behindOfOrigin: checkoutStatus.behindOfOrigin,
hasRemote: checkoutStatus.hasRemote,
diffStat,
};
target.latestGitLoadedAtMs = this.deps.now().getTime();
if (previousGitHubPollKey !== this.getGitHubPollKey(target)) {
target.latestGithub = buildGitHubUnavailableSnapshot();
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
}
}
private async refreshGitHubSnapshot(
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<void> {
const githubRemote = target.cachedGitHubRemote?.identity ?? null;
const forceGitHub = request.force && request.includeGitHub;
if (forceGitHub) {
this.deps.github.invalidate({ cwd: target.cwd });
}
const cwd = target.cwd;
const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
if (!checkoutStatus.isGit) {
target.latestSnapshotLoadedAtMs = now.getTime();
return buildNotGitSnapshot(cwd);
target.latestGithub = await loadGitHubSnapshot({
cwd: target.cwd,
githubRemote,
now: this.deps.now(),
deps: this.deps,
force: forceGitHub,
reason: request.reason,
});
target.latestGithubLoadedAtMs = this.deps.now().getTime();
}
private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot {
if (!target.latestGit) {
return target.latestSnapshot ?? buildNotGitSnapshot(target.cwd);
}
const githubRemote = await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
const [diffStat, github] = await Promise.all([
this.deps.getCheckoutShortstat(cwd, context, { force: request.force }).catch(() => null),
loadGitHubSnapshot({
cwd,
githubRemote,
now,
deps: this.deps,
force: forceGitHub,
reason: request.reason,
}),
]);
target.latestSnapshotLoadedAtMs = now.getTime();
return {
cwd,
git: {
isGit: true,
repoRoot: checkoutStatus.repoRoot,
mainRepoRoot: checkoutStatus.mainRepoRoot,
currentBranch: checkoutStatus.currentBranch,
remoteUrl: checkoutStatus.remoteUrl,
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
isDirty: checkoutStatus.isDirty,
baseRef: checkoutStatus.baseRef,
aheadBehind: checkoutStatus.aheadBehind,
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
behindOfOrigin: checkoutStatus.behindOfOrigin,
hasRemote: checkoutStatus.hasRemote,
diffStat,
},
github,
cwd: target.cwd,
git: target.latestGit,
github: target.latestGithub ?? buildGitHubUnavailableSnapshot(),
};
}
private getGitHubPollKey(target: WorkspaceGitTarget): string | null {
const git = target.latestGit;
if (!git?.currentBranch || !git.remoteUrl) {
return null;
}
const githubRemote = target.cachedGitHubRemote;
if (!githubRemote || githubRemote.remoteUrl !== git.remoteUrl || !githubRemote.identity) {
return null;
}
return JSON.stringify([git.remoteUrl, git.currentBranch]);
}
private rememberGitHubSnapshot(
target: WorkspaceGitTarget,
github: WorkspaceGitRuntimeSnapshot["github"],
options?: { notify?: boolean },
): void {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.latestGithub = github;
target.latestGithubLoadedAtMs = this.deps.now().getTime();
this.rememberSnapshot(target, this.combineSnapshot(target), {
notify: options?.notify,
forceEmit: false,
});
}
private rememberSnapshot(
target: WorkspaceGitTarget,
snapshot: WorkspaceGitRuntimeSnapshot,
@@ -1794,11 +1862,25 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
hasRemote: false,
diffStat: null,
},
github: {
featuresEnabled: false,
pullRequest: null,
error: null,
},
github: buildGitHubUnavailableSnapshot(),
};
}
function buildGitHubUnavailableSnapshot(): WorkspaceGitRuntimeSnapshot["github"] {
return {
featuresEnabled: false,
pullRequest: null,
error: null,
};
}
function buildGitHubSnapshotFromStatus(
status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"],
): WorkspaceGitRuntimeSnapshot["github"] {
return {
featuresEnabled: true,
pullRequest: status,
error: null,
};
}
@@ -3,4 +3,4 @@
* providers (gemini, copilot, generic acp) are excluded because they either
* don't expose persisted history quickly or duplicate other providers.
*/
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode"] as const;
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode", "pi"] as const;
+2
View File
@@ -114,6 +114,7 @@ export const MutableDaemonConfigSchema = z
.passthrough(),
providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}),
autoArchiveAfterMerge: z.boolean().default(false),
appendSystemPrompt: z.string().default(""),
})
.passthrough();
@@ -124,6 +125,7 @@ export const MutableDaemonConfigPatchSchema = z
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
.optional(),
autoArchiveAfterMerge: z.boolean().optional(),
appendSystemPrompt: z.string().optional(),
})
.partial()
.passthrough();
+35
View File
@@ -1,4 +1,5 @@
import os from "node:os";
import { realpathSync } from "node:fs";
import nodePath from "node:path";
/**
@@ -46,6 +47,40 @@ export function createPathEquivalenceMatcher(target: string): (candidate: string
};
}
export function createRealpathAwarePathMatcher(target: string): (candidate: string) => boolean {
const targetMatchers = collectPathVariants(target).map((variant) =>
createPathEquivalenceMatcher(variant),
);
return (candidate) => {
const candidateVariants = collectPathVariants(candidate);
return candidateVariants.some((variant) => targetMatchers.some((matches) => matches(variant)));
};
}
function collectPathVariants(value: string): string[] {
const variants = new Set<string>([value]);
for (const realpath of resolveRealpathVariants(value)) {
variants.add(realpath);
}
return Array.from(variants);
}
function resolveRealpathVariants(value: string): string[] {
const variants: string[] = [];
try {
variants.push(realpathSync.native(value));
} catch {
// Path may not exist, or the platform may reject this link flavor.
}
try {
variants.push(realpathSync(value));
} catch {
// Keep string-only comparison as the fallback.
}
return variants;
}
function shouldCompareAsWindows(left: string, right: string): boolean {
return looksLikeDefiniteWindowsPath(left) || looksLikeDefiniteWindowsPath(right);
}
@@ -54,6 +54,12 @@
},
"additionalProperties": true
},
"autoArchiveAfterMerge": {
"type": "boolean"
},
"appendSystemPrompt": {
"type": "string"
},
"cors": {
"type": "object",
"properties": {
@@ -77,6 +83,22 @@
},
"publicEndpoint": {
"type": "string"
},
"useTls": {
"type": "boolean"
},
"publicUseTls": {
"type": "boolean"
}
},
"additionalProperties": false
},
"auth": {
"type": "object",
"properties": {
"password": {
"type": "string",
"pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$"
}
},
"additionalProperties": false
@@ -248,6 +270,10 @@
"type": "string",
"minLength": 1
},
"language": {
"type": "string",
"minLength": 1
},
"confidenceThreshold": {
"type": "number"
}
@@ -285,6 +311,10 @@
"model": {
"type": "string",
"minLength": 1
},
"language": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": false
+10
View File
@@ -187,6 +187,16 @@ export const AGENT_PAGES = [
metaDescription:
"Run the LangChain DeepAgents coding agent from your phone. Launch sessions, monitor progress, and ship code from anywhere.",
},
{
slug: "deepseek-tui",
name: "DeepSeek TUI",
title: "DeepSeek TUI from your phone",
subtitle:
"Run the DeepSeek V4 terminal coding agent on your machine, then drive it from anywhere.",
metaTitle: "DeepSeek TUI Mobile App – Run DeepSeek TUI from anywhere | Paseo",
metaDescription:
"Drive DeepSeek TUI from your phone. Launch coding sessions on your machine, monitor progress, and ship from anywhere.",
},
{
slug: "dimcode",
name: "DimCode",
+21
View File
@@ -34,6 +34,7 @@ import { Route as DownloadRouteImport } from "./routes/download";
import { Route as DocsRouteImport } from "./routes/docs";
import { Route as DiracRouteImport } from "./routes/dirac";
import { Route as DimcodeRouteImport } from "./routes/dimcode";
import { Route as DeepseekTuiRouteImport } from "./routes/deepseek-tui";
import { Route as DeepagentsRouteImport } from "./routes/deepagents";
import { Route as CursorRouteImport } from "./routes/cursor";
import { Route as CrowRouteImport } from "./routes/crow";
@@ -183,6 +184,11 @@ const DimcodeRoute = DimcodeRouteImport.update({
path: "/dimcode",
getParentRoute: () => rootRouteImport,
} as any);
const DeepseekTuiRoute = DeepseekTuiRouteImport.update({
id: "/deepseek-tui",
path: "/deepseek-tui",
getParentRoute: () => rootRouteImport,
} as any);
const DeepagentsRoute = DeepagentsRouteImport.update({
id: "/deepagents",
path: "/deepagents",
@@ -319,6 +325,7 @@ export interface FileRoutesByFullPath {
"/crow": typeof CrowRoute;
"/cursor": typeof CursorRoute;
"/deepagents": typeof DeepagentsRoute;
"/deepseek-tui": typeof DeepseekTuiRoute;
"/dimcode": typeof DimcodeRoute;
"/dirac": typeof DiracRoute;
"/docs": typeof DocsRouteWithChildren;
@@ -368,6 +375,7 @@ export interface FileRoutesByTo {
"/crow": typeof CrowRoute;
"/cursor": typeof CursorRoute;
"/deepagents": typeof DeepagentsRoute;
"/deepseek-tui": typeof DeepseekTuiRoute;
"/dimcode": typeof DimcodeRoute;
"/dirac": typeof DiracRoute;
"/download": typeof DownloadRoute;
@@ -418,6 +426,7 @@ export interface FileRoutesById {
"/crow": typeof CrowRoute;
"/cursor": typeof CursorRoute;
"/deepagents": typeof DeepagentsRoute;
"/deepseek-tui": typeof DeepseekTuiRoute;
"/dimcode": typeof DimcodeRoute;
"/dirac": typeof DiracRoute;
"/docs": typeof DocsRouteWithChildren;
@@ -470,6 +479,7 @@ export interface FileRouteTypes {
| "/crow"
| "/cursor"
| "/deepagents"
| "/deepseek-tui"
| "/dimcode"
| "/dirac"
| "/docs"
@@ -519,6 +529,7 @@ export interface FileRouteTypes {
| "/crow"
| "/cursor"
| "/deepagents"
| "/deepseek-tui"
| "/dimcode"
| "/dirac"
| "/download"
@@ -568,6 +579,7 @@ export interface FileRouteTypes {
| "/crow"
| "/cursor"
| "/deepagents"
| "/deepseek-tui"
| "/dimcode"
| "/dirac"
| "/docs"
@@ -619,6 +631,7 @@ export interface RootRouteChildren {
CrowRoute: typeof CrowRoute;
CursorRoute: typeof CursorRoute;
DeepagentsRoute: typeof DeepagentsRoute;
DeepseekTuiRoute: typeof DeepseekTuiRoute;
DimcodeRoute: typeof DimcodeRoute;
DiracRoute: typeof DiracRoute;
DocsRoute: typeof DocsRouteWithChildren;
@@ -823,6 +836,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DimcodeRouteImport;
parentRoute: typeof rootRouteImport;
};
"/deepseek-tui": {
id: "/deepseek-tui";
path: "/deepseek-tui";
fullPath: "/deepseek-tui";
preLoaderRoute: typeof DeepseekTuiRouteImport;
parentRoute: typeof rootRouteImport;
};
"/deepagents": {
id: "/deepagents";
path: "/deepagents";
@@ -1031,6 +1051,7 @@ const rootRouteChildren: RootRouteChildren = {
CrowRoute: CrowRoute,
CursorRoute: CursorRoute,
DeepagentsRoute: DeepagentsRoute,
DeepseekTuiRoute: DeepseekTuiRoute,
DimcodeRoute: DimcodeRoute,
DiracRoute: DiracRoute,
DocsRoute: DocsRouteWithChildren,
+1 -1
View File
@@ -10,7 +10,7 @@ export const Route = createFileRoute("/agents")({
head: () => ({
meta: pageMeta(
"Supported agents – Every coding agent Paseo runs | Paseo",
"Run Claude Code, Codex, OpenCode, Cursor CLI, Gemini CLI, Hermes Agent, Qwen Code, Kimi Code, and 28 more coding agents from your phone. Self-hosted, your code stays on your machine.",
"Run Claude Code, Codex, OpenCode, Cursor CLI, Gemini CLI, Hermes Agent, Qwen Code, Kimi Code, DeepSeek TUI, and 28 more coding agents from your phone. Self-hosted, your code stays on your machine.",
),
}),
component: AgentsPage,
@@ -0,0 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { agentRouteOptions } from "~/components/agent-route";
export const Route = createFileRoute("/deepseek-tui")(agentRouteOptions("deepseek-tui"));