From da0e94fd180601bcffda76dfe41971c560c5bc73 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 19 May 2026 22:20:09 +0800 Subject: [PATCH 1/5] Add DeepSeek TUI to the ACP catalog (#1096) * Consolidate provider selection handling * List DeepSeek TUI in docs and website --- docs/custom-providers.md | 2 + docs/product.md | 2 +- .../app/src/components/agent-status-bar.tsx | 39 ++- .../components/combined-model-selector.tsx | 99 +++--- .../combined-model-selector.utils.ts | 90 ------ .../app/src/hooks/use-agent-form-state.ts | 14 +- .../src/hooks/use-agent-input-draft-core.ts | 52 ---- .../hooks/use-agent-input-draft.live.test.tsx | 23 +- .../src/hooks/use-agent-input-draft.test.ts | 76 +++-- .../app/src/hooks/use-agent-input-draft.ts | 58 ++-- .../provider-selection.test.ts} | 150 ++++++--- .../provider-selection/provider-selection.ts | 284 ++++++++++++++++++ .../resolve-agent-form.test.ts | 0 .../resolve-agent-form.ts | 2 +- .../workspace-draft-agent-tab-core.ts | 40 ++- packages/website/src/data/agent-pages.ts | 10 + packages/website/src/routeTree.gen.ts | 21 ++ packages/website/src/routes/agents.tsx | 2 +- packages/website/src/routes/deepseek-tui.tsx | 4 + 19 files changed, 629 insertions(+), 339 deletions(-) delete mode 100644 packages/app/src/components/combined-model-selector.utils.ts rename packages/app/src/{components/combined-model-selector.test.ts => provider-selection/provider-selection.test.ts} (53%) create mode 100644 packages/app/src/provider-selection/provider-selection.ts rename packages/app/src/{hooks => provider-selection}/resolve-agent-form.test.ts (100%) rename packages/app/src/{hooks => provider-selection}/resolve-agent-form.ts (99%) create mode 100644 packages/website/src/routes/deepseek-tui.tsx diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 40933afc8..f7dd2745f 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -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`: diff --git a/docs/product.md b/docs/product.md index ba1c531cf..57fa1808b 100644 --- a/docs/product.md +++ b/docs/product.md @@ -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 diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index cb4672b38..9dcc01202 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -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; 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; @@ -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], ); diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 23ff82cd1..920a1f6d6 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -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, -): 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; @@ -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 ( {providerLabel} - {hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`} + {defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`} - {hasNoModels ? null : ( + {defaultLabel ? null : ( )} @@ -317,22 +314,23 @@ function GroupedProviderRows({ onDrillDown, onSelectDefault, }: { - providers: ModelSelectorProvider[]; + providers: ProviderSelectorProvider[]; onDrillDown: (providerId: string, providerLabel: string) => void; onSelectDefault: (providerId: string) => void; }) { return ( {providers.map((provider, index) => { - const hasNoModels = provider.rows.length === 0; + const rows = getProviderModelRows(provider); + const defaultLabel = getProviderDefaultLabel(provider); return ( {index > 0 ? : null} @@ -381,7 +379,7 @@ function ProviderModelRows({ onToggleFavorite, normalizedQuery, }: { - rows: SelectorModelRow[]; + rows: ProviderSelectionModelRow[]; selectedProvider: string; selectedModel: string; favoriteKeys: Set; @@ -396,7 +394,7 @@ function ProviderModelRows({ [favoriteKeys, normalizedQuery, rows], ); const renderItem = useCallback( - ({ item }: { item: SelectorModelRow }) => ( + ({ item }: { item: ProviderSelectionModelRow }) => ( 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 ( { - 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]); diff --git a/packages/app/src/components/combined-model-selector.utils.ts b/packages/app/src/components/combined-model-selector.utils.ts deleted file mode 100644 index b39324019..000000000 --- a/packages/app/src/components/combined-model-selector.utils.ts +++ /dev/null @@ -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, -): 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 } => - 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); -} diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index fb579a53c..c99a34d7b 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -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; - modelSelectorProviders: ModelSelectorProvider[]; + modelSelectorProviders: ProviderSelectorProvider[]; isAllModelsLoading: boolean; availableThinkingOptions: NonNullable; isModelLoading: boolean; @@ -243,7 +243,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg [snapshotEntries], ); const snapshotModelSelectorProviders = useMemo( - () => buildSelectableModelSelectorProviders(snapshotEntries), + () => buildSelectableProviderSelectorProviders(snapshotEntries), [snapshotEntries], ); const snapshotSelectedEntry = useMemo( diff --git a/packages/app/src/hooks/use-agent-input-draft-core.ts b/packages/app/src/hooks/use-agent-input-draft-core.ts index 91c16bbab..45d2b37fb 100644 --- a/packages/app/src/hooks/use-agent-input-draft-core.ts +++ b/packages/app/src/hooks/use-agent-input-draft-core.ts @@ -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; -}): 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"]; diff --git a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx index 07cc2aed9..90c30756f 100644 --- a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx +++ b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx @@ -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, diff --git a/packages/app/src/hooks/use-agent-input-draft.test.ts b/packages/app/src/hooks/use-agent-input-draft.test.ts index ee9cec7d7..f34c5ff2a 100644 --- a/packages/app/src/hooks/use-agent-input-draft.test.ts +++ b/packages/app/src/hooks/use-agent-input-draft.test.ts @@ -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", }), diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 8e63f38ac..f8c468ae2 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -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( + () => ({ + 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, }; diff --git a/packages/app/src/components/combined-model-selector.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts similarity index 53% rename from packages/app/src/components/combined-model-selector.test.ts rename to packages/app/src/provider-selection/provider-selection.test.ts index 5c892159d..8d79f7d3b 100644 --- a/packages/app/src/components/combined-model-selector.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -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 }); + }); }); diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts new file mode 100644 index 000000000..871694045 --- /dev/null +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -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; +}): 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 } => + 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; +}): 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 }; +} diff --git a/packages/app/src/hooks/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts similarity index 100% rename from packages/app/src/hooks/resolve-agent-form.test.ts rename to packages/app/src/provider-selection/resolve-agent-form.test.ts diff --git a/packages/app/src/hooks/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts similarity index 99% rename from packages/app/src/hooks/resolve-agent-form.ts rename to packages/app/src/provider-selection/resolve-agent-form.ts index a54f9ae8c..3763370e2 100644 --- a/packages/app/src/hooks/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -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; diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts b/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts index c9d0d55e2..7e31b03a9 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab-core.ts @@ -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); } diff --git a/packages/website/src/data/agent-pages.ts b/packages/website/src/data/agent-pages.ts index 005bf1ee6..6091a3d10 100644 --- a/packages/website/src/data/agent-pages.ts +++ b/packages/website/src/data/agent-pages.ts @@ -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", diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index 94c697378..21fc1bdfc 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -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, diff --git a/packages/website/src/routes/agents.tsx b/packages/website/src/routes/agents.tsx index 976595904..05a4d67c8 100644 --- a/packages/website/src/routes/agents.tsx +++ b/packages/website/src/routes/agents.tsx @@ -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, diff --git a/packages/website/src/routes/deepseek-tui.tsx b/packages/website/src/routes/deepseek-tui.tsx new file mode 100644 index 000000000..b709ec6cf --- /dev/null +++ b/packages/website/src/routes/deepseek-tui.tsx @@ -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")); From 306601a0c3a0f98d3b919a812e33022b46ef35cf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 19 May 2026 22:24:56 +0800 Subject: [PATCH 2/5] Add daemon-wide system prompts (#1100) * Add daemon-wide system prompt setting * Align settings textarea rendering * Polish daemon settings layout * Remove orchestrator prompt injection --- docs/data-model.md | 1 + .../app/src/components/settings-textarea.tsx | 58 +++++++++ .../src/screens/project-settings-screen.tsx | 66 ++++------ .../app/src/screens/settings/host-page.tsx | 121 +++++++++++++++++- .../settings/providers-section.test.tsx | 7 +- .../src/server/agent/agent-manager.test.ts | 61 +++++++++ .../server/src/server/agent/agent-manager.ts | 44 ++++++- .../src/server/agent/agent-sdk-types.ts | 5 + .../server/agent/orchestrator-instructions.ts | 50 -------- .../server/agent/providers/claude/agent.ts | 10 +- .../agent/providers/codex-app-server-agent.ts | 43 +++++-- .../server/agent/providers/opencode-agent.ts | 7 +- .../server/src/server/agent/system-prompt.ts | 10 ++ packages/server/src/server/bootstrap.ts | 6 + packages/server/src/server/config.ts | 16 ++- .../src/server/daemon-config-store.test.ts | 22 ++++ .../server/src/server/daemon-config-store.ts | 1 + .../src/server/persisted-config.test.ts | 12 ++ .../server/src/server/persisted-config.ts | 1 + packages/server/src/shared/messages.ts | 2 + .../public/schemas/paseo.config.v1.json | 30 +++++ 21 files changed, 449 insertions(+), 124 deletions(-) create mode 100644 packages/app/src/components/settings-textarea.tsx delete mode 100644 packages/server/src/server/agent/orchestrator-instructions.ts create mode 100644 packages/server/src/server/agent/system-prompt.ts diff --git a/docs/data-model.md b/docs/data-model.md index b92acfa94..c11d7a65f 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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 diff --git a/packages/app/src/components/settings-textarea.tsx b/packages/app/src/components/settings-textarea.tsx new file mode 100644 index 000000000..1e19d35cf --- /dev/null +++ b/packages/app/src/components/settings-textarea.tsx @@ -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; +} + +export function SettingsTextArea({ + accessibilityLabel, + value, + onChangeText, + placeholder, + testID, + style, +}: SettingsTextAreaProps) { + const { theme } = useUnistyles(); + const inputStyle = useMemo(() => [styles.input, style], [style]); + + return ( + + ); +} + +export function SettingsTextAreaCard(props: SettingsTextAreaProps) { + return ( + + + + ); +} + +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", + }, +})); diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 26068a03f..f1dd8e3e9 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -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" > - - - + - - - + @@ -1008,18 +999,13 @@ function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPr ); return ( - - - + ); } @@ -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, diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index f7894079d..6cd2d2c3e 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -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 ( <> - - + + {isLocalDaemon ? ( @@ -544,9 +545,9 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) { - Inject Paseo tools + Enable Paseo tools - Automatically inject Paseo MCP tools into new agents + Agents will be able to manage worktrees, agents and schedules (() => ({ 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 ( + <> + + + + System prompt + Added a system prompt to all agents + + + + + + {isEditing ? ( + + + + + + + + ) : null} + + ); +} + function PairDeviceRow() { const { theme } = useUnistyles(); const [isModalOpen, setIsModalOpen] = useState(false); @@ -631,6 +737,8 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?: return ( + + @@ -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", diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 61095e834..560df9f70 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -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[] { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 2c5a2aae8..384b81c0a 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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 | undefined> = []; async isAvailable(): Promise { return true; } async createSession(config: AgentSessionConfig): Promise { + this.createdConfigs.push(config); return new TestAgentSession(config); } @@ -135,9 +138,11 @@ class TestAgentClient implements AgentClient { config?: Partial, _launchContext?: AgentLaunchContext, ): Promise { + 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 not injected into Pi", 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).toBeUndefined(); +}); + test("setAgentMode persists the selected mode across session reload", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 570ecbbab..d13ac0e72 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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>(); private readonly agentStreamCoalescer: AgentStreamCoalescer; private mcpBaseUrl: string | null; + private appendSystemPrompt: string; private onAgentAttention?: AgentAttentionCallback; private logger: Logger; private readonly rescueTimeouts: Required; @@ -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 = {}; 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 = { ...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 @@ -3437,6 +3455,26 @@ export class AgentManager { return normalized; } + private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig { + if (config.provider === "pi") { + const next = { ...config }; + delete next.daemonAppendSystemPrompt; + return next; + } + + const trimmed = this.appendSystemPrompt.trim(); + if (!trimmed) { + const next = { ...config }; + delete next.daemonAppendSystemPrompt; + return next; + } + + return { + ...config, + daemonAppendSystemPrompt: trimmed, + }; + } + private buildLaunchContext(agentId: string): AgentLaunchContext { return { agentId, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 768a1a059..cdeee51bf 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -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; diff --git a/packages/server/src/server/agent/orchestrator-instructions.ts b/packages/server/src/server/agent/orchestrator-instructions.ts deleted file mode 100644 index 659beb0a3..000000000 --- a/packages/server/src/server/agent/orchestrator-instructions.ts +++ /dev/null @@ -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 ` - -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. - -`; -} diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 8d88d7929..27bcabf6d 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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 = [ @@ -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 | 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", diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ffdb13c81..8bf5364d6 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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 = {}; 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 = { 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 = { 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 } : {}), }; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index d7b491399..9e28b4b73 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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 } : {}), diff --git a/packages/server/src/server/agent/system-prompt.ts b/packages/server/src/server/agent/system-prompt.ts new file mode 100644 index 000000000..2aafc3759 --- /dev/null +++ b/packages/server/src/server/agent/system-prompt.ts @@ -0,0 +1,10 @@ +export function composeSystemPromptParts( + ...parts: Array +): 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; +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 4f509e40a..95e0d4d00 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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; diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index f5b2fe09a..3a6f2ed28 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -256,6 +256,10 @@ function resolveAuthConfig( : undefined; } +function resolveAppendSystemPrompt(persisted: ReturnType): 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"), diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index fe44969ae..2ac5f65e3 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -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); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index a12b8571c..b7852eb05 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -186,6 +186,7 @@ function mergeMutableConfigIntoPersistedConfig(params: { injectIntoAgents: mutable.mcp.injectIntoAgents, }, autoArchiveAfterMerge: mutable.autoArchiveAfterMerge, + appendSystemPrompt: mutable.appendSystemPrompt, }, agents: providerOverrides && Object.keys(providerOverrides).length > 0 diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index a17d5129f..23756ceb2 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -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({ diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index dc5dfcec6..011721416 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -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(), diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 76207e2f4..574a1eb05 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -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(); diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 7f56eb511..6757654b4 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -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 From b7a856709280a90338956f7c507d69854ef7bc9a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 19 May 2026 23:53:57 +0800 Subject: [PATCH 3/5] Restore the previous workspace on app start (#1101) * Restore previous workspace on app start * Guard workspace startup hydration * Extract last workspace selection store --- .../src/app/host-runtime-bootstrap.test.ts | 14 ++- .../app/src/app/host-runtime-bootstrap.ts | 6 +- packages/app/src/app/index.test.tsx | 23 +++- packages/app/src/app/index.tsx | 10 +- .../stores/last-workspace-selection.test.ts | 66 ++++++++++++ .../src/stores/last-workspace-selection.ts | 102 ++++++++++++++++++ .../navigation-active-workspace-store.test.ts | 13 +++ .../navigation-active-workspace-store.ts | 71 ++++++++++-- 8 files changed, 280 insertions(+), 25 deletions(-) create mode 100644 packages/app/src/stores/last-workspace-selection.test.ts create mode 100644 packages/app/src/stores/last-workspace-selection.ts diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/app/host-runtime-bootstrap.test.ts index fba5b504e..cd0055412 100644 --- a/packages/app/src/app/host-runtime-bootstrap.test.ts +++ b/packages/app/src/app/host-runtime-bootstrap.test.ts @@ -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", () => { diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/app/host-runtime-bootstrap.ts index 45c821f6e..0af3f1d91 100644 --- a/packages/app/src/app/host-runtime-bootstrap.ts +++ b/packages/app/src/app/host-runtime-bootstrap.ts @@ -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; diff --git a/packages/app/src/app/index.test.tsx b/packages/app/src/app/index.test.tsx index 1b200928d..cd6540363 100644 --- a/packages/app/src/app/index.test.tsx +++ b/packages/app/src/app/index.test.tsx @@ -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 () => { diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 08ecb638c..d2fbbd53d 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -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, }); diff --git a/packages/app/src/stores/last-workspace-selection.test.ts b/packages/app/src/stores/last-workspace-selection.test.ts new file mode 100644 index 000000000..06f4dceec --- /dev/null +++ b/packages/app/src/stores/last-workspace-selection.test.ts @@ -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((resolve) => { + this.finishRead = resolve; + }); + private saved: string | null = null; + + read(): Promise { + return this.pendingRead; + } + + async write(value: string): Promise { + 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", + }); + }); +}); diff --git a/packages/app/src/stores/last-workspace-selection.ts b/packages/app/src/stores/last-workspace-selection.ts new file mode 100644 index 000000000..a9fe19b9e --- /dev/null +++ b/packages/app/src/stores/last-workspace-selection.ts @@ -0,0 +1,102 @@ +export interface ActiveWorkspaceSelection { + serverId: string; + workspaceId: string; +} + +export interface LastWorkspaceSelectionStorage { + read(): Promise; + write(value: string): Promise; +} + +function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null { + if (!input || typeof input !== "object" || Array.isArray(input)) { + return null; + } + const record = input as Record; + 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 | 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 { + 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); + }; + }, + }; +} diff --git a/packages/app/src/stores/navigation-active-workspace-store.test.ts b/packages/app/src/stores/navigation-active-workspace-store.test.ts index c5f4b287f..3fbb93f7b 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.test.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.test.ts @@ -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", () => { diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts index 930f13e14..698e1a148 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -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 { + 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(); From 0e1c590a3b80c3e4418379922654fc47192bb856 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 19 May 2026 23:54:52 +0800 Subject: [PATCH 4/5] Reduce workspace git refresh polling (#1102) * Reduce workspace git refresh polling * Keep workspace git snapshot reads passive * Update workspace git refresh tests --- .../workspace-git-service.primitive.test.ts | 137 ++++++++--- .../src/server/workspace-git-service.test.ts | 17 +- .../src/server/workspace-git-service.ts | 224 ++++++++++++------ 3 files changed, 273 insertions(+), 105 deletions(-) diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 12f7204e9..a330522bb 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -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(); 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>() .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 => { + 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(); 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); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index d3969a356..974bf5eb4 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -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>() .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); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 739ed1a32..3d00cab32 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -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 { + 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 { 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 { + 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, }; } From 4cf985c25450d88b21b7767e01ee13c1dc08aae7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 19 May 2026 23:56:07 +0800 Subject: [PATCH 5/5] Run Pi agents through the installed Pi CLI (#1097) * Run Pi through its RPC interface Replace the embedded Pi runtime packages with a process-backed runtime that starts pi --mode rpc, keeps the runtime port thin, and leaves provider behavior in the Paseo session and mapper modules. Add focused adapter and mapper tests plus real Pi E2E coverage for the installed binary path. * Keep Pi provider code in one module * Add Pi session imports * Enable Paseo MCP tools in Pi agents Route injected MCP servers through Pi's MCP adapter and make structured MCP results visible to model-only clients. Normalize Pi MCP proxy calls so Paseo timelines show the underlying tool names. * Fix Pi import symlink tests on Windows Create directory links explicitly in symlink cwd fixtures so Windows server tests exercise the intended path-equivalence behavior. * Use native realpath for import cwd matching Resolve import cwd matchers with native realpath so Windows symlink and junction sessions compare against the same path shape used by persisted provider data. * Fallback realpath for Windows import matching Use plain realpath when native realpath cannot resolve a cwd so symlink-equivalent persisted sessions still match on Windows runners. * Add daemon system prompt support for Pi * Fix Windows realpath import matching * Remove Pi RPC migration plan doc --- docs/providers.md | 10 +- package-lock.json | 1583 +---------------- .../cli/src/commands/agent/import.test.ts | 41 +- packages/cli/src/commands/agent/import.ts | 8 +- packages/server/package.json | 3 - .../src/server/agent/agent-manager.test.ts | 4 +- .../server/src/server/agent/agent-manager.ts | 26 +- .../src/server/agent/import-sessions.test.ts | 38 + .../src/server/agent/import-sessions.ts | 4 +- .../src/server/agent/mcp-server.test.ts | 4 + .../server/src/server/agent/mcp-server.ts | 124 +- .../server/agent/provider-registry.test.ts | 4 +- .../src/server/agent/provider-registry.ts | 4 +- .../server/agent/providers/acp-agent.test.ts | 2 +- .../agent/providers/pi-direct-agent.test.ts | 373 ---- .../server/agent/providers/pi-direct-agent.ts | 1565 ---------------- .../pi-session-recovery-policy.test.ts | 115 -- .../providers/pi-session-recovery-policy.ts | 91 - .../server/agent/providers/pi/agent.test.ts | 489 +++++ .../src/server/agent/providers/pi/agent.ts | 1074 +++++++++++ .../agent/providers/pi/cli-runtime.test.ts | 188 ++ .../server/agent/providers/pi/cli-runtime.ts | 282 +++ .../agent/providers/pi/history-mapper.test.ts | 127 ++ .../agent/providers/pi/history-mapper.ts | 169 ++ .../server/agent/providers/pi/rpc-types.ts | 172 ++ .../src/server/agent/providers/pi/runtime.ts | 106 ++ .../providers/pi/session-descriptor.test.ts | 297 ++++ .../agent/providers/pi/session-descriptor.ts | 372 ++++ .../agent/providers/pi/test-utils/fake-pi.ts | 149 ++ .../providers/pi/tool-call-mapper.test.ts | 76 + .../agent/providers/pi/tool-call-mapper.ts | 469 +++++ .../src/server/daemon-config-store.test.ts | 22 + .../src/server/daemon-e2e/pi.real.e2e.test.ts | 63 +- .../src/server/persisted-config.test.ts | 12 + .../server/src/shared/importable-providers.ts | 2 +- packages/server/src/utils/path.ts | 35 + 36 files changed, 4319 insertions(+), 3784 deletions(-) delete mode 100644 packages/server/src/server/agent/providers/pi-direct-agent.test.ts delete mode 100644 packages/server/src/server/agent/providers/pi-direct-agent.ts delete mode 100644 packages/server/src/server/agent/providers/pi-session-recovery-policy.test.ts delete mode 100644 packages/server/src/server/agent/providers/pi-session-recovery-policy.ts create mode 100644 packages/server/src/server/agent/providers/pi/agent.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/agent.ts create mode 100644 packages/server/src/server/agent/providers/pi/cli-runtime.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/cli-runtime.ts create mode 100644 packages/server/src/server/agent/providers/pi/history-mapper.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/history-mapper.ts create mode 100644 packages/server/src/server/agent/providers/pi/rpc-types.ts create mode 100644 packages/server/src/server/agent/providers/pi/runtime.ts create mode 100644 packages/server/src/server/agent/providers/pi/session-descriptor.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/session-descriptor.ts create mode 100644 packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts create mode 100644 packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts create mode 100644 packages/server/src/server/agent/providers/pi/tool-call-mapper.ts diff --git a/docs/providers.md b/docs/providers.md index 6e3c39150..d491d082b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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. diff --git a/package-lock.json b/package-lock.json index d165c1f36..b8cc87493 100644 --- a/package-lock.json +++ b/package-lock.json @@ -211,432 +211,6 @@ "win32" ] }, - "node_modules/@anthropic-ai/sdk": { - "version": "0.91.1", - "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", - "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", - "license": "MIT", - "dependencies": { - "json-schema-to-ts": "^3.1.1" - }, - "bin": { - "anthropic-ai-sdk": "bin/cli" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "zod": { - "optional": true - } - } - }, - "node_modules/@aws-crypto/crc32": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", - "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/sha256-browser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", - "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-js": "^5.2.0", - "@aws-crypto/supports-web-crypto": "^5.2.0", - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-locate-window": "^3.0.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/sha256-js": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", - "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/util": "^5.2.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@aws-crypto/supports-web-crypto": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", - "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-crypto/util": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", - "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@smithy/util-utf8": "^2.0.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1049.0.tgz", - "integrity": "sha512-YM8b2baoRY8ul47b4amQW2VlUthLmM8DnqdlGO20LJmmmRpjnT91SaQJai3OMehA6uE0Gig88VyDCT1vEACSww==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-node": "^3.972.43", - "@aws-sdk/eventstream-handler-node": "^3.972.16", - "@aws-sdk/middleware-eventstream": "^3.972.12", - "@aws-sdk/middleware-websocket": "^3.972.20", - "@aws-sdk/token-providers": "3.1049.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/core": { - "version": "3.974.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.12.tgz", - "integrity": "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.24", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.38.tgz", - "integrity": "sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.40", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.40.tgz", - "integrity": "sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.42.tgz", - "integrity": "sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-login": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.42.tgz", - "integrity": "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.43.tgz", - "integrity": "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-ini": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.38.tgz", - "integrity": "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.42.tgz", - "integrity": "sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/token-providers": "3.1049.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.42.tgz", - "integrity": "sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.16", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", - "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", - "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.20", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.20.tgz", - "integrity": "sha512-LM6P0i+Lu6pi25oNw2nqxjRxiEOtLgPB7xIvHfa+FxHTRLg8wcgqu3qg2COl4QaT7Es2yCxYdeRLVYazKAwL8g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.10.tgz", - "integrity": "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", - "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/token-providers": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1049.0.tgz", - "integrity": "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.14.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/util-locate-window": { - "version": "3.965.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", - "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", - "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", - "license": "Apache-2.0", - "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", - "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@babel/cli": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.28.6.tgz", @@ -3357,279 +2931,6 @@ "react": ">=16.8.0" } }, - "node_modules/@earendil-works/pi-agent-core": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.75.3.tgz", - "integrity": "sha512-azg09GSrckQa3ffbH09YEZC7DyHgmNSX+vmWEoEhQvp4icbzqbqLfIeMayMNEK/aGusm1SghZC4bPlDdagDALg==", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-ai": "^0.75.3", - "ignore": "^7.0.5", - "typebox": "^1.1.24", - "yaml": "^2.8.2" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-agent-core/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-ai": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.75.3.tgz", - "integrity": "sha512-UKccS+ADlkSVJ49a00346jUfXmUi6zzzB+pPWotsyA6SxhKr2ejjkGQksGyR1DyNVrsEP/WWlsOSTUUwVlzNaA==", - "license": "MIT", - "dependencies": { - "@anthropic-ai/sdk": "^0.91.1", - "@aws-sdk/client-bedrock-runtime": "^3.1030.0", - "@google/genai": "^1.40.0", - "@mistralai/mistralai": "^2.2.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "openai": "6.26.0", - "partial-json": "^0.1.7", - "typebox": "^1.1.24" - }, - "bin": { - "pi-ai": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/@earendil-works/pi-ai/node_modules/openai": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", - "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.75.3.tgz", - "integrity": "sha512-LIi5/CdUBfcLp3BAtpLx1BfnHDLmDOQVdzYfS1H9fjjCw2dcPr9voSI5ncrhvZdgyFSnfHck4BCbNcfZk+TEHQ==", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-agent-core": "^0.75.3", - "@earendil-works/pi-ai": "^0.75.3", - "@earendil-works/pi-tui": "^0.75.3", - "@silvia-odwyer/photon-node": "^0.3.4", - "chalk": "^5.5.0", - "cross-spawn": "^7.0.6", - "diff": "^8.0.2", - "glob": "^13.0.1", - "highlight.js": "^10.7.3", - "hosted-git-info": "^9.0.2", - "ignore": "^7.0.5", - "jiti": "^2.7.0", - "minimatch": "^10.2.3", - "proper-lockfile": "^4.1.2", - "typebox": "^1.1.24", - "undici": "^8.3.0", - "yaml": "^2.8.2" - }, - "bin": { - "pi": "dist/cli.js" - }, - "engines": { - "node": ">=22.19.0" - }, - "optionalDependencies": { - "@mariozechner/clipboard": "^0.3.6" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", - "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, - "node_modules/@earendil-works/pi-tui": { - "version": "0.75.3", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.75.3.tgz", - "integrity": "sha512-UbhtCsae+b3Y8/ZxtBPhiOrkD66gOHvJbfvLZwhBBsNtQuvUkZY5t9MQwmb8QcDYkFRnXHaq3FcEy1hjRSfj6w==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "marked": "^15.0.12" - }, - "engines": { - "node": ">=22.19.0" - }, - "optionalDependencies": { - "koffi": "^2.9.0" - } - }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -7142,30 +6443,6 @@ "resolved": "packages/website", "link": true }, - "node_modules/@google/genai": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", - "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, "node_modules/@gorhom/bottom-sheet": { "version": "5.2.14", "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz", @@ -8580,196 +7857,6 @@ "node": ">=18" } }, - "node_modules/@mariozechner/clipboard": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.6.tgz", - "integrity": "sha512-MXdtr+6+ntlIVHdrZYuZNQydu6o8yZswFJ2Ln81j2O/Y9B/LDHvEaIm95xWNPkjGTWriSOeLnQJRFs6dYb60bg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@mariozechner/clipboard-darwin-arm64": "0.3.6", - "@mariozechner/clipboard-darwin-universal": "0.3.6", - "@mariozechner/clipboard-darwin-x64": "0.3.6", - "@mariozechner/clipboard-linux-arm64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-arm64-musl": "0.3.6", - "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-gnu": "0.3.6", - "@mariozechner/clipboard-linux-x64-musl": "0.3.6", - "@mariozechner/clipboard-win32-arm64-msvc": "0.3.6", - "@mariozechner/clipboard-win32-x64-msvc": "0.3.6" - } - }, - "node_modules/@mariozechner/clipboard-darwin-arm64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.6.tgz", - "integrity": "sha512-HjaisYCAbHi/1+N1yDAQHc8ZXGffufIUT5NSOSVR3f3AuMDusxTtnbK8tZ7JFDkShua1oNGZoNwQHsc8MPtE0Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-universal": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.6.tgz", - "integrity": "sha512-8BWtPjOtJOJoykml3w0fx0zRrfWP31mXrJwfoA7xzNprkZw1uolCNfgmjDiVBseoKjp16EGITz7bN+61qn8dWA==", - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-darwin-x64": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.6.tgz", - "integrity": "sha512-p9syiZD1kU4I+1ya7f7g+zD1GiUvR8fdlRlNmgsZNWlyjtc8rlV2EjTLd/35x1LsdBq020GVvtzp0ZmPgBI09Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.6.tgz", - "integrity": "sha512-5JFf5rGofrm+V29HNF+wLthXphHdQpMbKDUYJ5tML6/Z5DLlLOV/9Ak4kDPtYyZ+Dzf+kAusE0VsFg4+tfP1IA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-arm64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.6.tgz", - "integrity": "sha512-JlVjxxw0GbGC0djXYWRIqyteO3J1KZ/QG3udlEFaOD5TLOM1FnmXXAPDQBqr+aBVr720ef9K00dirYnJ0LDCtw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.6.tgz", - "integrity": "sha512-4t8BUi5zZ+L77otFQVnVSlaTyAX4TVk9EqQm4syMrEQp96trFEHEwwNHcNEBGzYv5+K7mxay50TthYkz47OWzQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-x64-gnu": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.6.tgz", - "integrity": "sha512-trtPwcNLW37irwQCJLtCxLw757jjJZk3TSnY/MU9bhtWtA3K9b/eLW0e4RGhUXDoFRds9opNWWaUDuFLa8dm0w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-linux-x64-musl": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.6.tgz", - "integrity": "sha512-WfnzIvOCCWQiN0MmltCEo6cLceUDbYe+I7xyFZjaps5A+2Op/M2CY7Rey+C4ucQhrvmpoHmTSFgY9ODWk7snoA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.6.tgz", - "integrity": "sha512-+8+1aHYsBPUjmW3otmWlg+Hijt0iJvoBBs5e0mxFeUd4gDaKMB8Bn6x7c6KVtscg7E5j5NFXnwQqNSIAO4p8zQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mariozechner/clipboard-win32-x64-msvc": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.6.tgz", - "integrity": "sha512-S4xfPmERC8ZkiLHe3vekZCjdDwNEETCuvCgQK2kP6/TnvmUkq1y2Pk+DjM4t8uh9KMX9bH4zs5ePcKa8GTXmfg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@mistralai/mistralai": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", - "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", - "license": "Apache-2.0", - "dependencies": { - "ws": "^8.18.0", - "zod": "^3.25.0 || ^4.0.0", - "zod-to-json-schema": "^3.25.0" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", @@ -8795,18 +7882,6 @@ "license": "MIT", "optional": true }, - "node_modules/@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/nodable" - } - ], - "license": "MIT" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -10277,69 +9352,6 @@ "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", "license": "MIT" }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -11272,7 +10284,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.59.0", @@ -11285,7 +10298,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.59.0", @@ -11298,7 +10312,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.59.0", @@ -11311,7 +10326,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.59.0", @@ -11324,7 +10340,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.59.0", @@ -11337,7 +10354,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.59.0", @@ -11350,7 +10368,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.59.0", @@ -11363,7 +10382,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.59.0", @@ -11376,7 +10396,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.59.0", @@ -11389,7 +10410,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", @@ -11402,7 +10424,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.59.0", @@ -11415,7 +10438,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.59.0", @@ -11428,7 +10452,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.59.0", @@ -11441,7 +10466,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.59.0", @@ -11454,7 +10480,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.59.0", @@ -11467,7 +10494,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.59.0", @@ -11480,7 +10508,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.59.0", @@ -11493,7 +10522,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.59.0", @@ -11506,7 +10536,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.59.0", @@ -11519,7 +10550,8 @@ "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.59.0", @@ -11532,7 +10564,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.59.0", @@ -11545,7 +10578,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.59.0", @@ -11558,7 +10592,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", @@ -11571,7 +10606,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.59.0", @@ -11584,7 +10620,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -11747,12 +10784,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@silvia-odwyer/photon-node": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", - "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", - "license": "Apache-2.0" - }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -11789,126 +10820,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@smithy/core": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", - "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", - "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", - "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/signature-v4": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", - "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@speed-highlight/core": { "version": "1.2.15", "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.15.tgz", @@ -13593,12 +12504,6 @@ "@types/node": "*" } }, - "node_modules/@types/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", - "license": "MIT" - }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -16204,15 +15109,6 @@ "node": ">=0.6" } }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -16305,12 +15201,6 @@ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "license": "MIT" }, - "node_modules/bowser": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", - "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", - "license": "MIT" - }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -16446,6 +15336,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/buffer-from": { @@ -17784,15 +16675,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/data-urls": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", @@ -18821,6 +17703,7 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -19420,6 +18303,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -22934,43 +21818,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fast-xml-builder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", - "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "path-expression-matcher": "^1.5.0", - "xml-naming": "^0.1.0" - } - }, - "node_modules/fast-xml-parser": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", - "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "dependencies": { - "@nodable/entities": "^2.1.0", - "fast-xml-builder": "^1.1.7", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.2.3" - }, - "bin": { - "fxparser": "src/cli/cli.js" - } - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -23057,38 +21904,6 @@ } } }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/fetch-blob/node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/fetch-retry": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-4.1.1.tgz", @@ -23544,18 +22359,6 @@ "node": ">= 12.20" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -23717,74 +22520,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gaxios/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/gaxios/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/gaxios/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/gcp-metadata": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", - "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -23812,18 +22547,6 @@ "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -24135,53 +22858,6 @@ "integrity": "sha512-YSwLaGMOgSBx9roJlNLL12c+FRiw7VECphinc6mGucphc/ZxTHgdEz6gmJqH6NOzYEd/yr64hwjom5pZ+tJVpg==", "dev": true }, - "node_modules/google-auth-library": { - "version": "10.6.2", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", - "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.1.4", - "gcp-metadata": "8.1.2", - "google-logging-utils": "1.1.3", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-auth-library/node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/google-auth-library/node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -24600,15 +23276,6 @@ "hermes-estree": "0.29.1" } }, - "node_modules/highlight.js": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", - "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", - "license": "BSD-3-Clause", - "engines": { - "node": "*" - } - }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -26706,15 +25373,6 @@ "node": ">=6" } }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -26992,17 +25650,6 @@ "typescript": ">=5.0.4 <7" } }, - "node_modules/koffi": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.16.2.tgz", - "integrity": "sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "funding": { - "url": "https://liberapay.com/Koromix" - } - }, "node_modules/lan-network": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.1.7.tgz", @@ -27606,12 +26253,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -27830,18 +26471,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/marked": { - "version": "15.0.12", - "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", - "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", - "license": "MIT", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -30859,28 +29488,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", - "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-retry/node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -31046,12 +29653,6 @@ "node": ">= 0.8" } }, - "node_modules/partial-json": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", - "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", - "license": "MIT" - }, "node_modules/password-prompt": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/password-prompt/-/password-prompt-1.1.3.tgz", @@ -31102,21 +29703,6 @@ "node": ">=8" } }, - "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -31723,6 +30309,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -31734,6 +30321,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/property-information": { @@ -31746,30 +30334,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/protobufjs": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz", - "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.1", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.2", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -33395,6 +31959,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -35051,18 +33616,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strnum": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", - "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT" - }, "node_modules/structured-headers": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", @@ -36113,12 +34666,6 @@ "node": ">= 0.6" } }, - "node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", - "license": "MIT" - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -37657,21 +36204,6 @@ "node": ">=12" } }, - "node_modules/xml-naming": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", - "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/NaturalIntelligence" - } - ], - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/xml-reader": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/xml-reader/-/xml-reader-2.4.3.tgz", @@ -38268,9 +36800,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", diff --git a/packages/cli/src/commands/agent/import.test.ts b/packages/cli/src/commands/agent/import.test.ts index 28fef48d5..48a678de4 100644 --- a/packages/cli/src/commands/agent/import.test.ts +++ b/packages/cli/src/commands/agent/import.test.ts @@ -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"); + }); }); diff --git a/packages/cli/src/commands/agent/import.ts b/packages/cli/src/commands/agent/import.ts index 9bd7d1507..32f99623a 100644 --- a/packages/cli/src/commands/agent/import.ts +++ b/packages/cli/src/commands/agent/import.ts @@ -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(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("", "Provider session/thread ID to import") - .requiredOption("--provider ", "Agent provider: claude, codex, opencode, or acp") + .requiredOption("--provider ", `Agent provider: ${IMPORT_PROVIDER_HELP}`) .option("--cwd ", "Working directory for providers that require it") .option( "--label ", @@ -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; } diff --git a/packages/server/package.json b/packages/server/package.json index d3832ba9a..09137073f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 384b81c0a..b22cbffee 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -425,7 +425,7 @@ test("createAgent injects daemon append system prompt at runtime only", async () expect(record?.config).not.toHaveProperty("daemonAppendSystemPrompt"); }); -test("daemon append system prompt is not injected into Pi", async () => { +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); @@ -449,7 +449,7 @@ test("daemon append system prompt is not injected into Pi", async () => { systemPrompt: "Agent instructions.", }); - expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBeUndefined(); + expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions."); }); test("setAgentMode persists the selected mode across session reload", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index d13ac0e72..3c3a95d79 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1073,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); @@ -3456,23 +3457,16 @@ export class AgentManager { } private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig { - if (config.provider === "pi") { - const next = { ...config }; - delete next.daemonAppendSystemPrompt; - return next; - } + const daemonAppendSystemPrompt = this.appendSystemPrompt.trim(); + const next = { ...config }; + delete next.daemonAppendSystemPrompt; - const trimmed = this.appendSystemPrompt.trim(); - if (!trimmed) { - const next = { ...config }; - delete next.daemonAppendSystemPrompt; - return next; - } - - return { - ...config, - daemonAppendSystemPrompt: trimmed, - }; + return daemonAppendSystemPrompt + ? { + ...next, + daemonAppendSystemPrompt, + } + : next; } private buildLaunchContext(agentId: string): AgentLaunchContext { diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts index f46f26f95..91584ac61 100644 --- a/packages/server/src/server/agent/import-sessions.test.ts +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -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, + agentStorage: { + list: async () => [], + } satisfies Pick, + providerRegistry: { pi: { label: "Pi" } }, + }); + + expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["pi-handle"]); +}); + test("listImportableProviderSessions rejects invalid since values", async () => { await expect( listImportableProviderSessions({ diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index 9741b1418..746d42215 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -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; @@ -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; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index c1f59cc8f..1c1e18d1c 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -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 () => { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 577e9f495..4727c654d 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -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; + 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).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; 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", diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index c6f507b3e..9d8acc8ae 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -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, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 1af17a12d..6cc26ca85 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -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 = { }), opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings), pi: (logger, runtimeSettings) => - new PiDirectAgentClient({ + new PiRpcAgentClient({ logger, runtimeSettings, }), diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index b8f889c56..21b417172 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -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"; diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts b/packages/server/src/server/agent/providers/pi-direct-agent.test.ts deleted file mode 100644 index 92dc2b982..000000000 --- a/packages/server/src/server/agent/providers/pi-direct-agent.test.ts +++ /dev/null @@ -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, - options: { - compact?: () => Promise; - 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 = vi.fn(async () => undefined), -): PiDirectSessionRuntimeAdapter { - return { - session, - dispose, - }; -} - -function createPiModel(provider: string, id: string): Model { - 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; -} - -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 }); - } - }); -}); diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.ts b/packages/server/src/server/agent/providers/pi-direct-agent.ts deleted file mode 100644 index edaee537d..000000000 --- a/packages/server/src/server/agent/providers/pi-direct-agent.ts +++ /dev/null @@ -1,1565 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { homedir } from "node:os"; -import type { Logger } from "pino"; -import { - AuthStorage, - ModelRegistry, - SessionManager, - createAgentSessionFromServices, - createAgentSessionRuntime, - createAgentSessionServices, - getAgentDir, - type AgentSession as PiAgentSession, - type AgentSessionEvent, - type AgentSessionServices, - type BashToolInput, - type CreateAgentSessionRuntimeFactory, - type EditToolInput, - type FindToolInput, - type GrepToolInput, - type LsToolInput, - type ReadToolInput, - type ResourceLoader, - type ResolvedCommand, - type Skill, - type WriteToolInput, -} from "@earendil-works/pi-coding-agent"; -import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai"; -import { z } from "zod"; - -import { - getAgentStreamEventTurnId, - type AgentCapabilityFlags, - type AgentClient, - type AgentLaunchContext, - type AgentMetadata, - type AgentMode, - type AgentModelDefinition, - type AgentPermissionRequest, - type AgentPermissionResponse, - type AgentPersistenceHandle, - type AgentPromptInput, - type AgentRunOptions, - type AgentRunResult, - type AgentRuntimeInfo, - type AgentSession, - type AgentSessionConfig, - type AgentSlashCommand, - type AgentStreamEvent, - type AgentTimelineItem, - type AgentUsage, - type ListModesOptions, - type ListModelsOptions, - type ToolCallDetail, -} from "../agent-sdk-types.js"; -import type { ProviderRuntimeSettings } from "../provider-launch-config.js"; -import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; -import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; -import { - formatDiagnosticStatus, - formatProviderDiagnostic, - formatProviderDiagnosticError, - resolveBinaryVersion, - toDiagnosticErrorMessage, -} from "./diagnostic-utils.js"; -import { applyPiSessionRecoveryPolicy } from "./pi-session-recovery-policy.js"; - -const PI_PROVIDER = "pi"; -const DEFAULT_PI_THINKING_LEVEL: ThinkingLevel = "medium"; -const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi"; - -const PI_CAPABILITIES: AgentCapabilityFlags = { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: false, - supportsReasoningStream: true, - supportsToolInvocations: true, -}; - -interface PiDirectAgentClientOptions { - logger: Logger; - runtimeSettings?: ProviderRuntimeSettings; -} - -interface PiPromptPayload { - text: string; - images?: ImageContent[]; -} - -interface ToolCallOutputSummary { - output?: string; - exitCode?: number | null; -} - -interface PiModelReference { - provider?: string; - id: string; -} - -interface PiPersistenceMetadata { - cwd?: string; -} - -interface StartTurnResult { - turnId: string; -} - -interface PiDirectSessionResult { - runtime: PiDirectSessionRuntimeAdapter; - modelRegistry: ModelRegistry; -} - -export type PiDirectSessionAdapter = Pick< - PiAgentSession, - | "abort" - | "agent" - | "compact" - | "dispose" - | "extensionRunner" - | "getSessionStats" - | "messages" - | "model" - | "prompt" - | "promptTemplates" - | "resourceLoader" - | "sessionId" - | "sessionManager" - | "setModel" - | "setThinkingLevel" - | "subscribe" - | "thinkingLevel" ->; - -export interface PiDirectSessionRuntimeAdapter { - readonly session: PiDirectSessionAdapter; - dispose(): Promise; -} - -type PiDirectModelRegistry = Pick; - -interface PiToolResultObject { - output?: string; - stdout?: string; - text?: string; - content?: PiToolResultContent[]; - exitCode?: number; - code?: number; - details?: PiToolResultDetails; -} - -interface PiToolResultDetails { - diff?: string; -} - -interface PiToolResultTextContent { - type: "text"; - text: string; -} - -interface PiToolResultUnknownContent { - type: string; -} - -type PiToolResultContent = PiToolResultTextContent | PiToolResultUnknownContent; -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; -} - -type PiTrackedToolCall = - | PiBashToolCall - | PiReadToolCall - | PiEditToolCall - | PiWriteToolCall - | PiFindToolCall - | PiGrepToolCall - | PiLsToolCall - | PiUnknownToolCall; - -const PI_THINKING_OPTIONS: ReadonlyArray<{ - id: ThinkingLevel; - label: string; - description: string; - isDefault?: boolean; -}> = [ - { id: "off", label: "Off", description: "No extra reasoning" }, - { id: "minimal", label: "Minimal", description: "Light reasoning" }, - { id: "low", label: "Low", description: "Faster reasoning" }, - { id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true }, - { id: "high", label: "High", description: "Deeper reasoning" }, - { id: "xhigh", label: "XHigh", description: "Maximum reasoning" }, -] as const; - -const PiPromptTextBlockSchema = z.object({ - type: z.literal("text"), - text: z.string(), -}); - -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()]); - -const PiPersistenceMetadataSchema = z - .object({ - cwd: z.string().optional(), - }) - .passthrough(); - -const BashToolInputSchema: z.ZodType = z.object({ - command: z.string(), - timeout: z.number().optional(), -}); - -const ReadToolInputSchema: z.ZodType = z.object({ - path: z.string(), - offset: z.number().optional(), - limit: z.number().optional(), -}); - -const EditToolInputSchema: z.ZodType = 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 = z.object({ - path: z.string(), - content: z.string(), -}); - -const FindToolInputSchema: z.ZodType = z.object({ - pattern: z.string(), - path: z.string().optional(), - limit: z.number().optional(), -}); - -const GrepToolInputSchema: z.ZodType = 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 = z.object({ - path: z.string().optional(), - limit: z.number().optional(), -}); - -function normalizePiModelLabel(label: string): string { - return label.trim().replace(/[_\s]+/g, " "); -} - -export function transformPiModels(models: AgentModelDefinition[]): AgentModelDefinition[] { - return models.map((model) => { - if (!model.label.includes("/")) { - return model; - } - - const segments = model.label.split("/").filter((segment) => segment.length > 0); - const rawLabel = segments.at(-1); - if (!rawLabel) { - return model; - } - - return { - ...model, - label: normalizePiModelLabel(rawLabel), - description: model.description ?? model.label, - }; - }); -} - -function isPiThinkingLevel(value: string | null | undefined): value is ThinkingLevel { - return ( - value === "off" || - value === "minimal" || - value === "low" || - value === "medium" || - value === "high" || - value === "xhigh" - ); -} - -function normalizePiThinkingOption(value: string | null | undefined): ThinkingLevel | null { - if (!value) { - return null; - } - return isPiThinkingLevel(value) ? value : null; -} - -function toAgentUsage( - stats: ReturnType, -): AgentUsage | undefined { - const inputTokens = stats.tokens.input; - const cachedInputTokens = stats.tokens.cacheRead; - const outputTokens = stats.tokens.output; - const totalCostUsd = stats.cost; - - if (inputTokens === 0 && cachedInputTokens === 0 && outputTokens === 0 && totalCostUsd === 0) { - return undefined; - } - - return { - inputTokens, - cachedInputTokens, - outputTokens, - totalCostUsd, - }; -} - -function convertPromptInput(prompt: AgentPromptInput): PiPromptPayload { - if (typeof prompt === "string") { - return { text: prompt }; - } - - const textParts: string[] = []; - const images: ImageContent[] = []; - - for (const block of prompt) { - if (block.type === "text") { - textParts.push(block.text); - continue; - } - - if (block.type === "image") { - images.push({ - type: "image", - data: block.data, - mimeType: block.mimeType, - }); - continue; - } - - textParts.push(renderPromptAttachmentAsText(block)); - } - - const payload: PiPromptPayload = { - text: textParts.join("\n\n"), - }; - if (images.length > 0) { - payload.images = images; - } - return payload; -} - -function parseToolResult(rawResult: unknown): PiToolResult { - const parsed = PiToolResultSchema.safeParse(rawResult); - if (parsed.success) { - return parsed.data; - } - return null; -} - -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); - } - } - - if (textParts.length === 0) { - return undefined; - } - return textParts.join("\n"); -} - -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 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 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 { - const query = args.path ?? "ls"; - return { - type: "search", - query, - content: typeof result === "string" ? result : undefined, - }; -} - -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 parseModelReference(modelId: string | null): PiModelReference | null { - if (!modelId) { - return null; - } - if (modelId.includes("/")) { - const [provider, ...rest] = modelId.split("/"); - const id = rest.join("/"); - if (provider && id) { - return { provider, id }; - } - } - if (modelId.includes(":")) { - const [provider, ...rest] = modelId.split(":"); - const id = rest.join(":"); - if (provider && id) { - return { provider, id }; - } - } - return { id: modelId }; -} - -function mapResolvedCommand(command: ResolvedCommand): AgentSlashCommand { - return { - name: command.invocationName, - description: command.description ?? "Extension command", - argumentHint: "", - }; -} - -function mapSkillCommand(skill: Skill): AgentSlashCommand { - return { - name: `skill:${skill.name}`, - description: skill.description || "Skill", - argumentHint: "", - }; -} - -function buildSlashCommands(session: PiDirectSessionAdapter): AgentSlashCommand[] { - const commands: AgentSlashCommand[] = []; - const extensionCommands = session.extensionRunner?.getRegisteredCommands() ?? []; - - for (const command of extensionCommands) { - commands.push(mapResolvedCommand(command)); - } - - for (const template of session.promptTemplates) { - commands.push({ - name: template.name, - description: template.description ?? "Prompt template", - argumentHint: "", - }); - } - - const resourceLoader: ResourceLoader = session.resourceLoader; - const skills = resourceLoader.getSkills().skills; - for (const skill of skills) { - commands.push(mapSkillCommand(skill)); - } - - return commands; -} - -function applySystemPrompt(session: PiAgentSession, systemPrompt: string | undefined): void { - const trimmed = systemPrompt?.trim(); - if (!trimmed) { - return; - } - - // Pi does not expose a public setter for composing an additional system prompt, - // so this escape hatch is isolated to one typed boundary. - const sessionObject = session as object; - const baseSystemPrompt = Reflect.get(sessionObject, "_baseSystemPrompt"); - const currentBase = - typeof baseSystemPrompt === "string" ? baseSystemPrompt : session.agent.state.systemPrompt; - const combinedPrompt = currentBase ? `${currentBase}\n\n${trimmed}` : trimmed; - Reflect.set(sessionObject, "_baseSystemPrompt", combinedPrompt); - session.agent.state.systemPrompt = combinedPrompt; -} - -function isTextContentBlock(block: unknown): block is TextContent { - return PiPromptTextBlockSchema.safeParse(block).success; -} - -function getUserMessageText(content: string | (TextContent | ImageContent)[]): 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"); -} - -function parsePersistenceMetadata(metadata: AgentMetadata | undefined): PiPersistenceMetadata { - const parsed = PiPersistenceMetadataSchema.safeParse(metadata); - if (parsed.success) { - return parsed.data; - } - return {}; -} - -function isPiRequestAbortError(error: unknown): boolean { - if (error instanceof Error && error.name === "AbortError") { - return true; - } - - return /\brequest was aborted\b/i.test(toDiagnosticErrorMessage(error)); -} - -function resolveThinkingOptionId( - cachedThinkingOptionId: string | null, - sessionThinkingLevel: ThinkingLevel, -): ThinkingLevel | null { - const currentThinking = cachedThinkingOptionId ?? sessionThinkingLevel; - return normalizePiThinkingOption(currentThinking); -} - -function mapThinkingOption(option: (typeof PI_THINKING_OPTIONS)[number]) { - const mappedOption = { - id: option.id, - label: option.label, - description: option.description, - }; - if (option.isDefault) { - return { - ...mappedOption, - isDefault: true, - }; - } - return mappedOption; -} - -function findModelInRegistry( - registry: PiDirectModelRegistry, - parsedReference: PiModelReference, -): Model | undefined { - if (parsedReference.provider) { - return ( - registry.find(parsedReference.provider, parsedReference.id) ?? - createProviderModelFallback(registry, { - provider: parsedReference.provider, - id: parsedReference.id, - }) - ); - } - - return registry.getAll().find((entry) => { - if (entry.id === parsedReference.id) { - return true; - } - return `${entry.provider}/${entry.id}` === parsedReference.id; - }); -} - -function createProviderModelFallback( - registry: PiDirectModelRegistry, - parsedReference: { provider: string; id: string }, -): Model | undefined { - const providerDefault = registry - .getAll() - .find((model) => model.provider === parsedReference.provider); - if (!providerDefault) { - return undefined; - } - - return { - id: parsedReference.id, - name: parsedReference.id, - api: providerDefault.api, - provider: parsedReference.provider, - baseUrl: providerDefault.baseUrl, - reasoning: false, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 128000, - maxTokens: 16384, - compat: providerDefault.compat, - }; -} - -export class PiDirectAgentSession implements AgentSession { - readonly provider = PI_PROVIDER; - readonly capabilities = PI_CAPABILITIES; - - private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); - private readonly activeToolCalls = new Map(); - private activeTurnId: string | null = null; - private lastKnownThinkingOptionId: string | null; - private latestUsage: AgentUsage | undefined; - - constructor( - private readonly runtime: PiDirectSessionRuntimeAdapter, - private readonly modelRegistry: PiDirectModelRegistry, - private readonly config: AgentSessionConfig, - ) { - const session = this.session; - this.lastKnownThinkingOptionId = - normalizePiThinkingOption(config.thinkingOptionId) ?? session.thinkingLevel ?? null; - - session.subscribe((event) => { - this.handleSessionEvent(event); - }); - } - - private get session(): PiDirectSessionAdapter { - return this.runtime.session; - } - - get id(): string | null { - return this.session.sessionId; - } - - private emit(event: AgentStreamEvent): void { - for (const subscriber of this.subscribers) { - subscriber(event); - } - } - - private currentTurnIdForEvent(): string | undefined { - return this.activeTurnId ?? undefined; - } - - private emitToolCallEvent( - toolCallId: string, - toolCall: PiTrackedToolCall, - status: "running" | "completed" | "failed", - result: PiToolResult, - error: unknown, - ): void { - const turnId = this.currentTurnIdForEvent(); - const detail = mapToolDetail(toolCall, result); - const baseItem = { - type: "tool_call" as const, - callId: toolCallId, - name: toolCall.toolName, - detail, - }; - const item = - status === "failed" ? { ...baseItem, status, error } : { ...baseItem, status, error: null }; - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item, - }); - } - - private handleMessageUpdate( - event: Extract, - turnId: string | undefined, - ): void { - if (event.message.role !== "assistant") { - return; - } - if (event.assistantMessageEvent.type === "text_delta") { - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item: { - type: "assistant_message", - text: event.assistantMessageEvent.delta ?? "", - }, - }); - return; - } - if (event.assistantMessageEvent.type === "thinking_delta") { - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item: { - type: "reasoning", - text: event.assistantMessageEvent.delta ?? "", - }, - }); - } - } - - private handleSessionEvent(event: AgentSessionEvent): void { - const turnId = this.currentTurnIdForEvent(); - - switch (event.type) { - case "agent_start": - this.emit({ - type: "thread_started", - provider: PI_PROVIDER, - sessionId: this.session.sessionId, - }); - return; - case "turn_start": - this.emit({ - type: "turn_started", - provider: PI_PROVIDER, - turnId, - }); - return; - case "message_update": - this.handleMessageUpdate(event, turnId); - return; - case "tool_execution_start": { - const toolCall = parseToolArgs(event.toolName, event.args); - this.activeToolCalls.set(event.toolCallId, toolCall); - this.emitToolCallEvent(event.toolCallId, toolCall, "running", null, null); - return; - } - case "tool_execution_update": { - const toolCall = this.activeToolCalls.get(event.toolCallId); - if (!toolCall) { - return; - } - - const partialResult = parseToolResult(event.partialResult); - this.emitToolCallEvent(event.toolCallId, toolCall, "running", partialResult, null); - return; - } - case "tool_execution_end": { - const toolCall = - this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); - this.activeToolCalls.delete(event.toolCallId); - - const result = parseToolResult(event.result); - const error = event.isError ? event.result : null; - const status = event.isError ? "failed" : "completed"; - this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error); - return; - } - case "turn_end": - return; - case "compaction_start": - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item: { - type: "compaction", - status: "loading", - trigger: event.reason === "manual" ? "manual" : "auto", - }, - }); - return; - case "compaction_end": - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item: { - type: "compaction", - status: "completed", - }, - }); - return; - case "agent_end": { - this.latestUsage = toAgentUsage(this.session.getSessionStats()); - const currentTurnId = turnId; - this.activeTurnId = null; - if (this.session.agent.state.errorMessage) { - this.emit({ - type: "turn_failed", - provider: PI_PROVIDER, - turnId: currentTurnId, - error: this.session.agent.state.errorMessage, - }); - return; - } - this.emit({ - type: "turn_completed", - provider: PI_PROVIDER, - turnId: currentTurnId, - usage: this.latestUsage, - }); - return; - } - default: - return; - } - } - - async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { - const timeline: AgentTimelineItem[] = []; - let finalText = ""; - let usage: AgentUsage | undefined; - let turnId: string | null = null; - const bufferedEvents: AgentStreamEvent[] = []; - let settled = false; - let resolveCompletion!: () => void; - let rejectCompletion!: (error: Error) => void; - - function processEvent(event: AgentStreamEvent): void { - if (settled) { - return; - } - - const eventTurnId = getAgentStreamEventTurnId(event); - if (turnId && eventTurnId && eventTurnId !== turnId) { - return; - } - if (event.type === "timeline") { - timeline.push(event.item); - if (event.item.type === "assistant_message") { - finalText += event.item.text; - } - return; - } - if (event.type === "turn_completed") { - usage = event.usage; - settled = true; - resolveCompletion(); - return; - } - if (event.type === "turn_failed") { - settled = true; - rejectCompletion(new Error(event.error)); - } - } - - const completion = new Promise((resolve, reject) => { - resolveCompletion = resolve; - rejectCompletion = reject; - }); - const unsubscribe = this.subscribe((event) => { - if (!turnId) { - bufferedEvents.push(event); - return; - } - processEvent(event); - }); - - try { - const result = await this.startTurn(prompt, options); - turnId = result.turnId; - for (const event of bufferedEvents) { - processEvent(event); - } - if (!settled) { - await completion; - } - } finally { - unsubscribe(); - } - - return { - sessionId: this.session.sessionId, - finalText, - usage, - timeline, - }; - } - - async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise { - if (this.activeTurnId) { - throw new Error("A Pi turn is already active"); - } - - const payload = convertPromptInput(prompt); - const turnId = randomUUID(); - this.activeTurnId = turnId; - - try { - await applyPiSessionRecoveryPolicy(this.session); - } catch (error) { - this.activeTurnId = null; - this.emit({ - type: "turn_failed", - provider: PI_PROVIDER, - turnId, - error: toDiagnosticErrorMessage(error), - }); - return { turnId }; - } - - void this.session - .prompt(payload.text, payload.images ? { images: payload.images } : undefined) - .catch((error) => { - const failedTurnId = this.activeTurnId ?? turnId; - this.activeTurnId = null; - if (isPiRequestAbortError(error)) { - this.emit({ - type: "turn_canceled", - provider: PI_PROVIDER, - turnId: failedTurnId, - reason: toDiagnosticErrorMessage(error), - }); - return; - } - this.emit({ - type: "turn_failed", - provider: PI_PROVIDER, - turnId: failedTurnId, - error: toDiagnosticErrorMessage(error), - }); - }); - - return { turnId }; - } - - subscribe(callback: (event: AgentStreamEvent) => void): () => void { - this.subscribers.add(callback); - return () => { - this.subscribers.delete(callback); - }; - } - - async *streamHistory(): AsyncGenerator { - const pendingToolCalls = new Map(); - let userIndex = 0; - - for (const message of this.session.messages) { - if (message.role === "user") { - const text = getUserMessageText(message.content); - if (text) { - yield { - type: "timeline", - provider: PI_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: PI_PROVIDER, - item: { type: "assistant_message", text: content.text }, - }; - continue; - } - - if (content.type === "thinking" && content.thinking) { - yield { - type: "timeline", - provider: PI_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: PI_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); - - if (message.isError) { - const errorText = extractTextFromToolResult(result) ?? "Tool call failed"; - yield { - type: "timeline", - provider: PI_PROVIDER, - item: { - type: "tool_call", - callId: message.toolCallId, - name: tracked.toolName, - status: "failed", - detail, - error: errorText, - }, - }; - } else { - yield { - type: "timeline", - provider: PI_PROVIDER, - item: { - type: "tool_call", - callId: message.toolCallId, - name: tracked.toolName, - status: "completed", - detail, - error: null, - }, - }; - } - continue; - } - - if (message.role === "bashExecution") { - const callId = `pi-bash-${message.timestamp}`; - const exitCode = message.exitCode ?? null; - const detail: ToolCallDetail = { - type: "shell", - command: message.command, - output: message.output, - exitCode, - }; - yield { - type: "timeline", - provider: PI_PROVIDER, - item: { - type: "tool_call", - callId, - name: "bash", - status: message.cancelled ? "canceled" : "completed", - detail, - error: null, - }, - }; - } - } - } - - async getRuntimeInfo(): Promise { - const thinkingOptionId = resolveThinkingOptionId( - this.lastKnownThinkingOptionId, - this.session.thinkingLevel, - ); - - return { - provider: PI_PROVIDER, - sessionId: this.session.sessionId, - model: this.session.model ? `${this.session.model.provider}/${this.session.model.id}` : null, - thinkingOptionId, - modeId: null, - }; - } - - async getAvailableModes(): Promise { - return []; - } - - async getCurrentMode(): Promise { - return null; - } - - async setMode(modeId: string): Promise { - void modeId; - throw new Error("Pi does not expose selectable modes"); - } - - getPendingPermissions(): AgentPermissionRequest[] { - return []; - } - - async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { - void requestId; - void response; - } - - describePersistence(): AgentPersistenceHandle | null { - return { - provider: PI_PROVIDER, - sessionId: this.session.sessionId, - nativeHandle: this.session.sessionManager.getSessionFile(), - metadata: { - cwd: this.session.sessionManager.getCwd(), - }, - }; - } - - async interrupt(): Promise { - await this.session.abort(); - } - - async close(): Promise { - await this.runtime.dispose(); - } - - async listCommands(): Promise { - return buildSlashCommands(this.session); - } - - async setModel(modelId: string | null): Promise { - const parsedReference = parseModelReference(modelId); - if (!parsedReference) { - return; - } - - const model = findModelInRegistry(this.modelRegistry, parsedReference); - if (!model) { - throw new Error(`Unknown Pi model: ${modelId}`); - } - - await this.session.setModel(model); - this.config.model = `${model.provider}/${model.id}`; - } - - async setThinkingOption(thinkingOptionId: string | null): Promise { - const thinkingLevel = normalizePiThinkingOption(thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL; - this.session.setThinkingLevel(thinkingLevel); - this.lastKnownThinkingOptionId = thinkingLevel; - this.config.thinkingOptionId = thinkingLevel; - } -} - -export class PiDirectAgentClient implements AgentClient { - readonly provider = PI_PROVIDER; - readonly capabilities = PI_CAPABILITIES; - - private readonly logger: Logger; - private readonly runtimeSettings?: ProviderRuntimeSettings; - private modelRegistry: ModelRegistry | null = null; - - constructor(options: PiDirectAgentClientOptions) { - this.logger = options.logger; - this.runtimeSettings = options.runtimeSettings; - } - - private async getSessionServices(cwd: string, agentDir?: string): Promise { - return createAgentSessionServices({ - cwd, - ...(agentDir ? { agentDir } : {}), - ...(this.modelRegistry ? { modelRegistry: this.modelRegistry } : {}), - }); - } - - private resolveConfiguredModel( - registry: PiDirectModelRegistry, - modelId: string | null | undefined, - ): Model | undefined { - const parsedReference = parseModelReference(modelId ?? null); - if (!parsedReference) { - return undefined; - } - - return findModelInRegistry(registry, parsedReference); - } - - private async createSdkRuntime( - config: AgentSessionConfig, - sessionManager: SessionManager, - options: { defaultThinkingLevel?: ThinkingLevel } = {}, - ): Promise { - const createRuntime: CreateAgentSessionRuntimeFactory = async ({ - cwd, - agentDir, - sessionManager: runtimeSessionManager, - sessionStartEvent, - }) => { - const thinkingLevel = - normalizePiThinkingOption(config.thinkingOptionId) ?? options.defaultThinkingLevel; - const services = await this.getSessionServices(cwd, agentDir); - const model = this.resolveConfiguredModel(services.modelRegistry, config.model); - - return { - ...(await createAgentSessionFromServices({ - services, - sessionManager: runtimeSessionManager, - sessionStartEvent, - ...(thinkingLevel ? { thinkingLevel } : {}), - ...(model ? { model } : {}), - })), - services, - diagnostics: services.diagnostics, - }; - }; - - const runtime = await createAgentSessionRuntime(createRuntime, { - cwd: sessionManager.getCwd(), - agentDir: getAgentDir(), - sessionManager, - }); - await runtime.session.bindExtensions({}); - applySystemPrompt(runtime.session, config.systemPrompt); - return { runtime, modelRegistry: runtime.services.modelRegistry }; - } - - async createSession( - config: AgentSessionConfig, - _launchContext?: AgentLaunchContext, - ): Promise { - const { runtime, modelRegistry } = await this.createSdkRuntime( - config, - SessionManager.create(config.cwd), - { - defaultThinkingLevel: DEFAULT_PI_THINKING_LEVEL, - }, - ); - return new PiDirectAgentSession(runtime, modelRegistry, config); - } - - async resumeSession( - handle: AgentPersistenceHandle, - overrides?: Partial, - _launchContext?: AgentLaunchContext, - ): Promise { - const sessionFile = handle.nativeHandle; - if (!sessionFile) { - throw new Error("Pi resume requires a native session file handle"); - } - - const initialManager = SessionManager.open(sessionFile); - const persistenceMetadata = parsePersistenceMetadata(handle.metadata); - const cwd = overrides?.cwd ?? persistenceMetadata.cwd ?? initialManager.getCwd(); - const resumedManager = SessionManager.open(sessionFile, undefined, cwd); - const mergedConfig: AgentSessionConfig = { - provider: PI_PROVIDER, - cwd, - model: overrides?.model, - thinkingOptionId: overrides?.thinkingOptionId, - systemPrompt: overrides?.systemPrompt, - featureValues: overrides?.featureValues, - title: overrides?.title, - approvalPolicy: overrides?.approvalPolicy, - sandboxMode: overrides?.sandboxMode, - networkAccess: overrides?.networkAccess, - webSearch: overrides?.webSearch, - extra: overrides?.extra, - mcpServers: overrides?.mcpServers, - internal: overrides?.internal, - modeId: overrides?.modeId, - }; - - const { runtime, modelRegistry } = await this.createSdkRuntime(mergedConfig, resumedManager); - return new PiDirectAgentSession(runtime, modelRegistry, mergedConfig); - } - - async listModels(options: ListModelsOptions): Promise { - const services = await this.getSessionServices(options.cwd); - const models = services.modelRegistry.getAvailable().map((model) => ({ - provider: PI_PROVIDER, - id: `${model.provider}/${model.id}`, - label: `${model.provider}/${model.name}`, - description: `${model.provider}/${model.id}`, - metadata: { - provider: model.provider, - modelId: model.id, - } satisfies AgentMetadata, - thinkingOptions: model.reasoning ? PI_THINKING_OPTIONS.map(mapThinkingOption) : undefined, - defaultThinkingOptionId: model.reasoning ? DEFAULT_PI_THINKING_LEVEL : undefined, - })); - - return transformPiModels(models); - } - - async listModes(_options: ListModesOptions): Promise { - return []; - } - - async isAvailable(): Promise { - const command = this.runtimeSettings?.command; - if (command?.mode === "replace" && command.argv[0]) { - if (!existsSync(command.argv[0])) { - return false; - } - } else if (!(await isCommandAvailable(PI_BINARY_COMMAND))) { - return false; - } - - const registry = ModelRegistry.create(AuthStorage.create()); - return registry.getAvailable().length > 0; - } - - async getDiagnostic(): Promise<{ diagnostic: string }> { - try { - const available = await this.isAvailable(); - const binaryOverride = this.runtimeSettings?.command; - const binary = - binaryOverride?.mode === "replace" && binaryOverride.argv[0] - ? binaryOverride.argv[0] - : await findExecutable(PI_BINARY_COMMAND); - const version = binary ? await resolveBinaryVersion(binary) : "unknown"; - const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); - let modelsValue = "Not checked"; - let status = formatDiagnosticStatus(available); - const registry = ModelRegistry.create(AuthStorage.create()); - const configuredProviders = Array.from( - new Set(registry.getAvailable().map((model) => model.provider)), - ).sort(); - - if (available) { - try { - const models = await this.listModels({ cwd: homedir(), force: false }); - modelsValue = String(models.length); - } catch (error) { - modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; - status = formatDiagnosticStatus(available, { - source: "model fetch", - cause: error, - }); - } - } - - return { - diagnostic: formatProviderDiagnostic("Pi", [ - { label: "Binary", value: binary ?? "not found" }, - { label: "Version", value: version }, - { - label: "Configured providers", - value: configuredProviders.length > 0 ? configuredProviders.join(", ") : "none", - }, - { - label: "Auth config (~/.pi/agent/auth.json)", - value: existsSync(authConfigPath) ? "found" : "not found", - }, - { label: "Models", value: modelsValue }, - { label: "Status", value: status }, - ]), - }; - } catch (error) { - this.logger.debug({ err: error }, "Pi diagnostic lookup failed"); - return { - diagnostic: formatProviderDiagnosticError("Pi", error), - }; - } - } -} diff --git a/packages/server/src/server/agent/providers/pi-session-recovery-policy.test.ts b/packages/server/src/server/agent/providers/pi-session-recovery-policy.test.ts deleted file mode 100644 index 302b7f075..000000000 --- a/packages/server/src/server/agent/providers/pi-session-recovery-policy.test.ts +++ /dev/null @@ -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; - } = {}, -): 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"); - }); -}); diff --git a/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts b/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts deleted file mode 100644 index 140eb6b09..000000000 --- a/packages/server/src/server/agent/providers/pi-session-recovery-policy.ts +++ /dev/null @@ -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; -} - -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; -} - -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 { - 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 { - 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)); -} diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts new file mode 100644 index 000000000..30a2d6d55 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -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 { + 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 => + event.type === "timeline", + ) + .map((event) => event.item); + } + + nextTurnCompletion(): Promise> { + return this.nextEvent( + (event): event is Extract => + event.type === "turn_completed", + ); + } + + nextTurnFailure(): Promise> { + return this.nextEvent( + (event): event is Extract => + event.type === "turn_failed", + ); + } + + private nextEvent( + predicate: (event: AgentStreamEvent) => event is T, + ): Promise { + 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; + }; + 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", + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts new file mode 100644 index 000000000..3c4607c99 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -0,0 +1,1074 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import type { Logger } from "pino"; + +import { + type AgentCapabilityFlags, + type AgentClient, + type AgentLaunchContext, + type AgentMetadata, + type AgentMode, + type AgentModelDefinition, + type McpServerConfig, + type AgentPermissionRequest, + type AgentPermissionResponse, + type AgentPersistenceHandle, + type AgentPromptInput, + type AgentRunOptions, + type AgentRunResult, + type AgentRuntimeInfo, + type AgentSession, + type AgentSessionConfig, + type AgentSlashCommand, + type AgentStreamEvent, + type AgentUsage, + type ListPersistedAgentsOptions, + type ListModesOptions, + type ListModelsOptions, + type PersistedAgentDescriptor, +} from "../../agent-sdk-types.js"; +import { runProviderTurn } from "../provider-runner.js"; +import type { ProviderRuntimeSettings } from "../../provider-launch-config.js"; +import { renderPromptAttachmentAsText } from "../../prompt-attachments.js"; +import { composeSystemPromptParts } from "../../system-prompt.js"; +import { findExecutable } from "../../../../utils/executable.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "../diagnostic-utils.js"; +import { streamPiHistory } from "./history-mapper.js"; +import { PiCliRuntime } from "./cli-runtime.js"; +import { listPiPersistedAgents } from "./session-descriptor.js"; +import type { PiRuntime, PiRuntimeSession } from "./runtime.js"; +import type { + PiAgentSessionEvent, + PiAgentMessage, + PiImageContent, + PiModel, + PiRpcSlashCommand, + PiRuntimeEvent, + PiSessionStats, + PiSessionState, + PiThinkingLevel, +} from "./rpc-types.js"; +import { + mapToolDetail, + parseToolArgs, + parseToolResult, + resolveToolCallName, + type PiToolResult, + type PiTrackedToolCall, +} from "./tool-call-mapper.js"; + +const PI_PROVIDER = "pi"; +const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium"; +const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi"; + +const PI_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: false, + supportsReasoningStream: true, + supportsToolInvocations: true, +}; + +const PI_THINKING_OPTIONS: ReadonlyArray<{ + id: PiThinkingLevel; + label: string; + description: string; + isDefault?: boolean; +}> = [ + { id: "off", label: "Off", description: "No extra reasoning" }, + { id: "minimal", label: "Minimal", description: "Light reasoning" }, + { id: "low", label: "Low", description: "Faster reasoning" }, + { id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true }, + { id: "high", label: "High", description: "Deeper reasoning" }, + { id: "xhigh", label: "XHigh", description: "Maximum reasoning" }, +] as const; + +interface PiRpcAgentClientOptions { + logger: Logger; + runtimeSettings?: ProviderRuntimeSettings; + runtime?: PiRuntime; +} + +interface PiPromptPayload { + text: string; + images?: PiImageContent[]; +} + +interface PiModelReference { + provider?: string; + id: string; +} + +interface PiPersistenceMetadata { + cwd?: string; + model?: string; + thinkingOptionId?: string; + systemPrompt?: string; +} + +interface StartTurnResult { + turnId: string; +} + +interface PiRpcAgentSessionOptions { + runtimeSession: PiRuntimeSession; + config: AgentSessionConfig; + initialState: PiSessionState; + capabilities: AgentCapabilityFlags; + cleanup?: () => void; +} + +interface PiResumeConfig { + cwd: string; + model?: string; + thinkingOptionId?: string; + config: AgentSessionConfig; +} + +interface PiMcpServerConfig { + command?: string; + args?: string[]; + env?: Record; + url?: string; + headers?: Record; + auth?: false; + oauth?: false; +} + +interface PiMcpConfigFile { + path: string; + cleanup: () => void; +} + +function normalizePiModelLabel(label: string): string { + return label.trim().replace(/[_\s]+/g, " "); +} + +export function transformPiModels(models: AgentModelDefinition[]): AgentModelDefinition[] { + return models.map((model) => { + if (!model.label.includes("/")) { + return model; + } + + const segments = model.label.split("/").filter((segment) => segment.length > 0); + const rawLabel = segments.at(-1); + if (!rawLabel) { + return model; + } + + return { + ...model, + label: normalizePiModelLabel(rawLabel), + description: model.description ?? model.label, + }; + }); +} + +function isPiThinkingLevel(value: string | null | undefined): value is PiThinkingLevel { + return ( + value === "off" || + value === "minimal" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" + ); +} + +function normalizePiThinkingOption(value: string | null | undefined): PiThinkingLevel | null { + if (!value) { + return null; + } + return isPiThinkingLevel(value) ? value : null; +} + +function mapThinkingOption(option: (typeof PI_THINKING_OPTIONS)[number]) { + const mappedOption = { + id: option.id, + label: option.label, + description: option.description, + }; + if (option.isDefault) { + return { + ...mappedOption, + isDefault: true, + }; + } + return mappedOption; +} + +function toAgentUsage(stats: PiSessionStats): AgentUsage | undefined { + const inputTokens = stats.tokens?.input ?? 0; + const cachedInputTokens = stats.tokens?.cacheRead ?? 0; + const outputTokens = stats.tokens?.output ?? 0; + const totalCostUsd = stats.cost ?? 0; + const contextWindowMaxTokens = stats.contextUsage?.contextWindow ?? undefined; + const contextWindowUsedTokens = stats.contextUsage?.tokens ?? undefined; + + if ( + inputTokens === 0 && + cachedInputTokens === 0 && + outputTokens === 0 && + totalCostUsd === 0 && + contextWindowMaxTokens === undefined && + contextWindowUsedTokens === undefined + ) { + return undefined; + } + + return { + inputTokens, + cachedInputTokens, + outputTokens, + totalCostUsd, + ...(typeof contextWindowMaxTokens === "number" ? { contextWindowMaxTokens } : {}), + ...(typeof contextWindowUsedTokens === "number" ? { contextWindowUsedTokens } : {}), + }; +} + +function convertPromptInput(prompt: AgentPromptInput): PiPromptPayload { + if (typeof prompt === "string") { + return { text: prompt }; + } + + const textParts: string[] = []; + const images: PiImageContent[] = []; + + for (const block of prompt) { + if (block.type === "text") { + textParts.push(block.text); + continue; + } + + if (block.type === "image") { + images.push({ + type: "image", + data: block.data, + mimeType: block.mimeType, + }); + continue; + } + + textParts.push(renderPromptAttachmentAsText(block)); + } + + const payload: PiPromptPayload = { + text: textParts.join("\n\n"), + }; + if (images.length > 0) { + payload.images = images; + } + return payload; +} + +function parseModelReference(modelId: string | null): PiModelReference | null { + if (!modelId) { + return null; + } + if (modelId.includes("/")) { + const [provider, ...rest] = modelId.split("/"); + const id = rest.join("/"); + if (provider && id) { + return { provider, id }; + } + } + if (modelId.includes(":")) { + const [provider, ...rest] = modelId.split(":"); + const id = rest.join(":"); + if (provider && id) { + return { provider, id }; + } + } + return { id: modelId }; +} + +function parsePersistenceMetadata(metadata: AgentMetadata | undefined): PiPersistenceMetadata { + if (!metadata) { + return {}; + } + return { + ...(typeof metadata.cwd === "string" ? { cwd: metadata.cwd } : {}), + ...(typeof metadata.model === "string" ? { model: metadata.model } : {}), + ...(typeof metadata.thinkingOptionId === "string" + ? { thinkingOptionId: metadata.thinkingOptionId } + : {}), + ...(typeof metadata.systemPrompt === "string" ? { systemPrompt: metadata.systemPrompt } : {}), + }; +} + +function buildResumeConfig( + metadata: PiPersistenceMetadata, + overrides: Partial | undefined, +): PiResumeConfig { + const overrideConfig = overrides ?? {}; + const cwd = overrideConfig.cwd ?? metadata.cwd ?? process.cwd(); + const model = overrideConfig.model ?? metadata.model; + const thinkingOptionId = overrideConfig.thinkingOptionId ?? metadata.thinkingOptionId; + return { + cwd, + model, + thinkingOptionId, + config: { + ...overrideConfig, + provider: PI_PROVIDER, + cwd, + model, + thinkingOptionId, + systemPrompt: overrideConfig.systemPrompt ?? metadata.systemPrompt, + }, + }; +} + +function toPiMcpConfig(config: McpServerConfig): PiMcpServerConfig { + if (config.type === "stdio") { + return { + command: config.command, + ...(config.args ? { args: config.args } : {}), + ...(config.env ? { env: config.env } : {}), + }; + } + + return { + url: config.url, + ...(config.headers ? { headers: config.headers } : {}), + auth: false, + oauth: false, + }; +} + +function createPiMcpConfigFile(servers: Record): PiMcpConfigFile { + const dir = mkdtempSync(join(tmpdir(), "paseo-pi-mcp-")); + const filePath = join(dir, "mcp.json"); + const mcpServers: Record = {}; + for (const [name, serverConfig] of Object.entries(servers)) { + mcpServers[name] = toPiMcpConfig(serverConfig); + } + writeFileSync(filePath, `${JSON.stringify({ mcpServers }, null, 2)}\n`, "utf8"); + return { + path: filePath, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +function isPiMcpAdapterCommand(command: PiRpcSlashCommand): boolean { + if (command.source !== "extension" || !/^mcp(?::\d+)?$/.test(command.name)) { + return false; + } + if (!command.sourceInfo) { + return true; + } + return JSON.stringify(command.sourceInfo).includes("pi-mcp-adapter"); +} + +function withPiMcpCapability(supportsMcpServers: boolean): AgentCapabilityFlags { + return { + ...PI_CAPABILITIES, + supportsMcpServers, + }; +} + +function isPiRequestAbortError(error: unknown): boolean { + if (error instanceof Error && error.name === "AbortError") { + return true; + } + + return /\brequest was aborted\b|\babort(ed)?\b/i.test(toDiagnosticErrorMessage(error)); +} + +function resolveThinkingOptionId( + cachedThinkingOptionId: string | null, + sessionThinkingLevel: PiThinkingLevel, +): PiThinkingLevel | null { + const currentThinking = cachedThinkingOptionId ?? sessionThinkingLevel; + return normalizePiThinkingOption(currentThinking); +} + +function modelToId(model: PiModel | null | undefined): string | null { + return model?.provider && model.id ? `${model.provider}/${model.id}` : null; +} + +function latestPiErrorMessage(messages: PiAgentMessage[]): string | null { + const latestAssistant = messages.findLast((message) => message.role === "assistant"); + return latestAssistant && "errorMessage" in latestAssistant + ? (latestAssistant.errorMessage ?? null) + : null; +} + +function mapPiModel(model: PiModel): AgentModelDefinition { + return { + provider: PI_PROVIDER, + id: `${model.provider}/${model.id}`, + label: `${model.provider}/${model.name ?? model.id}`, + description: `${model.provider}/${model.id}`, + metadata: { + provider: model.provider, + modelId: model.id, + }, + thinkingOptions: model.reasoning ? PI_THINKING_OPTIONS.map(mapThinkingOption) : undefined, + defaultThinkingOptionId: model.reasoning ? DEFAULT_PI_THINKING_LEVEL : undefined, + }; +} + +function createRuntime(logger: Logger, runtimeSettings?: ProviderRuntimeSettings): PiRuntime { + return new PiCliRuntime({ logger, runtimeSettings }); +} + +export class PiRpcAgentSession implements AgentSession { + readonly provider = PI_PROVIDER; + readonly capabilities: AgentCapabilityFlags; + + private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); + private readonly activeToolCalls = new Map(); + private activeTurnId: string | null = null; + private lastKnownThinkingOptionId: string | null; + private state: PiSessionState; + private closed = false; + + constructor(options: PiRpcAgentSessionOptions) { + this.runtimeSession = options.runtimeSession; + this.config = options.config; + this.state = options.initialState; + this.capabilities = options.capabilities; + this.cleanup = options.cleanup; + this.lastKnownThinkingOptionId = + normalizePiThinkingOption(options.config.thinkingOptionId) ?? + this.state.thinkingLevel ?? + null; + + this.runtimeSession.onEvent((event) => { + this.handleRuntimeEvent(event); + }); + } + + private readonly runtimeSession: PiRuntimeSession; + private readonly config: AgentSessionConfig; + private readonly cleanup?: () => void; + + get id(): string | null { + return this.state.sessionId; + } + + async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { + return runProviderTurn({ + prompt, + runOptions: options, + startTurn: (p, o) => this.startTurn(p, o), + subscribe: (callback) => this.subscribe(callback), + getSessionId: () => this.state.sessionId, + reduceFinalText: ({ current, item }) => + item.type === "assistant_message" ? `${current}${item.text}` : current, + }); + } + + async startTurn(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise { + if (this.activeTurnId) { + throw new Error("A Pi turn is already active"); + } + + const payload = convertPromptInput(prompt); + const turnId = randomUUID(); + this.activeTurnId = turnId; + + void this.runtimeSession.prompt(payload.text, payload.images).catch((error) => { + const failedTurnId = this.activeTurnId ?? turnId; + this.activeTurnId = null; + if (isPiRequestAbortError(error)) { + this.emit({ + type: "turn_canceled", + provider: PI_PROVIDER, + turnId: failedTurnId, + reason: toDiagnosticErrorMessage(error), + }); + return; + } + this.emit({ + type: "turn_failed", + provider: PI_PROVIDER, + turnId: failedTurnId, + error: toDiagnosticErrorMessage(error), + }); + }); + + return { turnId }; + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.subscribers.add(callback); + return () => { + this.subscribers.delete(callback); + }; + } + + async *streamHistory(): AsyncGenerator { + yield* streamPiHistory(PI_PROVIDER, await this.runtimeSession.getMessages()); + } + + async getRuntimeInfo(): Promise { + await this.refreshState(); + return { + provider: PI_PROVIDER, + sessionId: this.state.sessionId, + model: modelToId(this.state.model), + thinkingOptionId: resolveThinkingOptionId( + this.lastKnownThinkingOptionId, + this.state.thinkingLevel, + ), + modeId: null, + }; + } + + async getAvailableModes(): Promise { + return []; + } + + async getCurrentMode(): Promise { + return null; + } + + async setMode(modeId: string): Promise { + void modeId; + throw new Error("Pi does not expose selectable modes"); + } + + getPendingPermissions(): AgentPermissionRequest[] { + return []; + } + + async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { + void requestId; + void response; + } + + describePersistence(): AgentPersistenceHandle | null { + return { + provider: PI_PROVIDER, + sessionId: this.state.sessionId, + nativeHandle: this.state.sessionFile, + metadata: { + cwd: this.config.cwd, + ...(this.config.model ? { model: this.config.model } : {}), + ...(this.config.thinkingOptionId ? { thinkingOptionId: this.config.thinkingOptionId } : {}), + }, + }; + } + + async interrupt(): Promise { + await this.runtimeSession.abort(); + } + + async close(): Promise { + if (this.closed) { + return; + } + this.closed = true; + try { + await this.runtimeSession.close(); + } finally { + this.cleanup?.(); + } + } + + async listCommands(): Promise { + const commands = await this.runtimeSession.getCommands(); + return commands.map((command) => ({ + name: command.name, + description: command.description ?? command.source, + argumentHint: "", + })); + } + + async setModel(modelId: string | null): Promise { + const parsedReference = parseModelReference(modelId); + if (!parsedReference) { + return; + } + if (!parsedReference.provider) { + throw new Error(`Pi model id must include a provider: ${modelId}`); + } + + const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id); + this.state = { + ...this.state, + model, + }; + this.config.model = `${model.provider}/${model.id}`; + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + const thinkingLevel = normalizePiThinkingOption(thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL; + await this.runtimeSession.setThinkingLevel(thinkingLevel); + this.lastKnownThinkingOptionId = thinkingLevel; + this.config.thinkingOptionId = thinkingLevel; + this.state = { + ...this.state, + thinkingLevel, + }; + } + + private emit(event: AgentStreamEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } + + private currentTurnIdForEvent(): string | undefined { + return this.activeTurnId ?? undefined; + } + + private handleRuntimeEvent(event: PiRuntimeEvent): void { + if (event.type === "extension_ui_request") { + this.runtimeSession.cancelExtensionUiRequest(event.id); + return; + } + if (event.type === "process_exit") { + this.handleProcessExit(event.error); + return; + } + this.handleSessionEvent(event); + } + + private handleProcessExit(error: string): void { + if (!this.activeTurnId) { + return; + } + const turnId = this.activeTurnId; + this.activeTurnId = null; + this.emit({ + type: "turn_failed", + provider: PI_PROVIDER, + turnId, + error, + }); + } + + private handleSessionEvent(event: PiAgentSessionEvent): void { + const turnId = this.currentTurnIdForEvent(); + + switch (event.type) { + case "agent_start": + this.emit({ + type: "thread_started", + provider: PI_PROVIDER, + sessionId: this.state.sessionId, + }); + return; + case "turn_start": + this.emit({ + type: "turn_started", + provider: PI_PROVIDER, + turnId, + }); + return; + case "message_update": + this.handleMessageUpdate(event, turnId); + return; + case "tool_execution_start": { + const toolCall = parseToolArgs(event.toolName, event.args); + this.activeToolCalls.set(event.toolCallId, toolCall); + this.emitToolCallEvent(event.toolCallId, toolCall, "running", null, null); + return; + } + case "tool_execution_update": { + const toolCall = this.activeToolCalls.get(event.toolCallId); + if (!toolCall) { + return; + } + + const partialResult = parseToolResult(event.partialResult); + this.emitToolCallEvent(event.toolCallId, toolCall, "running", partialResult, null); + return; + } + case "tool_execution_end": { + const toolCall = + this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); + this.activeToolCalls.delete(event.toolCallId); + + const result = parseToolResult(event.result); + const error = event.isError ? event.result : null; + const status = event.isError ? "failed" : "completed"; + this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error); + return; + } + case "compaction_start": + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item: { + type: "compaction", + status: "loading", + trigger: event.reason === "manual" ? "manual" : "auto", + }, + }); + return; + case "compaction_end": + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item: { + type: "compaction", + status: "completed", + }, + }); + return; + case "agent_end": + this.completeTurn(turnId, event.messages ?? []); + return; + default: + return; + } + } + + private handleMessageUpdate( + event: Extract, + turnId: string | undefined, + ): void { + if (event.message.role !== "assistant") { + return; + } + if (event.assistantMessageEvent.type === "text_delta") { + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item: { + type: "assistant_message", + text: event.assistantMessageEvent.delta ?? "", + }, + }); + return; + } + if (event.assistantMessageEvent.type === "thinking_delta") { + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item: { + type: "reasoning", + text: event.assistantMessageEvent.delta ?? "", + }, + }); + } + } + + private emitToolCallEvent( + toolCallId: string, + toolCall: PiTrackedToolCall, + status: "running" | "completed" | "failed", + result: PiToolResult, + error: unknown, + ): void { + const turnId = this.currentTurnIdForEvent(); + const detail = mapToolDetail(toolCall, result); + const baseItem = { + type: "tool_call" as const, + callId: toolCallId, + name: resolveToolCallName(toolCall, result), + detail, + }; + const item = + status === "failed" ? { ...baseItem, status, error } : { ...baseItem, status, error: null }; + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item, + }); + } + + private completeTurn(turnId: string | undefined, messages: PiAgentMessage[]): void { + this.activeTurnId = null; + const errorMessage = latestPiErrorMessage(messages); + if (typeof errorMessage === "string" && errorMessage.length > 0) { + this.emit({ + type: "turn_failed", + provider: PI_PROVIDER, + turnId, + error: errorMessage, + }); + return; + } + this.emit({ + type: "turn_completed", + provider: PI_PROVIDER, + turnId, + }); + void this.refreshAfterTurn(turnId); + } + + private async refreshState(): Promise { + this.state = await this.runtimeSession.getState(); + } + + private async refreshAfterTurn(turnId: string | undefined): Promise { + await this.refreshState().catch(() => undefined); + const usage = await this.runtimeSession + .getSessionStats() + .then(toAgentUsage) + .catch(() => undefined); + if (usage) { + this.emit({ + type: "usage_updated", + provider: PI_PROVIDER, + turnId, + usage, + }); + } + } +} + +export class PiRpcAgentClient implements AgentClient { + readonly provider = PI_PROVIDER; + readonly capabilities = PI_CAPABILITIES; + + private readonly logger: Logger; + private readonly runtimeSettings?: ProviderRuntimeSettings; + private readonly runtime: PiRuntime; + + constructor(options: PiRpcAgentClientOptions) { + this.logger = options.logger; + this.runtimeSettings = options.runtimeSettings; + this.runtime = options.runtime ?? createRuntime(options.logger, options.runtimeSettings); + } + + async createSession( + config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + ): Promise { + const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers); + let runtimeSession: PiRuntimeSession; + try { + runtimeSession = await this.runtime.startSession({ + cwd: config.cwd, + model: config.model, + thinkingOptionId: + normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL, + systemPrompt: composeSystemPromptParts( + config.systemPrompt, + config.daemonAppendSystemPrompt, + ), + mcpConfigPath: mcpConfig?.path, + }); + } catch (error) { + mcpConfig?.cleanup(); + throw error; + } + try { + return new PiRpcAgentSession({ + runtimeSession, + config, + initialState: await runtimeSession.getState(), + capabilities: withPiMcpCapability(mcpConfig !== null), + cleanup: mcpConfig?.cleanup, + }); + } catch (error) { + await runtimeSession.close().catch(() => undefined); + mcpConfig?.cleanup(); + throw error; + } + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + _launchContext?: AgentLaunchContext, + ): Promise { + const sessionFile = handle.nativeHandle; + if (!sessionFile) { + throw new Error("Pi resume requires a native session file handle"); + } + + const persistenceMetadata = parsePersistenceMetadata(handle.metadata); + const resumeConfig = buildResumeConfig(persistenceMetadata, overrides); + + const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers); + let runtimeSession: PiRuntimeSession; + try { + runtimeSession = await this.runtime.startSession({ + cwd: resumeConfig.cwd, + session: sessionFile, + model: resumeConfig.model, + thinkingOptionId: normalizePiThinkingOption(resumeConfig.thinkingOptionId) ?? undefined, + systemPrompt: composeSystemPromptParts( + resumeConfig.config.systemPrompt, + resumeConfig.config.daemonAppendSystemPrompt, + ), + mcpConfigPath: mcpConfig?.path, + }); + } catch (error) { + mcpConfig?.cleanup(); + throw error; + } + try { + return new PiRpcAgentSession({ + runtimeSession, + config: resumeConfig.config, + initialState: await runtimeSession.getState(), + capabilities: withPiMcpCapability(mcpConfig !== null), + cleanup: mcpConfig?.cleanup, + }); + } catch (error) { + await runtimeSession.close().catch(() => undefined); + mcpConfig?.cleanup(); + throw error; + } + } + + async listModels(options: ListModelsOptions): Promise { + const runtimeSession = await this.runtime.startSession({ cwd: options.cwd }); + try { + return transformPiModels((await runtimeSession.getAvailableModels()).map(mapPiModel)); + } finally { + await runtimeSession.close(); + } + } + + async listModes(_options: ListModesOptions): Promise { + return []; + } + + async listPersistedAgents( + options?: ListPersistedAgentsOptions, + ): Promise { + return await listPiPersistedAgents({ + ...options, + runtimeSettings: this.runtimeSettings, + }); + } + + async isAvailable(): Promise { + const binary = await this.resolvePiBinary(); + if (!binary) { + return false; + } + const runtimeSession = await this.runtime.startSession({ cwd: homedir() }).catch(() => null); + if (!runtimeSession) { + return false; + } + try { + return (await runtimeSession.getAvailableModels()).length > 0; + } catch { + return false; + } finally { + await runtimeSession.close().catch(() => undefined); + } + } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const binary = await this.resolvePiBinary(); + const version = binary ? await resolveBinaryVersion(binary) : "unknown"; + const authConfigPath = join(homedir(), ".pi", "agent", "auth.json"); + let modelsValue = "Not checked"; + let configuredProvidersValue = "none"; + let mcpToolsValue = "Not checked"; + let status = formatDiagnosticStatus(available); + + if (binary) { + const runtimeSession = await this.runtime + .startSession({ cwd: homedir() }) + .catch((error) => { + status = formatDiagnosticStatus(false, { + source: "startup", + cause: error, + }); + return null; + }); + if (runtimeSession) { + try { + const models = await runtimeSession.getAvailableModels(); + modelsValue = String(models.length); + const configuredProviders = Array.from( + new Set(models.map((model) => model.provider)), + ).sort(); + configuredProvidersValue = + configuredProviders.length > 0 ? configuredProviders.join(", ") : "none"; + const commands = await runtimeSession.getCommands(); + mcpToolsValue = commands.some(isPiMcpAdapterCommand) + ? "yes (pi-mcp-adapter loaded)" + : "no (install pi-mcp-adapter)"; + } catch (error) { + modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + mcpToolsValue = `Error - ${toDiagnosticErrorMessage(error)}`; + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } finally { + await runtimeSession.close().catch(() => undefined); + } + } + } + + return { + diagnostic: formatProviderDiagnostic("Pi", [ + { label: "Binary", value: binary ?? "not found" }, + { label: "Version", value: version }, + { label: "Configured providers", value: configuredProvidersValue }, + { + label: "Auth config (~/.pi/agent/auth.json)", + value: existsSync(authConfigPath) ? "found" : "not found", + }, + { label: "Models", value: modelsValue }, + { label: "Paseo MCP tools", value: mcpToolsValue }, + { label: "Status", value: status }, + ]), + }; + } catch (error) { + this.logger.debug({ err: error }, "Pi diagnostic lookup failed"); + return { + diagnostic: formatProviderDiagnosticError("Pi", error), + }; + } + } + + private async prepareMcpConfig( + cwd: string, + servers: Record | undefined, + ): Promise { + if (!servers || Object.keys(servers).length === 0) { + return null; + } + if (!(await this.detectMcpAdapter(cwd))) { + return null; + } + return createPiMcpConfigFile(servers); + } + + private async detectMcpAdapter(cwd: string): Promise { + const runtimeSession = await this.runtime.startSession({ cwd }).catch((error) => { + this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed to start"); + return null; + }); + if (!runtimeSession) { + return false; + } + try { + return (await runtimeSession.getCommands()).some(isPiMcpAdapterCommand); + } catch (error) { + this.logger.debug({ err: error, cwd }, "Pi MCP adapter probe failed"); + return false; + } finally { + await runtimeSession.close().catch(() => undefined); + } + } + + private async resolvePiBinary(): Promise { + const command = this.runtimeSettings?.command; + if (command?.mode === "replace" && command.argv[0]) { + return await findExecutable(command.argv[0]); + } + return await findExecutable(PI_BINARY_COMMAND); + } +} diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts new file mode 100644 index 000000000..bf10ee30d --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.test.ts @@ -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; +}; + +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) => 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; + 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"); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/cli-runtime.ts b/packages/server/src/server/agent/providers/pi/cli-runtime.ts new file mode 100644 index 000000000..27ecbd803 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/cli-runtime.ts @@ -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 { + 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(); + 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 { + await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) }); + } + + async abort(): Promise { + await this.request({ type: "abort" }); + } + + async getState(): Promise { + return (await this.request({ type: "get_state" })) as PiSessionState; + } + + async getMessages(): Promise { + const data = (await this.request({ type: "get_messages" })) as { messages?: PiAgentMessage[] }; + return data.messages ?? []; + } + + async getAvailableModels(): Promise { + const data = (await this.request({ type: "get_available_models" })) as { models?: PiModel[] }; + return data.models ?? []; + } + + async setModel(provider: string, modelId: string): Promise { + return (await this.request({ type: "set_model", provider, modelId })) as PiModel; + } + + async setThinkingLevel(level: string): Promise { + await this.request({ type: "set_thinking_level", level: level as never }); + } + + async getSessionStats(): Promise { + return (await this.request({ type: "get_session_stats" })) as PiSessionStats; + } + + async getCommands(): Promise { + 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 { + 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 { + 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; + 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(); + } +} diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts new file mode 100644 index 000000000..1851204b7 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts @@ -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 { + 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, + }, + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.ts b/packages/server/src/server/agent/providers/pi/history-mapper.ts new file mode 100644 index 000000000..f8440f901 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/history-mapper.ts @@ -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 { + const pendingToolCalls = new Map(); + 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, + }; +} diff --git a/packages/server/src/server/agent/providers/pi/rpc-types.ts b/packages/server/src/server/agent/providers/pi/rpc-types.ts new file mode 100644 index 000000000..93efad32b --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/rpc-types.ts @@ -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; + } + | { + 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; + 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; +} + +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; + }; diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts new file mode 100644 index 000000000..0e7a50b5e --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -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; + 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; + abort(): Promise; + getState(): Promise; + getMessages(): Promise; + getAvailableModels(): Promise; + setModel(provider: string, modelId: string): Promise; + setThinkingLevel(level: string): Promise; + getSessionStats(): Promise; + getCommands(): Promise; + cancelExtensionUiRequest(id: string): void; + close(): Promise; +} + +export interface PiRuntime { + startSession(input: PiStartSessionInput): Promise; +} + +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; +} diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts new file mode 100644 index 000000000..9665fe8be --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts @@ -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"]); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.ts new file mode 100644 index 000000000..b1e6d9918 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | 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 { + 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; +} diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts new file mode 100644 index 000000000..9542bc602 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -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 { + 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 { + this.prompts.push({ message, imageCount: images?.length ?? 0 }); + } + + async abort(): Promise { + this.abortRequested = true; + } + + async getState(): Promise { + return this.state; + } + + async getMessages(): Promise { + return this.messages; + } + + async getAvailableModels(): Promise { + return this.models; + } + + async setModel(provider: string, modelId: string): Promise { + 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 { + this.setThinkingLevelRequests.push(level); + } + + async getSessionStats(): Promise { + return this.stats; + } + + async getCommands(): Promise { + return this.commands; + } + + cancelExtensionUiRequest(id: string): void { + this.canceledExtensionUiRequests.push(id); + } + + async close(): Promise {} + + 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 }); + } +} diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts new file mode 100644 index 000000000..45ccbcb8a --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.test.ts @@ -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"); + }); +}); diff --git a/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts new file mode 100644 index 000000000..abdea5624 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/tool-call-mapper.ts @@ -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 { + 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 = z.object({ + command: z.string(), + timeout: z.number().optional(), +}); + +const ReadToolInputSchema: z.ZodType = z.object({ + path: z.string(), + offset: z.number().optional(), + limit: z.number().optional(), +}); + +const EditToolInputSchema: z.ZodType = 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 = z.object({ + path: z.string(), + content: z.string(), +}); + +const FindToolInputSchema: z.ZodType = z.object({ + pattern: z.string(), + path: z.string().optional(), + limit: z.number().optional(), +}); + +const GrepToolInputSchema: z.ZodType = 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 = 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, + }; +} diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 2ac5f65e3..8955b85e5 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -166,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); diff --git a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts index c4607671b..e2fa0a02d 100644 --- a/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/pi.real.e2e.test.ts @@ -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 => - 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, ): Promise { 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-"); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 23756ceb2..998b2fbba 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -63,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({ diff --git a/packages/server/src/shared/importable-providers.ts b/packages/server/src/shared/importable-providers.ts index f04fa2f1c..d4abcd339 100644 --- a/packages/server/src/shared/importable-providers.ts +++ b/packages/server/src/shared/importable-providers.ts @@ -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; diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index 738346361..2ee49fa95 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -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([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); }