diff --git a/docs/CUSTOM-PROVIDERS.md b/docs/CUSTOM-PROVIDERS.md index 3e1afa9a0..dedcdb60d 100644 --- a/docs/CUSTOM-PROVIDERS.md +++ b/docs/CUSTOM-PROVIDERS.md @@ -107,6 +107,7 @@ Required fields for custom providers: - `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key - The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic) - If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider +- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors. - Automated setup is also available: `npx @z_ai/coding-helper` - Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude) @@ -173,6 +174,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk - API keys must be created in the **Singapore region** - The coding plan is for personal use only in interactive coding tools +- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors. - Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan) --- @@ -436,6 +438,7 @@ Every entry under `agents.providers` accepts these fields: | `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process | | `env` | `Record` | No | Environment variables to set for the agent process | | `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) | +| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) | | `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) | | `order` | `number` | No | Sort order in the provider list | @@ -460,6 +463,29 @@ Each entry in the `models` array: | `description` | `string` | No | Short description | | `isDefault` | `boolean` | No | Mark as the default thinking option | +### Gotcha: `extends: "claude"` with third-party endpoints + +When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors. + +Use `disallowedTools` to disable unsupported tools: + +```json +{ + "agents": { + "providers": { + "my-proxy": { + "extends": "claude", + "label": "My Proxy", + "env": { + "ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1" + }, + "disallowedTools": ["WebSearch"] + } + } + } +} +``` + ### Valid `extends` values Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi` diff --git a/docs/RELEASE.md b/docs/RELEASE.md index b20aaec68..54e288c70 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -139,6 +139,33 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de - **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix. - **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them. +## Changelog attribution + +Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs. + +Format: append `([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user))` at the end of each bullet. For changes spanning multiple PRs or contributors: + +```markdown +- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob)) +``` + +Rules: + +- **Always link the PR number** as `[#N](https://github.com/getpaseo/paseo/pull/N)`. +- **Always link the contributor's GitHub profile** as `[@user](https://github.com/user)`. +- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet. +- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once. +- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default. +- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username. + +## Changelog ordering + +Entries within each section (Added, Improved, Fixed) are ordered by user impact: + +1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow. +2. **Quality-of-life improvements** — polish, performance, smoother interactions. +3. **Internal/infra changes last** — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal). + ## Pre-release sanity check Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs. diff --git a/nix/package.nix b/nix/package.nix index 5031fbe0c..f3d346853 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -42,7 +42,7 @@ buildNpmPackage rec { # To update: run `nix build` with lib.fakeHash, copy the `got:` hash. # CI auto-updates this when package-lock.json changes (see .github/workflows/). - npmDepsHash = "sha256-hsN56kvNxPEDY06JFh0sKCVlEnHXYHPpM0DbH/oZvz0="; + npmDepsHash = "sha256-UrTPJVju3jiSlkUMfh25rWobIo9hWNVFoVewFGxRQC0="; # Prevent onnxruntime-node's install script from running during automatic # npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox). diff --git a/package-lock.json b/package-lock.json index ebba06cc2..7854aee8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paseo", - "version": "0.1.56", + "version": "0.1.57-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paseo", - "version": "0.1.56", + "version": "0.1.57-rc.1", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "workspaces": [ @@ -34854,16 +34854,16 @@ }, "packages/app": { "name": "@getpaseo/app", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.56", - "@getpaseo/highlight": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/expo-two-way-audio": "0.1.57-rc.1", + "@getpaseo/highlight": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", @@ -35004,11 +35004,11 @@ }, "packages/cli": { "name": "@getpaseo/cli", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/relay": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", @@ -35049,11 +35049,11 @@ }, "packages/desktop": { "name": "@getpaseo/desktop", - "version": "0.1.56", + "version": "0.1.57-rc.1", "license": "AGPL-3.0-or-later", "dependencies": { - "@getpaseo/cli": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/cli": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" @@ -35087,7 +35087,7 @@ }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.56", + "version": "0.1.57-rc.1", "license": "MIT", "devDependencies": { "@biomejs/biome": "1.9.4", @@ -35288,7 +35288,7 @@ }, "packages/highlight": { "name": "@getpaseo/highlight", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "@lezer/common": "^1.5.0", "@lezer/cpp": "^1.1.5", @@ -35314,7 +35314,7 @@ }, "packages/relay": { "name": "@getpaseo/relay", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "base64-js": "^1.5.1", "tweetnacl": "^1.0.3", @@ -35330,14 +35330,14 @@ }, "packages/server": { "name": "@getpaseo/server", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "@agentclientprotocol/sdk": "^0.17.1", "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.56", - "@getpaseo/relay": "0.1.56", + "@getpaseo/highlight": "0.1.57-rc.1", + "@getpaseo/relay": "0.1.57-rc.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", @@ -35765,7 +35765,7 @@ }, "packages/website": { "name": "@getpaseo/website", - "version": "0.1.56", + "version": "0.1.57-rc.1", "dependencies": { "@cloudflare/vite-plugin": "^1.20.3", "@cloudflare/workers-types": "^4.20260114.0", diff --git a/package.json b/package.json index 20ec2b057..0a8b24903 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paseo", - "version": "0.1.56", + "version": "0.1.57-rc.1", "private": true, "workspaces": [ "packages/expo-two-way-audio", diff --git a/packages/app/package.json b/packages/app/package.json index c840d37f4..94e0f700d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@getpaseo/app", "main": "index.ts", - "version": "0.1.56", + "version": "0.1.57-rc.1", "private": true, "scripts": { "start": "expo start", @@ -31,9 +31,9 @@ "@dnd-kit/utilities": "^3.2.2", "@expo/vector-icons": "^15.0.2", "@floating-ui/react-native": "^0.10.7", - "@getpaseo/expo-two-way-audio": "0.1.56", - "@getpaseo/highlight": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/expo-two-way-audio": "0.1.57-rc.1", + "@getpaseo/highlight": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "@gorhom/bottom-sheet": "^5.2.6", "@gorhom/portal": "^1.0.14", "@react-native-async-storage/async-storage": "2.2.0", diff --git a/packages/app/public/index.html b/packages/app/public/index.html index 688fb0609..00c15da0c 100644 --- a/packages/app/public/index.html +++ b/packages/app/public/index.html @@ -51,6 +51,17 @@ [contenteditable='true'] { -webkit-app-region: no-drag !important; } + + /* Suppress the browser's default focus outline — it appears on mouse + clicks and looks out of place against our themed surfaces. Keep a + themed ring for keyboard users via :focus-visible. */ + *:focus { + outline: none; + } + *:focus-visible { + outline: 2px solid #20744A; + outline-offset: 2px; + } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 3bdda743f..0a29e2827 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -18,6 +18,35 @@ import { FileDropZone } from "@/components/file-drop-zone"; import type { ImageAttachment } from "@/components/message-input"; import { isWeb } from "@/constants/platform"; +type EscHandler = () => void; +const escStack: EscHandler[] = []; +let escListenerAttached = false; + +function handleEscKeyDown(event: KeyboardEvent) { + if (event.key !== "Escape") return; + const top = escStack[escStack.length - 1]; + if (!top) return; + event.stopPropagation(); + event.preventDefault(); + top(); +} + +function pushEscHandler(handler: EscHandler): () => void { + escStack.push(handler); + if (!escListenerAttached && typeof window !== "undefined") { + window.addEventListener("keydown", handleEscKeyDown, true); + escListenerAttached = true; + } + return () => { + const index = escStack.lastIndexOf(handler); + if (index !== -1) escStack.splice(index, 1); + if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") { + window.removeEventListener("keydown", handleEscKeyDown, true); + escListenerAttached = false; + } + }; +} + const styles = StyleSheet.create((theme) => ({ desktopOverlay: { ...StyleSheet.absoluteFillObject, @@ -55,10 +84,18 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, }, title: { + flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, }, + headerActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginLeft: theme.spacing[3], + marginRight: theme.spacing[2], + }, closeButton: { padding: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -91,6 +128,18 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[6], gap: theme.spacing[4], }, + bottomSheetStaticContent: { + flex: 1, + padding: theme.spacing[6], + gap: theme.spacing[4], + minHeight: 0, + }, + desktopStaticContent: { + flexShrink: 1, + minHeight: 0, + padding: theme.spacing[6], + gap: theme.spacing[4], + }, })); function SheetBackground({ style }: BottomSheetBackgroundProps) { @@ -116,6 +165,7 @@ export interface AdaptiveModalSheetProps { visible: boolean; onClose: () => void; children: ReactNode; + headerActions?: ReactNode; snapPoints?: string[]; stackBehavior?: "push" | "switch" | "replace"; testID?: string; @@ -123,6 +173,7 @@ export interface AdaptiveModalSheetProps { desktopMaxWidth?: number; /** When provided, wraps the card content in a FileDropZone. */ onFilesDropped?: (files: ImageAttachment[]) => void; + scrollable?: boolean; } export function AdaptiveModalSheet({ @@ -131,11 +182,13 @@ export function AdaptiveModalSheet({ visible, onClose, children, + headerActions, snapPoints, stackBehavior, testID, desktopMaxWidth, onFilesDropped, + scrollable = true, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); @@ -143,19 +196,6 @@ export function AdaptiveModalSheet({ const dismissingForVisibilityRef = useRef(false); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); - useEffect(() => { - if (isMobile || !visible || !isWeb || typeof window === "undefined") return; - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Escape") { - e.preventDefault(); - onClose(); - } - } - // Capture phase: RN Web TextInput stops propagation, so bubbling listeners never fire. - window.addEventListener("keydown", handleKeyDown, true); - return () => window.removeEventListener("keydown", handleKeyDown, true); - }, [isMobile, visible, onClose]); - useEffect(() => { if (!isMobile) return; if (visible) { @@ -187,6 +227,11 @@ export function AdaptiveModalSheet({ [], ); + useEffect(() => { + if (!isWeb || isMobile || !visible) return; + return pushEscHandler(onClose); + }, [visible, isMobile, onClose]); + if (isMobile) { return ( - {title} + + {title} + {subtitle} + {headerActions ? {headerActions} : null} - - {children} - + {scrollable ? ( + + {children} + + ) : ( + {children} + )} ); } @@ -227,21 +279,28 @@ export function AdaptiveModalSheet({ <> - {title} + + {title} + {subtitle} + {headerActions ? {headerActions} : null} - - {children} - + {scrollable ? ( + + {children} + + ) : ( + {children} + )} ); diff --git a/packages/app/src/components/artifact-drawer.tsx b/packages/app/src/components/artifact-drawer.tsx index d6a43a809..55699e1eb 100644 --- a/packages/app/src/components/artifact-drawer.tsx +++ b/packages/app/src/components/artifact-drawer.tsx @@ -2,6 +2,7 @@ import { View, Text, ScrollView, Pressable, Modal } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; export interface Artifact { id: string; @@ -147,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({ })); export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) { + const webScrollbarStyle = useWebScrollbarStyle(); + if (!artifact) { return null; } @@ -190,7 +193,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) { {/* Content */} {artifact.type === "image" ? ( @@ -200,7 +203,11 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) { ) : ( - + {content} diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index aeef7b346..335cbe683 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -45,7 +45,7 @@ export function BranchSwitcher({ const titleContent = ( <> - + {isGitCheckout ? : null} {title} diff --git a/packages/app/src/components/diff-scroll.web.tsx b/packages/app/src/components/diff-scroll.web.tsx index 65c8dce2d..364c752b2 100644 --- a/packages/app/src/components/diff-scroll.web.tsx +++ b/packages/app/src/components/diff-scroll.web.tsx @@ -1,4 +1,5 @@ import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; interface DiffScrollProps { children: React.ReactNode; @@ -14,12 +15,14 @@ export function DiffScroll({ style, contentContainerStyle, }: DiffScrollProps) { + const webScrollbarStyle = useWebScrollbarStyle(); + return ( onScrollViewWidthChange(e.nativeEvent.layout.width)} > diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index 7fe88e8df..ea4bb7147 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -4,6 +4,7 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { getCodeInsets } from "./code-insets"; import { isWeb } from "@/constants/platform"; @@ -23,6 +24,7 @@ export function DiffViewer({ fillAvailableHeight = false, }: DiffViewerProps) { const [scrollViewWidth, setScrollViewWidth] = React.useState(0); + const webScrollbarStyle = useWebScrollbarStyle(); if (!diffLines.length) { return ( @@ -38,6 +40,7 @@ export function DiffViewer({ styles.verticalScroll, maxHeight !== undefined && { maxHeight }, fillAvailableHeight && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.verticalContent} nestedScrollEnabled @@ -47,6 +50,7 @@ export function DiffViewer({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator + style={webScrollbarStyle} contentContainerStyle={styles.horizontalContent} onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)} > diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 5d952e135..9575eb891 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -167,23 +167,32 @@ export function FileExplorerPane({ if (hasInitializedRef.current) { return; } + // Mark initialized eagerly so concurrent effect re-runs don't double-fetch. + // If the root listing fails (e.g. client not yet connected), we reset the + // flag so the next time requestDirectoryListing is recreated (when client + // becomes available) this effect retries automatically. hasInitializedRef.current = true; void requestDirectoryListing(".", { recordHistory: false, setCurrentPath: false, - }); - const persistedPaths = - usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""]; - if (persistedPaths) { - for (const path of persistedPaths) { - if (path !== ".") { - void requestDirectoryListing(path, { - recordHistory: false, - setCurrentPath: false, - }); + }).then((succeeded) => { + if (!succeeded) { + hasInitializedRef.current = false; + return; + } + const persistedPaths = + usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""]; + if (persistedPaths) { + for (const path of persistedPaths) { + if (path !== ".") { + void requestDirectoryListing(path, { + recordHistory: false, + setCurrentPath: false, + }); + } } } - } + }); }, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]); // Expand ancestor directories when a file is selected (e.g., from an inline path click) diff --git a/packages/app/src/components/file-pane-render-mode.test.ts b/packages/app/src/components/file-pane-render-mode.test.ts new file mode 100644 index 000000000..b57b81711 --- /dev/null +++ b/packages/app/src/components/file-pane-render-mode.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; + +describe("isRenderedMarkdownFile", () => { + it("detects .md files", () => { + expect(isRenderedMarkdownFile("README.md")).toBe(true); + expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true); + }); + + it("detects .markdown files", () => { + expect(isRenderedMarkdownFile("notes.markdown")).toBe(true); + expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true); + }); + + it("does not treat .mdx files as rendered markdown", () => { + expect(isRenderedMarkdownFile("page.mdx")).toBe(false); + }); + + it("does not treat other text files as rendered markdown", () => { + expect(isRenderedMarkdownFile("src/index.ts")).toBe(false); + expect(isRenderedMarkdownFile("README.md.txt")).toBe(false); + }); +}); diff --git a/packages/app/src/components/file-pane-render-mode.ts b/packages/app/src/components/file-pane-render-mode.ts new file mode 100644 index 000000000..d6f507f9e --- /dev/null +++ b/packages/app/src/components/file-pane-render-mode.ts @@ -0,0 +1,4 @@ +export function isRenderedMarkdownFile(filePath: string): boolean { + const normalizedPath = filePath.trim().toLowerCase(); + return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown"); +} diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index e11419e57..5f9965349 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -1,5 +1,6 @@ import React, { useMemo, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; +import Markdown, { MarkdownIt } from "react-native-markdown-display"; import { ActivityIndicator, Image as RNImage, @@ -12,6 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { highlightCode, darkHighlightColors, @@ -20,7 +22,9 @@ import { type HighlightStyle, } from "@getpaseo/highlight"; import { lineNumberGutterWidth } from "@/components/code-insets"; +import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import { isWeb } from "@/constants/platform"; +import { createMarkdownStyles } from "@/styles/markdown-styles"; interface CodeLineProps { tokens: HighlightToken[]; @@ -68,7 +72,7 @@ const CodeLine = React.memo(function CodeLine({ {String(lineNumber)} - + {tokens.map((token, index) => ( createMarkdownStyles(theme), [theme]); + const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); + const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath); const previewScrollRef = useRef(null); + const webScrollbarStyle = useWebScrollbarStyle(); const scrollbar = useWebScrollViewScrollbar(previewScrollRef, { enabled: showDesktopWebScrollbar, }); const highlightedLines = useMemo(() => { - if (!preview || preview.kind !== "text") { + if (!preview || preview.kind !== "text" || isMarkdownFile) { return null; } return highlightCode(preview.content ?? "", filePath); - }, [preview?.kind, preview?.content, filePath]); + }, [isMarkdownFile, preview?.kind, preview?.content, filePath]); const gutterWidth = useMemo(() => { if (!highlightedLines) return 0; @@ -154,6 +162,28 @@ function FilePreviewBody({ } if (preview.kind === "text") { + if (isMarkdownFile) { + return ( + + + + {preview.content ?? ""} + + + {scrollbar.overlay} + + ); + } + const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]]; const codeLines = ( @@ -188,6 +218,7 @@ function FilePreviewBody({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator + style={webScrollbarStyle} contentContainerStyle={styles.previewCodeScrollContent} > {codeLines} @@ -329,6 +360,9 @@ const styles = StyleSheet.create((theme) => ({ previewCodeScrollContent: { padding: theme.spacing[4], }, + previewMarkdownScrollContent: { + padding: theme.spacing[4], + }, previewImageScrollContent: { flexGrow: 1, padding: theme.spacing[4], diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index b2e9fa8ca..d853378a0 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -45,6 +45,7 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { formatShortcut } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; +import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { markScrollInvestigationEvent, markScrollInvestigationRender, @@ -917,7 +918,7 @@ export const MessageInput = forwardRef(funct // IME composition in progress (e.g. CJK input) — all key events belong to the // IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is // cleared before the keydown fires. - if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; + if (isImeComposingKeyboardEvent(event.nativeEvent)) return; // Allow parent to intercept key events (e.g., for autocomplete navigation) if (onKeyPressCallback) { diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index d0e2fffac..418c22a63 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -1,11 +1,16 @@ -import { useCallback, useEffect, useState } from "react"; -import { View, Text, ActivityIndicator, ScrollView } from "react-native"; +import { AlertCircle, Search } from "lucide-react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; +import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; +import { isWeb } from "@/constants/platform"; +import { Fonts } from "@/constants/theme"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { resolveProviderLabel } from "@/utils/provider-definitions"; -import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; +import { formatTimeAgo } from "@/utils/time"; +import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; interface ProviderDiagnosticSheetProps { provider: string; @@ -22,83 +27,333 @@ export function ProviderDiagnosticSheet({ }: ProviderDiagnosticSheetProps) { const { theme } = useUnistyles(); const client = useHostRuntimeClient(serverId); - const { entries: snapshotEntries } = useProvidersSnapshot(serverId); + const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId); const [diagnostic, setDiagnostic] = useState(null); const [loading, setLoading] = useState(false); + const [query, setQuery] = useState(""); const providerLabel = resolveProviderLabel(provider, snapshotEntries); + const providerEntry = useMemo( + () => snapshotEntries?.find((entry) => entry.provider === provider), + [snapshotEntries, provider], + ); + const models = providerEntry?.models ?? []; + const providerSnapshotRefreshing = providerEntry?.status === "loading"; + const providerErrorMessage = + providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null; + const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading; - const fetchDiagnostic = useCallback(async () => { - if (!client || !provider) return; + const [clockTick, setClockTick] = useState(0); + useEffect(() => { + if (!visible) return; + const id = setInterval(() => setClockTick((t) => t + 1), 10_000); + return () => clearInterval(id); + }, [visible]); + const fetchedAtLabel = useMemo(() => { + if (!providerEntry?.fetchedAt) return null; + return formatTimeAgo(new Date(providerEntry.fetchedAt)); + // clockTick triggers re-computation on timer + }, [providerEntry?.fetchedAt, clockTick]); - setLoading(true); - setDiagnostic(null); + const q = query.trim().toLowerCase(); + const filteredModels = q + ? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)) + : models; - try { - const result = await client.getProviderDiagnostic(provider as AgentProvider); - setDiagnostic(result.diagnostic); - } catch (err) { - setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic"); - } finally { - setLoading(false); - } - }, [client, provider]); + const fetchDiagnostic = useCallback( + async (options?: { keepCurrent?: boolean }) => { + if (!client || !provider) return; + + setLoading(true); + if (!options?.keepCurrent) { + setDiagnostic(null); + } + + try { + const result = await client.getProviderDiagnostic(provider as AgentProvider); + setDiagnostic(result.diagnostic); + } catch (err) { + setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic"); + } finally { + setLoading(false); + } + }, + [client, provider], + ); + + const handleRefresh = useCallback(() => { + void refresh([provider as AgentProvider]); + void fetchDiagnostic({ keepCurrent: true }); + }, [fetchDiagnostic, provider, refresh]); useEffect(() => { if (visible) { fetchDiagnostic(); } else { setDiagnostic(null); + setQuery(""); } }, [visible, fetchDiagnostic]); + function renderModelsBody() { + if (models.length === 0 && providerSnapshotRefreshing) { + return ( + + + Loading models… + + ); + } + if (models.length === 0 && providerErrorMessage) { + return ( + + + {providerErrorMessage} + + ); + } + if (models.length === 0) { + return ( + + No models detected. + + ); + } + if (filteredModels.length === 0) { + return ( + + + No models match your search + + ); + } + return filteredModels.map((model: AgentModelDefinition, index) => ( + 0 && sheetStyles.modelRowBorder]}> + + {model.label} + + + {model.id} + + + )); + } + return ( - {loading ? ( - - - Fetching diagnostic… - - ) : diagnostic ? ( - [ + sheetStyles.iconButton, + (hovered || pressed) && sheetStyles.iconButtonHovered, + refreshInFlight ? sheetStyles.disabled : null, + ]} + accessibilityRole="button" + accessibilityLabel={`Refresh ${providerLabel}`} > - - {diagnostic} - + + + } + > + + Diagnostic + + {loading && !diagnostic ? ( + + + Running diagnostic… + + ) : diagnostic ? ( + + + + {diagnostic} + + + + ) : ( + + No diagnostic available. + + )} + + + + + + Models + + {models.length} + {fetchedAtLabel ? ( + <> + · + Updated {fetchedAtLabel} + + ) : null} + + + {models.length > 0 ? ( + + + + + ) : null} + + {renderModelsBody()} - ) : null} + ); } const sheetStyles = StyleSheet.create((theme) => ({ - loadingContainer: { + section: { + gap: theme.spacing[2], + }, + sectionTitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, + mutedText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + iconButton: { + width: 30, + height: 30, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + }, + iconButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + disabled: { + opacity: 0.5, + }, + codeBlock: { + borderWidth: 1, + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.base, + backgroundColor: theme.colors.surface2, + overflow: "hidden", + maxHeight: 180, + }, + codeScroll: { + maxHeight: 180, + }, + codeContent: { + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + }, + codeText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + lineHeight: 18, + }, + codeBlockLoading: { + paddingVertical: theme.spacing[4], + paddingHorizontal: theme.spacing[3], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + modelsSection: { + flex: 1, + minHeight: 0, + gap: theme.spacing[2], + }, + modelsHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + modelsHeaderMeta: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + }, + metaDot: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + countText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + searchContainer: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.md, + paddingHorizontal: theme.spacing[3], + }, + searchInput: { + flex: 1, + paddingVertical: theme.spacing[2], + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + modelsScroll: { + flex: 1, + minHeight: 0, + }, + modelsScrollContent: { + paddingBottom: theme.spacing[2], + }, + modelRow: { + paddingVertical: theme.spacing[3], + }, + modelRowBorder: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, + modelLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + modelId: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + fontFamily: Fonts.mono, + marginTop: 2, + }, + emptyState: { paddingVertical: theme.spacing[6], alignItems: "center", gap: theme.spacing[2], }, - loadingText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - scrollContainer: { - flex: 1, - }, - scrollContent: { - paddingBottom: theme.spacing[4], - }, - diagnosticText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - fontFamily: "monospace", - lineHeight: theme.fontSize.sm * 1.6, - }, })); diff --git a/packages/app/src/components/spinning-refresh-icon.tsx b/packages/app/src/components/spinning-refresh-icon.tsx new file mode 100644 index 000000000..9060ac0a5 --- /dev/null +++ b/packages/app/src/components/spinning-refresh-icon.tsx @@ -0,0 +1,67 @@ +import { useEffect } from "react"; +import { RefreshCw } from "lucide-react-native"; +import Animated, { + cancelAnimation, + Easing, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from "react-native-reanimated"; + +interface SpinningRefreshIconProps { + spinning: boolean; + size: number; + color: string; +} + +export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) { + const rotation = useSharedValue(0); + + useEffect(() => { + if (spinning) { + rotation.value = 0; + rotation.value = withRepeat( + withTiming(360, { + duration: 1000, + easing: Easing.linear, + }), + -1, + false, + ); + return; + } + + cancelAnimation(rotation); + const remainder = rotation.value % 360; + if (Math.abs(remainder) < 0.001) { + rotation.value = 0; + return; + } + + rotation.value = withTiming(360, { + duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)), + easing: Easing.linear, + }); + }, [rotation, spinning]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${rotation.value}deg` }], + })); + + return ( + + + + ); +} diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 75c94aa2e..fbc5614ee 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -6,6 +6,7 @@ import { Fonts } from "@/constants/theme"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; import { isWeb } from "@/constants/platform"; @@ -30,6 +31,7 @@ export function ToolCallDetailsContent({ showLoadingSkeleton = false, }: ToolCallDetailsContentProps) { const resolvedMaxHeight = fillAvailableHeight ? undefined : (maxHeight ?? 300); + const webScrollbarStyle = useWebScrollbarStyle(); // Compute diff lines for edit type const diffLines = useMemo(() => { @@ -65,6 +67,7 @@ export function ToolCallDetailsContent({ styles.codeVerticalScroll, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.codeVerticalContent} nestedScrollEnabled @@ -74,6 +77,7 @@ export function ToolCallDetailsContent({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator + style={webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -99,6 +103,7 @@ export function ToolCallDetailsContent({ styles.codeVerticalScroll, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.codeVerticalContent} nestedScrollEnabled @@ -108,6 +113,7 @@ export function ToolCallDetailsContent({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator + style={webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -137,6 +143,7 @@ export function ToolCallDetailsContent({ styles.codeVerticalScroll, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.codeVerticalContent} nestedScrollEnabled @@ -146,6 +153,7 @@ export function ToolCallDetailsContent({ horizontal nestedScrollEnabled showsHorizontalScrollIndicator + style={webScrollbarStyle} contentContainerStyle={styles.codeHorizontalContent} > @@ -181,12 +189,18 @@ export function ToolCallDetailsContent({ styles.scrollArea, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.scrollContent} nestedScrollEnabled showsVerticalScrollIndicator={true} > - + {detail.content} @@ -204,12 +218,18 @@ export function ToolCallDetailsContent({ styles.scrollArea, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.scrollContent} nestedScrollEnabled showsVerticalScrollIndicator={true} > - + {detail.content} @@ -236,12 +256,18 @@ export function ToolCallDetailsContent({ style={[ styles.scrollArea, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, + webScrollbarStyle, ]} contentContainerStyle={styles.scrollContent} nestedScrollEnabled showsVerticalScrollIndicator > - + {detail.content} @@ -286,12 +312,18 @@ export function ToolCallDetailsContent({ styles.scrollArea, resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight }, shouldFill && styles.fillHeight, + webScrollbarStyle, ]} contentContainerStyle={styles.scrollContent} nestedScrollEnabled showsVerticalScrollIndicator > - + {detail.result ? `${detail.url}\n\n${detail.result}` : detail.url} @@ -356,7 +388,7 @@ export function ToolCallDetailsContent({ @@ -378,7 +410,7 @@ export function ToolCallDetailsContent({ diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 12138f4d7..470184158 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -890,6 +890,7 @@ const styles = StyleSheet.create((theme) => ({ comboboxItemLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, + flexShrink: 0, }, comboboxItemDescription: { fontSize: theme.fontSize.xs, diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index a799ba9f4..26e9e527c 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -33,6 +33,7 @@ import { Check, CheckCircle } from "lucide-react-native"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { isWeb, isNative } from "@/constants/platform"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; // Keep parity with dropdown-menu action statuses. export type ActionStatus = "idle" | "pending" | "success"; @@ -348,6 +349,7 @@ export function ContextMenuContent({ testID?: string; }>): ReactElement | null { const context = useContextMenuContext("ContextMenuContent"); + const webScrollbarStyle = useWebScrollbarStyle(); const isMobile = useIsCompactFormFactor(); const useMobileSheet = isMobile && mobileMode === "sheet"; const { open, setOpen, triggerRef, anchorRect } = context; @@ -537,6 +539,7 @@ export function ContextMenuContent({ {children} diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 405d6714b..c11dbce7d 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -28,6 +28,7 @@ import Animated, { Keyframe, runOnJS } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Check, CheckCircle } from "lucide-react-native"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; // Action status for menu items with loading/success feedback export type ActionStatus = "idle" | "pending" | "success"; @@ -267,6 +268,7 @@ export function DropdownMenuContent({ }>): ReactElement | null { const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent"); const [modalVisible, setModalVisible] = useState(false); + const webScrollbarStyle = useWebScrollbarStyle(); const [closing, setClosing] = useState(false); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); @@ -411,6 +413,7 @@ export function DropdownMenuContent({ {children} diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 766b1f978..f23c4056e 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -82,9 +82,12 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor ); const requestDirectoryListing = useCallback( - async (path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }) => { + async ( + path: string, + options?: { recordHistory?: boolean; setCurrentPath?: boolean }, + ): Promise => { if (!workspaceStateKey) { - return; + return false; } const normalizedPath = path && path.length > 0 ? path : "."; const shouldSetCurrentPath = options?.setCurrentPath ?? true; @@ -113,7 +116,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor lastError: "Workspace is unavailable", pendingRequest: null, })); - return; + return false; } if (!client) { @@ -123,7 +126,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor lastError: "Host is not connected", pendingRequest: null, })); - return; + return false; } try { @@ -150,6 +153,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor return nextState; }); + return true; } catch (error) { updateExplorerState((state) => ({ ...state, @@ -157,6 +161,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor lastError: error instanceof Error ? error.message : "Failed to list directory", pendingRequest: null, })); + return false; } }, [client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey], diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 8f5ecece3..ac82e7649 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -26,6 +26,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { isNative } from "@/constants/platform"; +import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; export function useKeyboardShortcuts({ enabled, @@ -310,6 +311,12 @@ export function useKeyboardShortcuts({ return; } + // During IME composition, Enter confirms the candidate selection and must + // not route through global shortcuts like message send. + if (isImeComposingKeyboardEvent(event)) { + return; + } + const store = useKeyboardShortcutsStore.getState(); if (store.capturingShortcut) { return; diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 34f20bc2a..4f0982507 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AgentProvider, ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types"; import type { DaemonClient } from "@server/client/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useSessionForServer } from "./use-session-directory"; @@ -23,9 +23,10 @@ interface UseProvidersSnapshotResult { entries: ProviderSnapshotEntry[] | undefined; isLoading: boolean; isFetching: boolean; + isRefreshing: boolean; error: string | null; supportsSnapshot: boolean; - refresh: () => void; + refresh: (providers?: AgentProvider[]) => Promise; invalidate: () => void; } @@ -60,6 +61,16 @@ export function useProvidersSnapshot( }, }); + const refreshMutation = useMutation({ + mutationFn: async (providers?: AgentProvider[]) => { + if (!client) { + return; + } + await client.refreshProvidersSnapshot({ cwd: normalizedCwd, providers }); + }, + }); + const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation; + useEffect(() => { if (!supportsSnapshot || !client || !isConnected || !serverId) { return; @@ -81,12 +92,12 @@ export function useProvidersSnapshot( }); }, [client, isConnected, normalizedCwdKey, queryClient, queryKey, serverId, supportsSnapshot]); - const refresh = useCallback(() => { - if (!client) { - return; - } - void client.refreshProvidersSnapshot({ cwd: normalizedCwd }); - }, [client, normalizedCwd]); + const refresh = useCallback( + async (providers?: AgentProvider[]) => { + await refreshSnapshot(providers); + }, + [refreshSnapshot], + ); const invalidate = useCallback(() => { void queryClient.invalidateQueries({ queryKey }); @@ -96,6 +107,7 @@ export function useProvidersSnapshot( entries: snapshotQuery.data?.entries ?? undefined, isLoading: snapshotQuery.isLoading, isFetching: snapshotQuery.isFetching, + isRefreshing, error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null, supportsSnapshot, refresh, diff --git a/packages/app/src/hooks/use-web-scrollbar-style.d.ts b/packages/app/src/hooks/use-web-scrollbar-style.d.ts new file mode 100644 index 000000000..afafdb944 --- /dev/null +++ b/packages/app/src/hooks/use-web-scrollbar-style.d.ts @@ -0,0 +1 @@ +export * from "./use-web-scrollbar-style.web"; diff --git a/packages/app/src/hooks/use-web-scrollbar-style.native.ts b/packages/app/src/hooks/use-web-scrollbar-style.native.ts new file mode 100644 index 000000000..d2f336ed5 --- /dev/null +++ b/packages/app/src/hooks/use-web-scrollbar-style.native.ts @@ -0,0 +1,5 @@ +import type { ViewStyle } from "react-native"; + +export function useWebScrollbarStyle(): ViewStyle | undefined { + return undefined; +} diff --git a/packages/app/src/hooks/use-web-scrollbar-style.web.ts b/packages/app/src/hooks/use-web-scrollbar-style.web.ts new file mode 100644 index 000000000..38d22c910 --- /dev/null +++ b/packages/app/src/hooks/use-web-scrollbar-style.web.ts @@ -0,0 +1,21 @@ +import { useMemo } from "react"; +import type { ViewStyle } from "react-native"; +import { useUnistyles } from "react-native-unistyles"; + +// CSS scrollbar properties are supported by React Native Web at runtime +// but are not included in React Native's ViewStyle type definition. +interface WebScrollbarStyle extends ViewStyle { + scrollbarColor: string; + scrollbarWidth: string; +} + +export function useWebScrollbarStyle(): WebScrollbarStyle { + const { theme } = useUnistyles(); + return useMemo( + (): WebScrollbarStyle => ({ + scrollbarColor: `${theme.colors.scrollbarHandle} transparent`, + scrollbarWidth: "thin", + }), + [theme.colors.scrollbarHandle], + ); +} diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index baa7a3608..0b3e0e047 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -70,6 +70,7 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useIsCompactFormFactor } from "@/constants/layout"; import { getProviderIcon } from "@/components/provider-icons"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; +import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon"; import { StatusBadge } from "@/components/ui/status-badge"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { isWeb } from "@/constants/platform"; @@ -521,9 +522,11 @@ interface ProvidersSectionProps { function ProvidersSection({ routeServerId }: ProvidersSectionProps) { const { theme } = useUnistyles(); const isConnected = useHostRuntimeIsConnected(routeServerId); - const { entries, isLoading, isFetching, refresh } = useProvidersSnapshot(routeServerId); + const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(routeServerId); const [diagnosticProvider, setDiagnosticProvider] = useState(null); const providerDefinitions = buildProviderDefinitions(entries); + const providerRefreshInFlight = + isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false); const hasServer = routeServerId.length > 0; @@ -534,18 +537,25 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) { Providers {hasServer && isConnected ? ( { + void refresh(); + }} + disabled={providerRefreshInFlight} + hitSlop={8} + style={({ hovered, pressed }) => [ + settingsStyles.sectionHeaderLink, + styles.providerRefreshButton, + (hovered || pressed) && styles.providerRefreshButtonHovered, + providerRefreshInFlight ? styles.providerRefreshButtonDisabled : null, + ]} + accessibilityRole="button" + accessibilityLabel="Refresh providers" > - - Refresh - + ) : null} @@ -570,8 +580,15 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) { ? entry.error.trim() : null; + const modelCount = entry?.models?.length ?? 0; + return ( - + setDiagnosticProvider(def.id)} + accessibilityRole="button" + > ) : null} + {status === "ready" && modelCount > 0 ? ( + + {modelCount === 1 ? "1 model" : `${modelCount} models`} + + ) : null} - - - - - + + ); })} @@ -1952,6 +1965,18 @@ const styles = StyleSheet.create((theme) => ({ formButtonPrimaryText: { color: theme.colors.palette.white, }, + providerRefreshButton: { + width: 30, + height: 30, + borderRadius: theme.borderRadius.full, + justifyContent: "center", + }, + providerRefreshButtonHovered: { + backgroundColor: theme.colors.surface2, + }, + providerRefreshButtonDisabled: { + opacity: 0.5, + }, // Audio settings card audioCard: { overflow: "hidden", @@ -1980,11 +2005,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, marginTop: theme.spacing[1], }, - providerActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, aboutValue: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/screens/startup-splash-screen.tsx b/packages/app/src/screens/startup-splash-screen.tsx index 78a69f117..ad0aa105f 100644 --- a/packages/app/src/screens/startup-splash-screen.tsx +++ b/packages/app/src/screens/startup-splash-screen.tsx @@ -10,6 +10,7 @@ import { Fonts } from "@/constants/theme"; import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { isWeb } from "@/constants/platform"; +import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; type StartupSplashScreenProps = { bootstrapState?: { @@ -162,6 +163,7 @@ const styles = StyleSheet.create((theme) => ({ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps) { const { theme } = useUnistyles(); + const webScrollbarStyle = useWebScrollbarStyle(); const [daemonLogs, setDaemonLogs] = useState(null); const [logsError, setLogsError] = useState(null); const [isLoadingLogs, setIsLoadingLogs] = useState(false); @@ -283,7 +285,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps @@ -304,7 +306,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index bda1b2352..8463686f4 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -764,12 +764,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) }; }, [client, isConnected, queryClient, terminalsQueryKey, workspaceDirectory]); + const isCheckoutQueryEnabled = Boolean(client && isConnected) && Boolean(workspaceDirectory); const checkoutQuery = useQuery({ queryKey: checkoutStatusQueryKey( normalizedServerId, workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`, ), - enabled: Boolean(client && isConnected) && Boolean(workspaceDirectory), + enabled: isCheckoutQueryEnabled, queryFn: async () => { if (!client || !workspaceDirectory) { throw new Error("Host is not connected"); @@ -778,6 +779,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) }, staleTime: 15_000, }); + const isCheckoutStatusLoading = + isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError; const hasHydratedWorkspaces = useSessionStore( (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, ); @@ -811,7 +814,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const workspaceHeader = workspaceDescriptor ? resolveWorkspaceHeader({ workspace: workspaceDescriptor }) : null; - const isWorkspaceHeaderLoading = workspaceHeader === null; + const isWorkspaceHeaderLoading = workspaceHeader === null || isCheckoutStatusLoading; const workspaceHeaderTitle = workspaceHeader?.title ?? ""; const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? ""; const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent( diff --git a/packages/app/src/utils/keyboard-ime.test.ts b/packages/app/src/utils/keyboard-ime.test.ts new file mode 100644 index 000000000..e505f4b63 --- /dev/null +++ b/packages/app/src/utils/keyboard-ime.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { isImeComposingKeyboardEvent } from "./keyboard-ime"; + +describe("isImeComposingKeyboardEvent", () => { + it("ignores events while IME composition is active", () => { + expect( + isImeComposingKeyboardEvent({ + isComposing: true, + keyCode: 13, + } as KeyboardEvent), + ).toBe(true); + }); + + it("ignores Chromium IME fallback events with keyCode 229", () => { + expect( + isImeComposingKeyboardEvent({ + isComposing: false, + keyCode: 229, + } as KeyboardEvent), + ).toBe(true); + }); + + it("keeps regular keyboard events eligible for shortcuts", () => { + expect( + isImeComposingKeyboardEvent({ + isComposing: false, + keyCode: 13, + } as KeyboardEvent), + ).toBe(false); + }); +}); diff --git a/packages/app/src/utils/keyboard-ime.ts b/packages/app/src/utils/keyboard-ime.ts new file mode 100644 index 000000000..0e94262f0 --- /dev/null +++ b/packages/app/src/utils/keyboard-ime.ts @@ -0,0 +1,6 @@ +export function isImeComposingKeyboardEvent(event: { + isComposing?: boolean; + keyCode?: number; +}): boolean { + return Boolean(event.isComposing) || event.keyCode === 229; +} diff --git a/packages/cli/package.json b/packages/cli/package.json index bab89f940..f4056bfc0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.1.56", + "version": "0.1.57-rc.1", "description": "Paseo CLI - control your AI coding agents from the command line", "type": "module", "files": [ @@ -24,8 +24,8 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/relay": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/relay": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d251e2a9f..36843f596 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.1.56", + "version": "0.1.57-rc.1", "private": true, "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", @@ -13,8 +13,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@getpaseo/cli": "0.1.56", - "@getpaseo/server": "0.1.56", + "@getpaseo/cli": "0.1.57-rc.1", + "@getpaseo/server": "0.1.57-rc.1", "electron-log": "^5.4.3", "electron-updater": "^6.6.2", "ws": "^8.14.2" diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index 9181c3c11..a3b6a0ecc 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -13,6 +13,11 @@ npm run build:main EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port") export EXPO_PORT +# Allow any origin in dev so Electron on random localhost ports can reach +# the daemon websocket. Safe here because this script is development-only +# and the daemon still binds to localhost. +export PASEO_CORS_ORIGINS="*" + echo "══════════════════════════════════════════════════════" echo " Paseo Desktop Dev" echo "══════════════════════════════════════════════════════" diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index 454748fa5..0a7d355ec 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.1.56", + "version": "0.1.57-rc.1", "description": "Native module for two way audio streaming", "main": "build/index.js", "types": "build/index.d.ts", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 5803e4b3f..d3f4892ca 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.1.56", + "version": "0.1.57-rc.1", "type": "module", "publishConfig": { "access": "public" diff --git a/packages/relay/package.json b/packages/relay/package.json index d33a39760..00ca118e3 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.1.56", + "version": "0.1.57-rc.1", "description": "Paseo relay for bridging daemon and client connections", "type": "module", "publishConfig": { diff --git a/packages/server/package.json b/packages/server/package.json index 4c3ed9db9..2cbfbd024 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.1.56", + "version": "0.1.57-rc.1", "description": "Paseo backend server", "type": "module", "publishConfig": { @@ -60,8 +60,8 @@ "@ai-sdk/openai": "2.0.52", "@anthropic-ai/claude-agent-sdk": "^0.2.11", "@deepgram/sdk": "^3.4.0", - "@getpaseo/highlight": "0.1.56", - "@getpaseo/relay": "0.1.56", + "@getpaseo/highlight": "0.1.57-rc.1", + "@getpaseo/relay": "0.1.57-rc.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.2.6", diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 34eaffc3d..d786a92fa 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -2838,6 +2838,7 @@ export class DaemonClient { async refreshProvidersSnapshot(options?: { cwd?: string; + providers?: AgentProvider[]; requestId?: string; }): Promise { return this.sendCorrelatedSessionRequest({ @@ -2845,6 +2846,7 @@ export class DaemonClient { message: { type: "refresh_providers_snapshot_request", cwd: options?.cwd, + providers: options?.providers, }, responseType: "refresh_providers_snapshot_response", timeout: 5000, diff --git a/packages/server/src/server/agent-loading-service.ts b/packages/server/src/server/agent-loading-service.ts deleted file mode 100644 index f075850e2..000000000 --- a/packages/server/src/server/agent-loading-service.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type pino from "pino"; - -import type { ManagedAgent } from "./agent/agent-manager.js"; -import type { AgentManager } from "./agent/agent-manager.js"; -import type { AgentPersistenceHandle, AgentSessionConfig } from "./agent/agent-sdk-types.js"; -import type { AgentStorage } from "./agent/agent-storage.js"; -import { - buildConfigOverrides, - buildSessionConfig, - extractTimestamps, - toAgentPersistenceHandle, -} from "./persistence-hooks.js"; - -const pendingAgentBootstrapLoads = new Map>(); - -export type AgentLoadingServiceOptions = { - agentManager: Pick< - AgentManager, - | "createAgent" - | "getAgent" - | "hydrateTimelineFromProvider" - | "reloadAgentSession" - | "resumeAgentFromPersistence" - >; - agentStorage: Pick; - logger: pino.Logger; -}; - -// Coordinates cold loads, explicit resumes, and refreshes for persisted agents. -export class AgentLoadingService { - private readonly agentManager: AgentLoadingServiceOptions["agentManager"]; - private readonly agentStorage: AgentLoadingServiceOptions["agentStorage"]; - private readonly logger: pino.Logger; - - constructor(options: AgentLoadingServiceOptions) { - this.agentManager = options.agentManager; - this.agentStorage = options.agentStorage; - this.logger = options.logger.child({ component: "agent-loading" }); - } - - async ensureAgentLoaded(options: { agentId: string }): Promise { - const existing = this.agentManager.getAgent(options.agentId); - if (existing) { - return existing; - } - - const inflight = pendingAgentBootstrapLoads.get(options.agentId); - if (inflight) { - return inflight; - } - - const initPromise = this.loadStoredAgent(options); - pendingAgentBootstrapLoads.set(options.agentId, initPromise); - - try { - return await initPromise; - } finally { - pendingAgentBootstrapLoads.delete(options.agentId); - } - } - - async resumeAgent(options: { - handle: AgentPersistenceHandle; - overrides?: Partial; - }): Promise { - const snapshot = await this.agentManager.resumeAgentFromPersistence( - options.handle, - options.overrides, - ); - await this.agentManager.hydrateTimelineFromProvider(snapshot.id); - return snapshot; - } - - async refreshAgent(options: { agentId: string }): Promise { - const existing = this.agentManager.getAgent(options.agentId); - if (existing) { - if (!existing.persistence) { - return existing; - } - - const snapshot = await this.agentManager.reloadAgentSession(options.agentId); - await this.agentManager.hydrateTimelineFromProvider(snapshot.id); - return snapshot; - } - - const record = await this.agentStorage.get(options.agentId); - if (!record) { - throw new Error(`Agent not found: ${options.agentId}`); - } - - const handle = toAgentPersistenceHandle(this.logger, record.persistence); - if (!handle) { - throw new Error(`Agent ${options.agentId} cannot be refreshed because it lacks persistence`); - } - - const snapshot = await this.agentManager.resumeAgentFromPersistence( - handle, - buildConfigOverrides(record), - options.agentId, - extractTimestamps(record), - ); - await this.agentManager.hydrateTimelineFromProvider(snapshot.id); - return snapshot; - } - - private async loadStoredAgent(options: { agentId: string }): Promise { - const record = await this.agentStorage.get(options.agentId); - if (!record) { - throw new Error(`Agent not found: ${options.agentId}`); - } - - const handle = toAgentPersistenceHandle(this.logger, record.persistence); - let snapshot: ManagedAgent; - if (handle) { - snapshot = await this.agentManager.resumeAgentFromPersistence( - handle, - buildConfigOverrides(record), - options.agentId, - extractTimestamps(record), - ); - this.logger.info( - { agentId: options.agentId, provider: record.provider }, - "Agent resumed from persistence", - ); - } else { - const sessionConfig = buildSessionConfig(record); - if (!sessionConfig) { - throw new Error( - `Agent ${options.agentId} has an invalid provider '${record.provider}' and cannot be loaded`, - ); - } - snapshot = await this.agentManager.createAgent(sessionConfig, options.agentId, { - labels: record.labels, - }); - this.logger.info( - { agentId: options.agentId, provider: record.provider }, - "Agent created from stored config", - ); - } - - await this.agentManager.hydrateTimelineFromProvider(snapshot.id); - return snapshot; - } -} diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts new file mode 100644 index 000000000..01ae00630 --- /dev/null +++ b/packages/server/src/server/agent/agent-loading.ts @@ -0,0 +1,80 @@ +import type { Logger } from "pino"; + +import type { AgentProvider } from "./agent-sdk-types.js"; +import type { AgentManager, ManagedAgent } from "./agent-manager.js"; +import type { AgentStorage } from "./agent-storage.js"; +import { + buildConfigOverrides, + buildSessionConfig, + extractTimestamps, + toAgentPersistenceHandle, +} from "../persistence-hooks.js"; + +const pendingAgentInitializations = new Map>(); + +export interface EnsureAgentLoadedDeps { + agentManager: AgentManager; + agentStorage: AgentStorage; + validProviders?: Iterable; + logger: Logger; +} + +export async function ensureAgentLoaded( + agentId: string, + deps: EnsureAgentLoadedDeps, +): Promise { + const existing = deps.agentManager.getAgent(agentId); + if (existing) { + return existing; + } + + const inflight = pendingAgentInitializations.get(agentId); + if (inflight) { + return inflight; + } + + const initPromise = (async () => { + const record = await deps.agentStorage.get(agentId); + if (!record) { + throw new Error(`Agent not found: ${agentId}`); + } + + const validProviders = deps.validProviders ?? deps.agentManager.getRegisteredProviderIds(); + const handle = toAgentPersistenceHandle(deps.logger, validProviders, record.persistence); + + let snapshot: ManagedAgent; + if (handle) { + snapshot = await deps.agentManager.resumeAgentFromPersistence( + handle, + buildConfigOverrides(record), + agentId, + extractTimestamps(record), + ); + deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence"); + } else { + const config = buildSessionConfig(record, { + validProviders, + logger: deps.logger, + }); + if (!config) { + throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`); + } + snapshot = await deps.agentManager.createAgent(config, agentId, { labels: record.labels }); + deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); + } + + await deps.agentManager.hydrateTimelineFromProvider(agentId); + return deps.agentManager.getAgent(agentId) ?? snapshot; + })(); + + pendingAgentInitializations.set(agentId, initPromise); + + try { + return await initPromise; + } finally { + const current = pendingAgentInitializations.get(agentId); + if (current === initPromise) { + pendingAgentInitializations.delete(agentId); + } + } +} diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts deleted file mode 100644 index d58e52e79..000000000 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ /dev/null @@ -1,1031 +0,0 @@ -/** - * Agent Management MCP Server - * - * Purpose: Managing agents from the UI/voice assistant LLM - * Transport: In-memory (runs in-process with the voice assistant LLM) - * Server name: "paseo-agent-management" - * - * Tools: - * - create_agent - * - wait_for_agent - * - send_agent_prompt - * - get_agent_status - * - list_agents - * - cancel_agent - * - kill_agent - * - get_agent_activity - * - set_agent_mode - * - list_pending_permissions - * - respond_to_permission - * - * No callerAgentId needed - voice assistant is not an agent. - */ - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { ensureValidJson } from "../json-utils.js"; -import type { Logger } from "pino"; - -import type { AgentProvider } from "./agent-sdk-types.js"; -import type { AgentManager, WaitForAgentResult } from "./agent-manager.js"; -import { - AgentPermissionRequestPayloadSchema, - AgentPermissionResponseSchema, - AgentSnapshotPayloadSchema, -} from "../messages.js"; -import { toAgentPayload } from "./agent-projections.js"; -import { curateAgentActivity } from "./activity-curator.js"; -import type { AgentStorage } from "./agent-storage.js"; -import { - appendTimelineItemIfAgentKnown, - emitLiveTimelineItemIfAgentKnown, -} from "./timeline-append.js"; -import { type WorktreeConfig } from "../../utils/worktree.js"; -import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; -import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; -import { expandUserPath } from "../path-utils.js"; -import type { TerminalManager } from "../../terminal/terminal-manager.js"; -import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js"; -import type { ScheduleService } from "../schedule/service.js"; -import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js"; -import type { ProviderDefinition } from "./provider-registry.js"; -import { - AgentModelSchema, - AgentProviderEnum, - AgentStatusEnum, - ProviderSummarySchema, - parseDurationString, - resolveProviderAndModel, - sanitizePermissionRequest, - serializeSnapshotWithMetadata, - startAgentRun, - toScheduleSummary, - waitForAgentWithTimeout, -} from "./mcp-shared.js"; - -export interface AgentManagementMcpOptions { - agentManager: AgentManager; - agentStorage: AgentStorage; - terminalManager?: TerminalManager | null; - getDaemonTcpPort?: () => number | null; - scheduleService?: ScheduleService | null; - providerRegistry?: Record | null; - paseoHome?: string; - logger: Logger; -} - -export async function createAgentManagementMcpServer( - options: AgentManagementMcpOptions, -): Promise { - const { agentManager, agentStorage, scheduleService, providerRegistry, logger } = options; - const childLogger = logger.child({ - module: "agent", - component: "agent-management-mcp", - }); - const waitTracker = new WaitForAgentTracker(logger); - const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => { - const resolvedProviderModel = resolveProviderAndModel({ - provider: params?.provider, - defaultProvider: "claude", - }); - return { - type: "new-agent" as const, - config: { - provider: resolvedProviderModel.provider, - cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(), - ...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}), - }, - }; - }; - - const server = new McpServer({ - name: "paseo-agent-management", - version: "1.0.0", - }); - - const inputSchema = { - cwd: z - .string() - .describe("Required working directory for the agent (absolute, relative, or ~)."), - title: z - .string() - .trim() - .min(1, "Title is required") - .max(60, "Title must be 60 characters or fewer") - .describe("Short descriptive title (<= 60 chars) summarizing the agent's focus."), - provider: AgentProviderEnum.optional().describe( - "Optional agent implementation to spawn. Defaults to 'claude'.", - ), - model: z.string().optional().describe("Model to use (e.g. claude-sonnet-4-20250514)"), - thinking: z.string().optional().describe("Thinking option ID"), - labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), - initialPrompt: z - .string() - .optional() - .describe("Optional task to start immediately after creation (non-blocking)."), - mode: z - .string() - .optional() - .describe("Optional session mode to configure before the first run."), - worktreeName: z - .string() - .optional() - .describe("Optional git worktree branch name (lowercase alphanumerics + hyphen)."), - baseBranch: z - .string() - .optional() - .describe("Required when worktreeName is set: the base branch to diff/merge against."), - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), - }; - - server.registerTool( - "create_agent", - { - title: "Create agent", - description: - "Create a new Claude or Codex agent tied to a working directory. Optionally run an initial prompt immediately or create a git worktree for the agent.", - inputSchema, - outputSchema: { - agentId: z.string(), - type: AgentProviderEnum, - status: AgentStatusEnum, - cwd: z.string(), - currentModeId: z.string().nullable(), - availableModes: z.array( - z.object({ - id: z.string(), - label: z.string(), - description: z.string().nullable().optional(), - }), - ), - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), - }, - }, - async (args) => { - const { - cwd, - provider, - initialPrompt, - mode, - worktreeName, - baseBranch, - background = false, - title, - model, - thinking, - labels, - } = args as { - cwd: string; - provider?: AgentProvider; - initialPrompt?: string; - mode?: string; - worktreeName?: string; - baseBranch?: string; - background?: boolean; - title: string; - model?: string; - thinking?: string; - labels?: Record; - }; - - let resolvedCwd = expandUserPath(cwd); - let worktreeBootstrap: - | { - worktree: WorktreeConfig; - shouldBootstrap: boolean; - } - | undefined; - - if (worktreeName) { - if (!baseBranch) { - throw new Error("baseBranch is required when creating a worktree"); - } - worktreeBootstrap = await createAgentWorktree({ - branchName: worktreeName, - cwd: resolvedCwd, - baseBranch, - worktreeSlug: worktreeName, - paseoHome: options.paseoHome, - }); - resolvedCwd = worktreeBootstrap.worktree.worktreePath; - } - - const resolvedProvider: AgentProvider = provider ?? "claude"; - const normalizedTitle = title?.trim() ?? null; - const snapshot = await agentManager.createAgent( - { - provider: resolvedProvider, - cwd: resolvedCwd, - modeId: mode, - title: normalizedTitle ?? undefined, - model, - thinkingOptionId: thinking, - }, - undefined, - labels ? { labels } : undefined, - ); - - if (worktreeBootstrap) { - void runAsyncWorktreeBootstrap({ - agentId: snapshot.id, - worktree: worktreeBootstrap.worktree, - shouldBootstrap: worktreeBootstrap.shouldBootstrap, - terminalManager: options.terminalManager ?? null, - appendTimelineItem: (item) => - appendTimelineItemIfAgentKnown({ - agentManager, - agentId: snapshot.id, - item, - }), - emitLiveTimelineItem: (item) => - emitLiveTimelineItemIfAgentKnown({ - agentManager, - agentId: snapshot.id, - item, - }), - logger: childLogger, - }); - } - - const trimmedPrompt = initialPrompt?.trim(); - if (trimmedPrompt) { - scheduleAgentMetadataGeneration({ - agentManager, - agentId: snapshot.id, - cwd: snapshot.cwd, - initialPrompt: trimmedPrompt, - explicitTitle: normalizedTitle ?? undefined, - paseoHome: options.paseoHome, - logger: childLogger, - }); - - try { - agentManager.recordUserMessage(snapshot.id, trimmedPrompt, { - emitState: false, - }); - } catch (error) { - childLogger.error( - { err: error, agentId: snapshot.id }, - "Failed to record initial prompt", - ); - } - - try { - startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger); - - if (!background) { - const result = await waitForAgentWithTimeout(agentManager, snapshot.id, { - waitForActive: true, - }); - - const responseData = { - agentId: snapshot.id, - type: provider, - status: result.status, - cwd: snapshot.cwd, - currentModeId: snapshot.currentModeId, - availableModes: snapshot.availableModes, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - }; - const validJson = ensureValidJson(responseData); - - return { - content: [], - structuredContent: validJson, - }; - } - } catch (error) { - childLogger.error({ err: error, agentId: snapshot.id }, "Failed to run initial prompt"); - } - } - - return { - content: [], - structuredContent: ensureValidJson({ - agentId: snapshot.id, - type: provider, - status: snapshot.lifecycle, - cwd: snapshot.cwd, - currentModeId: snapshot.currentModeId, - availableModes: snapshot.availableModes, - lastMessage: null, - permission: null, - }), - }; - }, - ); - - server.registerTool( - "wait_for_agent", - { - title: "Wait for agent", - description: - "Block until the agent requests permission or the current run completes. Returns the pending permission (if any) and recent activity summary.", - inputSchema: { - agentId: z.string().describe("Agent identifier returned by the create_agent tool"), - }, - outputSchema: { - agentId: z.string(), - status: AgentStatusEnum, - permission: AgentPermissionRequestPayloadSchema.nullable(), - lastMessage: z.string().nullable(), - }, - }, - async ({ agentId }, { signal }) => { - const abortController = new AbortController(); - const cleanupFns: Array<() => void> = []; - - const cleanup = () => { - while (cleanupFns.length) { - const fn = cleanupFns.pop(); - try { - fn?.(); - } catch { - // ignore cleanup errors - } - } - }; - - const forwardExternalAbort = () => { - if (!abortController.signal.aborted) { - const reason = signal?.reason ?? new Error("wait_for_agent aborted"); - abortController.abort(reason); - } - }; - - if (signal) { - if (signal.aborted) { - forwardExternalAbort(); - } else { - signal.addEventListener("abort", forwardExternalAbort, { - once: true, - }); - cleanupFns.push(() => signal.removeEventListener("abort", forwardExternalAbort)); - } - } - - const unregister = waitTracker.register(agentId, (reason) => { - if (!abortController.signal.aborted) { - abortController.abort(new Error(reason ?? "wait_for_agent cancelled")); - } - }); - cleanupFns.push(unregister); - - try { - const result: WaitForAgentResult = await waitForAgentWithTimeout(agentManager, agentId, { - signal: abortController.signal, - }); - - const validJson = ensureValidJson({ - agentId, - status: result.status, - permission: sanitizePermissionRequest(result.permission), - lastMessage: result.lastMessage, - }); - - return { - content: [], - structuredContent: validJson, - }; - } finally { - cleanup(); - } - }, - ); - - server.registerTool( - "send_agent_prompt", - { - title: "Send agent prompt", - description: - "Send a task to a running agent. Returns immediately after the agent begins processing.", - inputSchema: { - agentId: z.string(), - prompt: z.string(), - sessionMode: z - .string() - .optional() - .describe("Optional mode to set before running the prompt."), - background: z - .boolean() - .optional() - .default(false) - .describe( - "Run agent in background. If false (default), waits for completion or permission request. If true, returns immediately.", - ), - }, - outputSchema: { - success: z.boolean(), - status: AgentStatusEnum, - lastMessage: z.string().nullable().optional(), - permission: AgentPermissionRequestPayloadSchema.nullable().optional(), - }, - }, - async ({ agentId, prompt, sessionMode, background = false }) => { - const snapshot = agentManager.getAgent(agentId); - if (!snapshot) { - throw new Error(`Agent ${agentId} not found`); - } - - if (agentManager.hasInFlightRun(agentId)) { - waitTracker.cancel(agentId, "Agent run interrupted by new prompt"); - } - - if (sessionMode) { - await agentManager.setAgentMode(agentId, sessionMode); - } - - try { - agentManager.recordUserMessage(agentId, prompt, { - emitState: false, - }); - } catch (error) { - childLogger.error({ err: error, agentId }, "Failed to record user message"); - } - - startAgentRun(agentManager, agentId, prompt, childLogger, { - replaceRunning: true, - }); - - if (!background) { - const result = await waitForAgentWithTimeout(agentManager, agentId, { - waitForActive: true, - }); - - const responseData = { - success: true, - status: result.status, - lastMessage: result.lastMessage, - permission: sanitizePermissionRequest(result.permission), - }; - const validJson = ensureValidJson(responseData); - - return { - content: [], - structuredContent: validJson, - }; - } - - const currentSnapshot = agentManager.getAgent(agentId); - - const responseData = { - success: true, - status: currentSnapshot?.lifecycle ?? "idle", - lastMessage: null, - permission: null, - }; - const validJson = ensureValidJson(responseData); - - return { - content: [], - structuredContent: validJson, - }; - }, - ); - - server.registerTool( - "get_agent_status", - { - title: "Get agent status", - description: - "Return the latest snapshot for an agent, including lifecycle state, capabilities, and pending permissions.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - status: AgentStatusEnum, - snapshot: AgentSnapshotPayloadSchema, - }, - }, - async ({ agentId }) => { - const snapshot = agentManager.getAgent(agentId); - if (!snapshot) { - throw new Error(`Agent ${agentId} not found`); - } - - const structuredSnapshot = await serializeSnapshotWithMetadata( - agentStorage, - snapshot, - childLogger, - ); - return { - content: [], - structuredContent: ensureValidJson({ - status: snapshot.lifecycle, - snapshot: structuredSnapshot, - }), - }; - }, - ); - - server.registerTool( - "list_agents", - { - title: "List agents", - description: "List all live agents managed by the server.", - inputSchema: {}, - outputSchema: { - agents: z.array(AgentSnapshotPayloadSchema), - }, - }, - async () => { - const snapshots = agentManager.listAgents(); - const agents = await Promise.all( - snapshots.map((snapshot) => - serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger), - ), - ); - return { - content: [], - structuredContent: ensureValidJson({ agents }), - }; - }, - ); - - server.registerTool( - "cancel_agent", - { - title: "Cancel agent run", - description: "Abort the agent's current run but keep the agent alive for future tasks.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId }) => { - const success = await agentManager.cancelAgentRun(agentId); - if (success) { - waitTracker.cancel(agentId, "Agent run cancelled"); - } - return { - content: [], - structuredContent: ensureValidJson({ success }), - }; - }, - ); - - server.registerTool( - "archive_agent", - { - title: "Archive agent", - description: - "Archive an agent (soft-delete). The agent is interrupted if running and removed from the active list.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId }) => { - await agentManager.archiveAgent(agentId); - waitTracker.cancel(agentId, "Agent archived"); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "kill_agent", - { - title: "Kill agent", - description: "Terminate an agent session permanently.", - inputSchema: { - agentId: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId }) => { - await agentManager.closeAgent(agentId); - waitTracker.cancel(agentId, "Agent terminated"); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "update_agent", - { - title: "Update agent", - description: "Update an agent name and/or labels.", - inputSchema: { - agentId: z.string(), - name: z.string().optional(), - labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId, name, labels }) => { - const trimmedName = name?.trim(); - if (trimmedName) { - const record = await agentStorage.get(agentId); - if (!record) { - throw new Error(`Agent ${agentId} not found`); - } - await agentStorage.upsert({ - ...record, - title: trimmedName, - updatedAt: new Date().toISOString(), - }); - agentManager.notifyAgentState(agentId); - } - - if (labels) { - await agentManager.setLabels(agentId, labels); - } - - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "create_schedule", - { - title: "Create schedule", - description: "Create a recurring schedule that runs on an agent or a new agent.", - inputSchema: { - prompt: z.string().trim().min(1, "prompt is required"), - every: z.string().optional(), - cron: z.string().optional(), - name: z.string().optional(), - target: z.enum(["self", "new-agent"]).optional(), - provider: AgentProviderEnum.optional().describe( - "Provider, or provider/model (for example: codex or codex/gpt-5.4).", - ), - cwd: z.string().optional(), - maxRuns: z.number().int().positive().optional(), - expiresIn: z.string().optional(), - }, - outputSchema: ScheduleSummarySchema.shape, - }, - async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - const cadenceCount = Number(every !== undefined) + Number(cron !== undefined); - if (cadenceCount !== 1) { - throw new Error("Specify exactly one of every or cron"); - } - if (target === "self") { - throw new Error("target=self requires a caller agent"); - } - - const schedule = await scheduleService.create({ - prompt: prompt.trim(), - cadence: every - ? { type: "every" as const, everyMs: parseDurationString(every) } - : { type: "cron" as const, expression: cron!.trim() }, - target: resolveNewAgentScheduleTarget({ provider, cwd }), - ...(name?.trim() ? { name: name.trim() } : {}), - ...(maxRuns === undefined ? {} : { maxRuns }), - ...(expiresIn === undefined - ? {} - : { expiresAt: new Date(Date.now() + parseDurationString(expiresIn)).toISOString() }), - }); - - return { - content: [], - structuredContent: ensureValidJson(toScheduleSummary(schedule)), - }; - }, - ); - - server.registerTool( - "list_schedules", - { - title: "List schedules", - description: "List all schedules managed by the daemon.", - inputSchema: {}, - outputSchema: { - schedules: z.array(ScheduleSummarySchema), - }, - }, - async () => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - const schedules = (await scheduleService.list()).map((schedule) => - toScheduleSummary(schedule), - ); - return { - content: [], - structuredContent: ensureValidJson({ schedules }), - }; - }, - ); - - server.registerTool( - "inspect_schedule", - { - title: "Inspect schedule", - description: "Inspect a schedule and its run history.", - inputSchema: { - id: z.string(), - }, - outputSchema: StoredScheduleSchema.shape, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - const schedule = await scheduleService.inspect(id); - return { - content: [], - structuredContent: ensureValidJson(schedule), - }; - }, - ); - - server.registerTool( - "pause_schedule", - { - title: "Pause schedule", - description: "Pause an active schedule.", - inputSchema: { - id: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - await scheduleService.pause(id); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "resume_schedule", - { - title: "Resume schedule", - description: "Resume a paused schedule.", - inputSchema: { - id: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - await scheduleService.resume(id); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "delete_schedule", - { - title: "Delete schedule", - description: "Delete a schedule permanently.", - inputSchema: { - id: z.string(), - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ id }) => { - if (!scheduleService) { - throw new Error("Schedule service is not configured"); - } - - await scheduleService.delete(id); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - server.registerTool( - "list_providers", - { - title: "List providers", - description: "List available agent providers and their modes.", - inputSchema: {}, - outputSchema: { - providers: z.array(ProviderSummarySchema), - }, - }, - async () => ({ - content: [], - structuredContent: ensureValidJson({ - providers: Object.values(providerRegistry ?? {}).map((provider) => ({ - id: provider.id, - label: provider.label, - modes: provider.modes.map((mode) => ({ - id: mode.id, - label: mode.label, - ...(mode.description ? { description: mode.description } : {}), - })), - })), - }), - }), - ); - - server.registerTool( - "list_models", - { - title: "List models", - description: "List models for an agent provider.", - inputSchema: { - provider: AgentProviderEnum, - }, - outputSchema: { - provider: z.string(), - models: z.array(AgentModelSchema), - }, - }, - async ({ provider }) => { - if (!providerRegistry) { - throw new Error("Provider registry is not configured"); - } - - const definition = providerRegistry[provider]; - if (!definition) { - throw new Error(`Provider ${provider} is not configured`); - } - - const models = await definition.fetchModels(); - return { - content: [], - structuredContent: ensureValidJson({ - provider, - models, - }), - }; - }, - ); - - server.registerTool( - "get_agent_activity", - { - title: "Get agent activity", - description: "Return recent agent timeline entries as a curated summary.", - inputSchema: { - agentId: z.string(), - limit: z - .number() - .optional() - .describe("Optional limit for number of activities to include (most recent first)."), - }, - outputSchema: { - agentId: z.string(), - updateCount: z.number(), - currentModeId: z.string().nullable(), - content: z.string(), - }, - }, - async ({ agentId, limit }) => { - const timeline = agentManager.getTimeline(agentId); - const snapshot = agentManager.getAgent(agentId); - - const activitiesToCurate = limit ? timeline.slice(-limit) : timeline; - - const curatedContent = curateAgentActivity(activitiesToCurate); - const totalCount = timeline.length; - const shownCount = activitiesToCurate.length; - - let countHeader: string; - if (limit && shownCount < totalCount) { - countHeader = `Showing ${shownCount} of ${totalCount} ${totalCount === 1 ? "activity" : "activities"} (limited to ${limit})`; - } else { - countHeader = `Showing all ${totalCount} ${totalCount === 1 ? "activity" : "activities"}`; - } - - const contentWithCount = `${countHeader}\n\n${curatedContent}`; - - return { - content: [], - structuredContent: ensureValidJson({ - agentId, - updateCount: timeline.length, - currentModeId: snapshot?.currentModeId ?? null, - content: contentWithCount, - }), - }; - }, - ); - - server.registerTool( - "set_agent_mode", - { - title: "Set agent session mode", - description: - "Switch the agent's session mode (plan, bypassPermissions, read-only, auto, etc.).", - inputSchema: { - agentId: z.string(), - modeId: z.string(), - }, - outputSchema: { - success: z.boolean(), - newMode: z.string(), - }, - }, - async ({ agentId, modeId }) => { - await agentManager.setAgentMode(agentId, modeId); - return { - content: [], - structuredContent: ensureValidJson({ success: true, newMode: modeId }), - }; - }, - ); - - server.registerTool( - "list_pending_permissions", - { - title: "List pending permissions", - description: - "Return all pending permission requests across all agents with the normalized payloads.", - inputSchema: {}, - outputSchema: { - permissions: z.array( - z.object({ - agentId: z.string(), - status: AgentStatusEnum, - request: AgentPermissionRequestPayloadSchema, - }), - ), - }, - }, - async () => { - const permissions = agentManager.listAgents().flatMap((agent) => { - const payload = toAgentPayload(agent); - return payload.pendingPermissions.map((request) => ({ - agentId: agent.id, - status: payload.status, - request, - })); - }); - - return { - content: [], - structuredContent: ensureValidJson({ permissions }), - }; - }, - ); - - server.registerTool( - "respond_to_permission", - { - title: "Respond to permission", - description: - "Approve or deny a pending permission request with an AgentManager-compatible response payload.", - inputSchema: { - agentId: z.string(), - requestId: z.string(), - response: AgentPermissionResponseSchema, - }, - outputSchema: { - success: z.boolean(), - }, - }, - async ({ agentId, requestId, response }) => { - await agentManager.respondToPermission(agentId, requestId, response); - return { - content: [], - structuredContent: ensureValidJson({ success: true }), - }; - }, - ); - - return server; -} diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 12ef789e7..e463c7662 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -13,6 +13,9 @@ import type { } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { JsonValue } from "../json-utils.js"; +import type { Logger } from "pino"; +import { buildProviderRegistry } from "./provider-registry.js"; +import { coerceAgentProvider, toAgentPersistenceHandle } from "../persistence-hooks.js"; export type { ManagedAgent }; @@ -129,6 +132,95 @@ export function toAgentPayload( return payload; } +export function buildStoredAgentPayload( + record: StoredAgentRecord, + providerRegistry: ReturnType, + logger: Logger, +): AgentSnapshotPayload { + const defaultCapabilities = { + supportsStreaming: false, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + } as const; + + const createdAt = new Date(record.createdAt); + const updatedAt = new Date(resolveStoredAgentPayloadUpdatedAt(record)); + const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null; + + const provider = coerceAgentProvider(logger, providerRegistry, record.provider, record.id); + const runtimeInfo = record.runtimeInfo + ? { + provider: coerceAgentProvider( + logger, + providerRegistry, + record.runtimeInfo.provider, + record.id, + ), + sessionId: record.runtimeInfo.sessionId, + ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model") + ? { model: record.runtimeInfo.model ?? null } + : {}), + ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "thinkingOptionId") + ? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null } + : {}), + ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "modeId") + ? { modeId: record.runtimeInfo.modeId ?? null } + : {}), + ...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}), + } + : undefined; + + return { + id: record.id, + provider, + cwd: record.cwd, + model: record.config?.model ?? null, + thinkingOptionId: record.config?.thinkingOptionId ?? null, + effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({ + runtimeInfo, + configuredThinkingOptionId: record.config?.thinkingOptionId ?? null, + }), + ...(runtimeInfo ? { runtimeInfo } : {}), + createdAt: createdAt.toISOString(), + updatedAt: updatedAt.toISOString(), + lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null, + status: record.lastStatus, + capabilities: defaultCapabilities, + currentModeId: record.lastModeId ?? null, + availableModes: [], + pendingPermissions: [], + persistence: toAgentPersistenceHandle(logger, providerRegistry, record.persistence), + lastUsage: undefined, + lastError: undefined, + title: record.title ?? record.config?.title ?? null, + requiresAttention: record.requiresAttention ?? false, + attentionReason: record.attentionReason ?? null, + attentionTimestamp: record.attentionTimestamp ?? null, + archivedAt: record.archivedAt ?? null, + labels: record.labels, + }; +} + +export function resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string { + const timestamps = [record.updatedAt, record.lastActivityAt] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .map((value) => ({ + raw: value, + parsed: Date.parse(value), + })) + .filter((value) => !Number.isNaN(value.parsed)); + + if (timestamps.length === 0) { + return record.updatedAt; + } + + timestamps.sort((a, b) => b.parsed - a.parsed); + return timestamps[0].raw; +} + function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null { const serializable: SerializableAgentConfig = {}; if (Object.prototype.hasOwnProperty.call(config, "title")) { diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index f2394be1b..007068afe 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { createAgentMcpServer } from "./mcp-server.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; -import type { AgentStorage } from "./agent-storage.js"; +import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { ProviderDefinition } from "./provider-registry.js"; type TestDeps = { @@ -29,10 +29,17 @@ function createTestDeps(): TestDeps { archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }), notifyAgentState: vi.fn(), getAgent: vi.fn(), + listAgents: vi.fn().mockReturnValue([]), + getTimeline: vi.fn().mockReturnValue([]), + resumeAgentFromPersistence: vi.fn(), + hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined), + hasInFlightRun: vi.fn().mockReturnValue(false), + subscribe: vi.fn().mockReturnValue(() => {}), streamAgent: vi.fn(() => (async function* noop() {})()), respondToPermission: vi.fn(), cancelAgentRun: vi.fn(), getPendingPermissions: vi.fn(), + getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]), }; const agentStorageSpies = { @@ -40,7 +47,7 @@ function createTestDeps(): TestDeps { setTitle: vi.fn().mockResolvedValue(undefined), upsert: vi.fn().mockResolvedValue(undefined), applySnapshot: vi.fn(), - list: vi.fn(), + list: vi.fn().mockResolvedValue([]), remove: vi.fn(), }; @@ -68,6 +75,43 @@ function createProviderDefinition(overrides: Partial): Provi }; } +function createStoredRecord(overrides: Partial = {}): StoredAgentRecord { + const now = "2026-04-11T00:00:00.000Z"; + return { + id: "stored-agent", + provider: "claude", + cwd: "/tmp/stored-project", + createdAt: now, + updatedAt: now, + lastActivityAt: now, + lastUserMessageAt: null, + title: "Stored agent", + labels: {}, + lastStatus: "closed", + lastModeId: "default", + config: { + modeId: "default", + model: "claude-sonnet-4-20250514", + }, + runtimeInfo: { + provider: "claude", + sessionId: "session-123", + model: "claude-sonnet-4-20250514", + }, + features: [], + persistence: { + provider: "claude", + sessionId: "session-123", + }, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + internal: false, + archivedAt: "2026-04-12T00:00:00.000Z", + ...overrides, + }; +} + describe("create_agent MCP tool", () => { const logger = createTestLogger(); const existingCwd = process.cwd(); @@ -483,4 +527,223 @@ describe("agent snapshot MCP serialization", () => { }); expect(Array.isArray(structured.agents[0].features)).toBe(true); }); + + it("returns archived agent snapshots from storage for get_agent_status", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const record = createStoredRecord({ + id: "archived-agent", + archivedAt: "2026-04-12T00:00:00.000Z", + }); + spies.agentManager.getAgent.mockReturnValue(null); + spies.agentStorage.get.mockResolvedValue(record); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["get_agent_status"]; + const response = await tool.callback({ agentId: "archived-agent" }); + + expect(response.structuredContent).toEqual({ + status: "closed", + snapshot: expect.objectContaining({ + id: "archived-agent", + archivedAt: "2026-04-12T00:00:00.000Z", + title: "Stored agent", + status: "closed", + }), + }); + expect(spies.agentStorage.get).toHaveBeenCalledWith("archived-agent"); + }); + + it("does not expose internal stored agents from get_agent_status", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + spies.agentManager.getAgent.mockReturnValue(null); + spies.agentStorage.get.mockResolvedValue( + createStoredRecord({ + id: "internal-agent", + internal: true, + }), + ); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["get_agent_status"]; + + await expect(tool.callback({ agentId: "internal-agent" })).rejects.toThrow( + "Agent internal-agent not found", + ); + }); + + it("includes stored non-archived agents in list_agents by default", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const liveAgent = { + id: "live-agent", + provider: "claude", + cwd: "/tmp/live-project", + config: {}, + runtimeInfo: undefined, + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + lastUserMessageAt: null, + lifecycle: "idle", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: true, + supportsReasoningStream: false, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + features: [], + pendingPermissions: new Map(), + persistence: null, + labels: {}, + attention: { requiresAttention: false }, + } as unknown as ManagedAgent; + spies.agentManager.listAgents.mockReturnValue([liveAgent]); + spies.agentStorage.list.mockResolvedValue([ + createStoredRecord({ id: "closed-agent", archivedAt: null }), + createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }), + createStoredRecord({ id: "live-agent", archivedAt: null }), + createStoredRecord({ id: "internal-agent", archivedAt: null, internal: true }), + ]); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["list_agents"]; + const response = await tool.callback({}); + + expect(response.structuredContent.agents).toEqual([ + expect.objectContaining({ id: "live-agent" }), + expect.objectContaining({ id: "closed-agent", archivedAt: null }), + ]); + }); + + it("includes archived stored agents in list_agents when requested", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const liveAgent = { + id: "live-agent", + provider: "claude", + cwd: "/tmp/live-project", + config: {}, + runtimeInfo: undefined, + createdAt: new Date("2026-04-11T00:00:00.000Z"), + updatedAt: new Date("2026-04-11T00:00:00.000Z"), + lastUserMessageAt: null, + lifecycle: "idle", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: true, + supportsReasoningStream: false, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + features: [], + pendingPermissions: new Map(), + persistence: null, + labels: {}, + attention: { requiresAttention: false }, + } as unknown as ManagedAgent; + spies.agentManager.listAgents.mockReturnValue([liveAgent]); + spies.agentStorage.list.mockResolvedValue([ + createStoredRecord({ id: "archived-agent", archivedAt: "2026-04-12T00:00:00.000Z" }), + createStoredRecord({ id: "live-agent", archivedAt: "2026-04-12T00:00:00.000Z" }), + createStoredRecord({ + id: "internal-archived-agent", + archivedAt: "2026-04-12T00:00:00.000Z", + internal: true, + }), + createStoredRecord({ id: "not-archived-agent", archivedAt: null }), + ]); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["list_agents"]; + const response = await tool.callback({ includeArchived: true }); + + expect(response.structuredContent.agents).toEqual([ + expect.objectContaining({ id: "live-agent" }), + expect.objectContaining({ + id: "archived-agent", + archivedAt: "2026-04-12T00:00:00.000Z", + }), + expect.objectContaining({ + id: "not-archived-agent", + archivedAt: null, + }), + ]); + }); + + it("loads archived agents before reading get_agent_activity", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const record = createStoredRecord({ id: "archived-activity-agent" }); + const snapshot = { + id: "archived-activity-agent", + currentModeId: "default", + } as ManagedAgent; + spies.agentManager.getAgent + .mockReturnValueOnce(null) + .mockReturnValue(snapshot) + .mockReturnValue(snapshot); + spies.agentStorage.get.mockResolvedValue(record); + spies.agentManager.resumeAgentFromPersistence.mockResolvedValue(snapshot); + spies.agentManager.getTimeline.mockReturnValue([ + { + kind: "status", + timestamp: "2026-04-11T00:00:00.000Z", + text: "Agent resumed", + }, + ]); + + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + logger, + providerRegistry: { + claude: createProviderDefinition({}), + } as any, + }); + const tool = (server as any)._registeredTools["get_agent_activity"]; + const response = await tool.callback({ agentId: "archived-activity-agent" }); + + expect(response.structuredContent).toEqual( + expect.objectContaining({ + agentId: "archived-activity-agent", + updateCount: 1, + currentModeId: "default", + }), + ); + expect(spies.agentManager.resumeAgentFromPersistence).toHaveBeenCalled(); + expect(spies.agentManager.hydrateTimelineFromProvider).toHaveBeenCalledWith( + "archived-activity-agent", + ); + }); }); diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 791f50cc0..ecdaedaba 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -12,9 +12,10 @@ import { AgentPermissionResponseSchema, AgentSnapshotPayloadSchema, } from "../messages.js"; -import { toAgentPayload } from "./agent-projections.js"; +import { buildStoredAgentPayload, toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; import type { AgentStorage } from "./agent-storage.js"; +import { ensureAgentLoaded } from "./agent-loading.js"; import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, @@ -199,6 +200,13 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom version: "2.0.0", }); + const requireProviderRegistry = (): Record => { + if (!providerRegistry) { + throw new Error("Provider registry is required to load stored agent records"); + } + return providerRegistry; + }; + const resolveCallerAgent = () => { if (!callerAgentId) { return null; @@ -598,6 +606,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom if (notifyOnFinish && callerAgentId) { setupFinishNotification({ agentManager, + agentStorage, childAgentId: snapshot.id, callerAgentId, logger: childLogger, @@ -790,6 +799,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom if (notifyOnFinish && callerAgentId) { setupFinishNotification({ agentManager, + agentStorage, childAgentId: agentId, callerAgentId, logger: childLogger, @@ -853,19 +863,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, async ({ agentId }) => { const snapshot = agentManager.getAgent(agentId); - if (!snapshot) { + if (snapshot) { + const structuredSnapshot = await serializeSnapshotWithMetadata( + agentStorage, + snapshot, + childLogger, + ); + return { + content: [], + structuredContent: ensureValidJson({ + status: snapshot.lifecycle, + snapshot: structuredSnapshot, + }), + }; + } + + const record = await agentStorage.get(agentId); + if (!record || record.internal) { throw new Error(`Agent ${agentId} not found`); } - const structuredSnapshot = await serializeSnapshotWithMetadata( - agentStorage, - snapshot, + const structuredSnapshot = buildStoredAgentPayload( + record, + requireProviderRegistry(), childLogger, ); return { content: [], structuredContent: ensureValidJson({ - status: snapshot.lifecycle, + status: structuredSnapshot.status, snapshot: structuredSnapshot, }), }; @@ -877,21 +903,30 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom { title: "List agents", description: "List all live agents managed by the server.", - inputSchema: {}, + inputSchema: { + includeArchived: z.boolean().optional().default(false), + }, outputSchema: { agents: z.array(AgentSnapshotPayloadSchema), }, }, - async () => { - const snapshots = agentManager.listAgents(); - const agents = await Promise.all( - snapshots.map((snapshot) => + async ({ includeArchived }) => { + const liveSnapshots = agentManager.listAgents(); + const liveAgents = await Promise.all( + liveSnapshots.map((snapshot) => serializeSnapshotWithMetadata(agentStorage, snapshot, childLogger), ), ); + const liveIds = new Set(liveSnapshots.map((snapshot) => snapshot.id)); + const storedRecords = await agentStorage.list(); + const storedAgents = storedRecords + .filter((record) => !record.internal && !liveIds.has(record.id)) + .filter((record) => includeArchived || !record.archivedAt) + .map((record) => buildStoredAgentPayload(record, requireProviderRegistry(), childLogger)); + return { content: [], - structuredContent: ensureValidJson({ agents }), + structuredContent: ensureValidJson({ agents: [...liveAgents, ...storedAgents] }), }; }, ); @@ -1566,6 +1601,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async ({ agentId, limit }) => { + await ensureAgentLoaded(agentId, { + agentManager, + agentStorage, + logger: childLogger, + }); const timeline = agentManager.getTimeline(agentId); const snapshot = agentManager.getAgent(agentId); diff --git a/packages/server/src/server/agent/mcp-shared.test.ts b/packages/server/src/server/agent/mcp-shared.test.ts new file mode 100644 index 000000000..2494e17a9 --- /dev/null +++ b/packages/server/src/server/agent/mcp-shared.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createTestLogger } from "../../test-utils/test-logger.js"; +import { setupFinishNotification } from "./mcp-shared.js"; +import type { AgentManager, AgentManagerEvent, ManagedAgent } from "./agent-manager.js"; +import type { AgentStorage } from "./agent-storage.js"; + +describe("setupFinishNotification", () => { + it("does not notify archived callers", async () => { + let subscriber: ((event: AgentManagerEvent) => void) | null = null; + + const childAgent = { + id: "child-agent", + lifecycle: "idle", + config: { title: "Child Agent" }, + } as ManagedAgent; + + const agentManager = { + getAgent: vi.fn((agentId: string) => { + if (agentId === "child-agent") { + return childAgent; + } + if (agentId === "caller-agent") { + return { + id: "caller-agent", + lifecycle: "idle", + config: { title: "Caller Agent" }, + } as ManagedAgent; + } + return null; + }), + subscribe: vi.fn((callback: (event: AgentManagerEvent) => void) => { + subscriber = callback; + return () => { + subscriber = null; + }; + }), + hasInFlightRun: vi.fn().mockReturnValue(false), + streamAgent: vi.fn(() => (async function* noop() {})()), + replaceAgentRun: vi.fn(() => (async function* noop() {})()), + } as unknown as AgentManager; + + const agentStorage = { + get: vi.fn(async (agentId: string) => + agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null, + ), + } as unknown as AgentStorage; + + setupFinishNotification({ + agentManager, + agentStorage, + childAgentId: "child-agent", + callerAgentId: "caller-agent", + logger: createTestLogger(), + }); + + expect(subscriber).not.toBeNull(); + + childAgent.lifecycle = "running"; + subscriber?.({ + type: "agent_state", + agent: childAgent, + }); + + childAgent.lifecycle = "idle"; + subscriber?.({ + type: "agent_state", + agent: childAgent, + }); + + await vi.waitFor(() => { + expect(agentStorage.get).toHaveBeenCalledWith("caller-agent"); + }); + + expect((agentManager as any).streamAgent).not.toHaveBeenCalled(); + expect((agentManager as any).replaceAgentRun).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts index 6065e5d6e..3edf8f6c7 100644 --- a/packages/server/src/server/agent/mcp-shared.ts +++ b/packages/server/src/server/agent/mcp-shared.ts @@ -187,18 +187,19 @@ export function startAgentRun( interface SetupFinishNotificationParams { agentManager: AgentManager; + agentStorage: AgentStorage; childAgentId: string; callerAgentId: string; logger: Logger; } export function setupFinishNotification(params: SetupFinishNotificationParams): void { - const { agentManager, childAgentId, callerAgentId, logger } = params; + const { agentManager, agentStorage, childAgentId, callerAgentId, logger } = params; let hasSeenRunning = false; let fired = false; let unsubscribe: (() => void) | null = null; - function notify(reason: "finished" | "errored" | "needs permission"): void { + async function notify(reason: "finished" | "errored" | "needs permission"): Promise { if (fired) { return; } @@ -209,6 +210,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): return; } + const callerRecord = await agentStorage.get(callerAgentId); + if (callerRecord?.archivedAt) { + return; + } + const title = agentManager.getAgent(childAgentId)?.config?.title ?? childAgentId; const prompt = `\nAgent ${childAgentId} (${title}) ${reason}.\n`; diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index 78e0462d0..770aca44f 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -36,6 +36,7 @@ export const ProviderRuntimeSettingsSchema = z .object({ command: ProviderCommandSchema.optional(), env: z.record(z.string()).optional(), + disallowedTools: z.array(z.string()).optional(), }) .strict(); @@ -66,6 +67,7 @@ export const ProviderOverrideSchema = z command: z.array(z.string().min(1)).min(1).optional(), env: z.record(z.string()).optional(), models: z.array(ProviderProfileModelSchema).optional(), + disallowedTools: z.array(z.string()).optional(), enabled: z.boolean().optional(), order: z.number().optional(), }) diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index e19a087e7..8d669c1bd 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -473,6 +473,52 @@ describe("buildProviderRegistry", () => { expect(mockState.isCommandAvailable).toHaveBeenCalledWith("claude"); }); + test("disallowedTools flows through to runtime settings", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { + disallowedTools: ["WebSearch", "WebFetch"], + }, + }, + }); + + expect(mockState.constructorArgs.claude[0]).toEqual({ + runtimeSettings: { + command: undefined, + env: undefined, + disallowedTools: ["WebSearch", "WebFetch"], + }, + }); + }); + + test("derived provider inherits and merges disallowedTools from base", () => { + buildProviderRegistry(logger, { + providerOverrides: { + claude: { + disallowedTools: ["WebSearch"], + }, + zai: { + extends: "claude", + label: "ZAI", + disallowedTools: ["ComputerUse"], + }, + }, + }); + + const zaiArgs = mockState.constructorArgs.claude.find( + (entry) => + Array.isArray((entry.runtimeSettings as { disallowedTools?: string[] })?.disallowedTools) && + (entry.runtimeSettings as { disallowedTools: string[] }).disallowedTools.includes( + "ComputerUse", + ), + ); + expect(zaiArgs).toBeDefined(); + expect((zaiArgs!.runtimeSettings as { disallowedTools: string[] }).disallowedTools).toEqual([ + "WebSearch", + "ComputerUse", + ]); + }); + test("extension inherits base override — override claude command, zai extends claude gets overridden command", () => { buildProviderRegistry(logger, { providerOverrides: { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 0707a8534..a7d383078 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -90,7 +90,7 @@ function getProviderClientFactory(provider: string): ProviderClientFactory { } function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings | undefined { - if (!override?.command && !override?.env) { + if (!override?.command && !override?.env && !override?.disallowedTools) { return undefined; } @@ -102,6 +102,7 @@ function toRuntimeSettings(override?: ProviderOverride): ProviderRuntimeSettings } : undefined, env: override.env, + disallowedTools: override.disallowedTools, }; } @@ -122,6 +123,10 @@ function mergeRuntimeSettings( ...(override?.env ?? {}), } : undefined, + disallowedTools: + base?.disallowedTools || override?.disallowedTools + ? [...(base?.disallowedTools ?? []), ...(override?.disallowedTools ?? [])] + : undefined, }; } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 43d0cd095..41cc52ac7 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -45,6 +45,7 @@ const TEST_CAPABILITIES = { } as const; describe("ProviderSnapshotManager", () => { + const ttlMs = 5 * 60 * 1_000; const projectCwd = resolve("/tmp/project"); const projectACwd = resolve("/tmp/project-a"); const projectBCwd = resolve("/tmp/project-b"); @@ -269,7 +270,7 @@ describe("ProviderSnapshotManager", () => { ); }); - manager.refresh(projectCwd); + manager.refresh({ cwd: projectCwd }); expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "codex", @@ -291,6 +292,155 @@ describe("ProviderSnapshotManager", () => { manager.destroy(); }); + test("refresh with providers only re-fetches matching providers", async () => { + const codexFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const claudeFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("claude", "sonnet-4")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: codexFetchModels, + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: claudeFetchModels, + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( + "sonnet-4", + ); + }); + + manager.refresh({ cwd: projectCwd, providers: ["codex"] }); + + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("loading"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")).toMatchObject({ + provider: "claude", + status: "ready", + models: [createModel("claude", "sonnet-4")], + }); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + expect(codexFetchModels).toHaveBeenCalledTimes(2); + expect(claudeFetchModels).toHaveBeenCalledTimes(1); + + manager.destroy(); + }); + + test("refresh treats an empty providers list as a full refresh", async () => { + const codexFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const claudeFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("claude", "sonnet-4")]) + .mockResolvedValueOnce([createModel("claude", "sonnet-4.5")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: codexFetchModels, + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: claudeFetchModels, + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready"); + }); + + manager.refresh({ cwd: projectCwd, providers: [] }); + + expect(manager.getSnapshot(projectCwd)).toEqual([ + { + provider: "codex", + status: "loading", + label: "codex", + description: "codex test provider", + defaultModeId: null, + }, + { + provider: "claude", + status: "loading", + label: "claude", + description: "claude test provider", + defaultModeId: null, + }, + ]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( + "sonnet-4.5", + ); + }); + + expect(codexFetchModels).toHaveBeenCalledTimes(2); + expect(claudeFetchModels).toHaveBeenCalledTimes(2); + + manager.destroy(); + }); + + test("refresh ignores provider filters that are not in the registry", async () => { + const codexFetchModels = vi + .fn<() => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: codexFetchModels, + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger()); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + }); + + manager.refresh({ cwd: projectCwd, providers: ["zai"] }); + + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({ + provider: "codex", + status: "ready", + models: [createModel("codex", "gpt-5.1")], + }); + expect(codexFetchModels).toHaveBeenCalledTimes(1); + + manager.destroy(); + }); + test("refresh during an in-flight refresh is a no-op", async () => { const fetchModels = deferred(); const fetchModes = deferred(); @@ -305,7 +455,7 @@ describe("ProviderSnapshotManager", () => { const changes: ProviderSnapshotEntry[][] = []; manager.on("change", (entries) => changes.push(entries)); - manager.refresh(projectCwd); + manager.refresh({ cwd: projectCwd }); expect(manager.getSnapshot(projectCwd)).toEqual([ { @@ -317,9 +467,9 @@ describe("ProviderSnapshotManager", () => { }, ]); - manager.refresh(projectCwd); - manager.refresh(projectCwd); - manager.refresh(projectCwd); + manager.refresh({ cwd: projectCwd }); + manager.refresh({ cwd: projectCwd }); + manager.refresh({ cwd: projectCwd }); expect(changes).toHaveLength(1); expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); @@ -342,6 +492,247 @@ describe("ProviderSnapshotManager", () => { manager.destroy(); }); + test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => { + let now = 1_000; + const fetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => fetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + now += ttlMs + 1; + + const staleSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(staleSnapshot, "codex")).toMatchObject({ + provider: "codex", + status: "ready", + models: [createModel("codex", "gpt-5.1")], + modes: [createMode("auto")], + }); + + await vi.waitFor(() => { + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + }); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + manager.destroy(); + }); + + test("getSnapshot does not trigger a second warm-up while a stale re-warm is already in flight", async () => { + let now = 2_000; + const staleRefreshModels = deferred(); + const fetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockImplementationOnce(async () => staleRefreshModels.promise); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => fetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + now += ttlMs + 1; + + const firstStaleSnapshot = manager.getSnapshot(projectCwd); + const secondStaleSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(firstStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1"); + expect(getProviderEntry(secondStaleSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1"); + + await vi.waitFor(() => { + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + }); + + staleRefreshModels.resolve([createModel("codex", "gpt-5.2")]); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + + manager.destroy(); + }); + + test("getSnapshot does not re-warm when the cached snapshot is still fresh", async () => { + let now = 3_000; + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async () => [createModel("codex", "gpt-5.1")], + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + }); + + now += ttlMs - 1; + + const freshSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(freshSnapshot, "codex")?.models?.[0]?.id).toBe("gpt-5.1"); + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); + + manager.destroy(); + }); + + test("getSnapshot re-warms snapshots in error and unavailable states after TTL", async () => { + let now = 4_000; + const unavailableFetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValue([createModel("codex", "gpt-5.2")]); + const unavailableIsAvailable = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const errorFetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockRejectedValueOnce(new Error("model lookup failed")) + .mockResolvedValueOnce([createModel("claude", "sonnet")]); + const { registry } = createRegistry([ + createMockProvider({ + provider: "codex", + isAvailable: unavailableIsAvailable, + fetchModels: async (cwd) => unavailableFetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + createMockProvider({ + provider: "claude", + fetchModels: async (cwd) => errorFetchModels(cwd), + fetchModes: async () => [createMode("default")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe( + "unavailable", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("error"); + }); + + now += ttlMs + 1; + + const staleSnapshot = manager.getSnapshot(projectCwd); + + expect(getProviderEntry(staleSnapshot, "codex")?.status).toBe("unavailable"); + expect(getProviderEntry(staleSnapshot, "claude")?.status).toBe("error"); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready"); + }); + + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.models?.[0]?.id).toBe( + "sonnet", + ); + + manager.destroy(); + }); + + test("getSnapshot respects an injected TTL", async () => { + let now = 5_000; + const customTtlMs = 100; + const fetchModels = vi + .fn<(cwd?: string) => Promise>() + .mockResolvedValueOnce([createModel("codex", "gpt-5.1")]) + .mockResolvedValueOnce([createModel("codex", "gpt-5.2")]); + const { registry, handles } = createRegistry([ + createMockProvider({ + provider: "codex", + fetchModels: async (cwd) => fetchModels(cwd), + fetchModes: async () => [createMode("auto")], + }), + ]); + const manager = new ProviderSnapshotManager(registry, createTestLogger(), { + ttlMs: customTtlMs, + now: () => now, + }); + + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.1", + ); + }); + + now += customTtlMs - 1; + manager.getSnapshot(projectCwd); + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1); + + now += 2; + manager.getSnapshot(projectCwd); + + await vi.waitFor(() => { + expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2); + }); + + await vi.waitFor(() => { + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( + "gpt-5.2", + ); + }); + + manager.destroy(); + }); + test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => { const codexModels = deferred(); const { registry, handles } = createRegistry([ diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 150cde42b..51586d5f1 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -7,19 +7,35 @@ import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js" import type { ProviderDefinition } from "./provider-registry.js"; const DEFAULT_CWD_KEY = "__default__"; +const DEFAULT_SNAPSHOT_TTL_MS = 300_000; type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void; +type ProviderSnapshotManagerOptions = { + ttlMs?: number; + now?: () => number; +}; +type ProviderSnapshotRefreshOptions = { + cwd?: string; + providers?: AgentProvider[]; +}; export class ProviderSnapshotManager { private readonly snapshots = new Map>(); + private readonly lastCheckedAts = new Map(); private readonly warmUps = new Map>(); private readonly events = new EventEmitter(); private destroyed = false; + private readonly ttlMs: number; + private readonly now: () => number; constructor( private readonly providerRegistry: Record, private readonly logger: Logger, - ) {} + options: ProviderSnapshotManagerOptions = {}, + ) { + this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS; + this.now = options.now ?? Date.now; + } getSnapshot(cwd?: string): ProviderSnapshotEntry[] { const cwdKey = normalizeCwdKey(cwd); @@ -29,17 +45,24 @@ export class ProviderSnapshotManager { void this.warmUp(cwd); return entriesToArray(loadingEntries); } + if (this.shouldRevalidate(cwdKey)) { + void this.warmUp(cwd); + } return entriesToArray(entries); } - refresh(cwd?: string): void { + async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise { + const { cwd } = options; const cwdKey = normalizeCwdKey(cwd); - if (this.warmUps.has(cwdKey)) { + const inFlight = this.warmUps.get(cwdKey); + if (inFlight) { + await inFlight; return; } - this.resetSnapshotToLoading(cwdKey); + const providers = this.resolveRefreshProviders(options.providers); + this.resetSnapshotToLoading(cwdKey, providers); this.emitChange(cwdKey); - void this.warmUp(cwd); + await this.warmUp(cwd, providers); } on(event: "change", listener: ProviderSnapshotChangeListener): this { @@ -56,6 +79,7 @@ export class ProviderSnapshotManager { this.destroyed = true; this.events.removeAllListeners(); this.snapshots.clear(); + this.lastCheckedAts.clear(); this.warmUps.clear(); } @@ -74,16 +98,21 @@ export class ProviderSnapshotManager { return entries; } - private async warmUp(cwd?: string): Promise { + private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise { const cwdKey = normalizeCwdKey(cwd); const inFlight = this.warmUps.get(cwdKey); if (inFlight) { return inFlight; } + const providersToRefresh = providers ?? this.getProviderIds(); const warmUpPromise = Promise.allSettled( - this.getProviderIds().map((provider) => this.refreshProvider(cwdKey, provider, cwd)), - ).then(() => undefined); + providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)), + ).then(() => { + if (!providers) { + this.lastCheckedAts.set(cwdKey, this.now()); + } + }); this.warmUps.set(cwdKey, warmUpPromise); @@ -107,13 +136,6 @@ export class ProviderSnapshotManager { } const snapshot = this.getOrCreateSnapshot(cwdKey); - snapshot.set(provider, { - provider, - status: "loading", - label: definition.label, - description: definition.description, - defaultModeId: definition.defaultModeId, - }); try { const client = definition.createClient(this.logger); @@ -174,6 +196,17 @@ export class ProviderSnapshotManager { this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey)); } + private shouldRevalidate(cwdKey: string): boolean { + if (this.warmUps.has(cwdKey)) { + return false; + } + const lastCheckedAt = this.lastCheckedAts.get(cwdKey); + if (lastCheckedAt === undefined) { + return false; + } + return this.now() - lastCheckedAt > this.ttlMs; + } + private getOrCreateSnapshot(cwdKey: string): Map { const existing = this.snapshots.get(cwdKey); if (existing) { @@ -185,11 +218,31 @@ export class ProviderSnapshotManager { return created; } - private resetSnapshotToLoading(cwdKey: string): Map { + private resetSnapshotToLoading( + cwdKey: string, + providers?: AgentProvider[], + ): Map { const snapshot = this.getOrCreateSnapshot(cwdKey); - snapshot.clear(); - for (const [provider, entry] of this.createLoadingEntries()) { - snapshot.set(provider, entry); + const loadingEntries = this.createLoadingEntries(); + + if (!providers) { + snapshot.clear(); + for (const [provider, entry] of loadingEntries) { + snapshot.set(provider, entry); + } + return snapshot; + } + + for (const provider of providers) { + const loadingEntry = loadingEntries.get(provider); + if (!loadingEntry) continue; + const existing = snapshot.get(provider); + snapshot.set(provider, { + ...loadingEntry, + models: existing?.models, + modes: existing?.modes, + fetchedAt: existing?.fetchedAt, + }); } return snapshot; } @@ -197,6 +250,15 @@ export class ProviderSnapshotManager { private getProviderIds(): AgentProvider[] { return Object.keys(this.providerRegistry) as AgentProvider[]; } + + private resolveRefreshProviders(providers?: AgentProvider[]): AgentProvider[] | undefined { + if (!providers || providers.length === 0) { + return undefined; + } + + const providerIds = new Set(this.getProviderIds()); + return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider)); + } } function normalizeCwdKey(cwd?: string): string { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 4ea40c370..8aa03ba8c 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -2146,6 +2146,12 @@ class ClaudeAgentSession implements AgentSession { if (this.claudeSessionId) { base.resume = this.claudeSessionId; } + if (this.runtimeSettings?.disallowedTools?.length) { + base.disallowedTools = [ + ...(base.disallowedTools ?? []), + ...this.runtimeSettings.disallowedTools, + ]; + } return this.applyRuntimeSettings(base); } diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.test.ts new file mode 100644 index 000000000..cd05d1f3a --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; + +const mockState = vi.hoisted(() => ({ + superConstructorOptions: [] as unknown[], +})); + +vi.mock("./acp-agent.js", () => ({ + ACPAgentClient: class ACPAgentClient { + readonly provider: string; + + constructor(options: unknown) { + this.provider = "acp"; + mockState.superConstructorOptions.push(options); + } + }, +})); + +import { GenericACPAgentClient } from "./generic-acp-agent.js"; + +describe("GenericACPAgentClient", () => { + test("passes the custom command only as defaultCommand", () => { + new GenericACPAgentClient({ + logger: createTestLogger(), + command: ["hermes", "acp"], + env: { + HERMES_LOG: "info", + }, + }); + + expect(mockState.superConstructorOptions).toEqual([ + { + provider: "acp", + logger: expect.any(Object), + runtimeSettings: { + env: { + HERMES_LOG: "info", + }, + }, + defaultCommand: ["hermes", "acp"], + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index ea9423df9..53b2f0f1c 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -21,10 +21,6 @@ export class GenericACPAgentClient extends ACPAgentClient { provider: "acp", logger: options.logger, runtimeSettings: { - command: { - mode: "replace", - argv: options.command, - }, env: options.env, }, defaultCommand: options.command as [string, ...string[]], diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 369e11a76..667794853 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -36,6 +36,7 @@ import type { ListPersistedAgentsOptions, McpServerConfig, PersistedAgentDescriptor, + ToolCallDetail, ToolCallTimelineItem, } from "../agent-sdk-types.js"; import { @@ -1164,6 +1165,150 @@ function readNonEmptyString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function mapOpenCodeTodosToTimelineItems( + todos: Array<{ content?: string | null; status?: string | null }>, +): Extract { + return { + type: "todo", + items: todos.flatMap((todo) => { + const text = readNonEmptyString(todo.content); + if (!text) { + return []; + } + + return [ + { + text, + completed: todo.status === "completed", + }, + ]; + }), + }; +} + +function createCompactionTimelineItem( + status: Extract["status"], + trigger?: Extract["trigger"], +): Extract { + return { + type: "compaction", + status, + ...(trigger ? { trigger } : {}), + }; +} + +const PERMISSION_COMMAND_KEYS = ["command", "cmd", "shellCommand"] as const; +const PERMISSION_CWD_KEYS = ["cwd", "directory", "path", "workdir"] as const; +const PERMISSION_REASON_KEYS = ["reason", "purpose", "description", "message"] as const; +const PERMISSION_TITLE_BY_NAME: Record = { + external_directory: "Access external directory", + bash: "Run shell command", + read: "Read files", + read_file: "Read files", + write: "Write files", + write_file: "Write files", + create_file: "Write files", + edit: "Edit files", + apply_patch: "Edit files", + apply_diff: "Edit files", +}; + +function toHumanReadablePermissionTitle(permission: string): string { + const mapped = PERMISSION_TITLE_BY_NAME[permission]; + if (mapped) { + return mapped; + } + + const normalized = permission + .split(/[\s_-]+/) + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`) + .join(" "); + return normalized.length > 0 ? normalized : "Permission request"; +} + +function readFirstStringFromRecord( + record: Record | null, + keys: readonly string[], +): string | null { + if (!record) { + return null; + } + for (const key of keys) { + const value = readNonEmptyString(record[key]); + if (value) { + return value; + } + } + return null; +} + +function readPermissionField( + metadata: Record | null, + keys: readonly string[], +): string | null { + const direct = readFirstStringFromRecord(metadata, keys); + if (direct) { + return direct; + } + + const nestedInput = readOpenCodeRecord(metadata?.input); + return readFirstStringFromRecord(nestedInput, keys); +} + +function buildOpenCodePermissionInput(params: { + patterns: string[]; + metadata: Record | null; + tool: Record | null; + command: string | null; +}): Record { + return { + ...(params.patterns.length > 0 ? { patterns: params.patterns } : {}), + ...(params.metadata ? { metadata: params.metadata } : {}), + ...(params.tool ? { tool: params.tool } : {}), + ...(params.command ? { command: params.command } : {}), + }; +} + +function buildOpenCodePermissionDetail(params: { + permission: string; + input: Record; + command: string | null; + cwd: string | null; +}): ToolCallDetail { + if (params.command) { + return { + type: "shell", + command: params.command, + ...(params.cwd ? { cwd: params.cwd } : {}), + }; + } + + return { + type: "unknown", + input: { + permission: params.permission, + ...params.input, + }, + output: null, + }; +} + +function buildOpenCodePermissionDescription(params: { + reason: string | null; + patterns: string[]; +}): string | undefined { + const parts: string[] = []; + if (params.reason) { + parts.push(params.reason); + } + if (params.patterns.length > 0) { + parts.push(`Scope: ${params.patterns.join(", ")}`); + } + return parts.length > 0 ? parts.join(" - ") : undefined; +} + export function translateOpenCodeEvent( event: OpenCodeEvent, state: OpenCodeEventTranslationState, @@ -1263,6 +1408,12 @@ export function translateOpenCodeEvent( item: parsedToolPart.data, }); } + } else if (part.type === "compaction") { + events.push({ + type: "timeline", + provider: "opencode", + item: createCompactionTimelineItem("loading", part.auto ? "auto" : "manual"), + }); } else if (part.type === "step-finish") { mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part); if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) { @@ -1320,6 +1471,31 @@ export function translateOpenCodeEvent( break; } + const metadata = readOpenCodeRecord(event.properties.metadata); + const tool = readOpenCodeRecord(event.properties.tool); + const patterns = Array.isArray(event.properties.patterns) + ? event.properties.patterns.filter((value): value is string => typeof value === "string") + : []; + const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS); + const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS); + const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS); + const input = buildOpenCodePermissionInput({ + patterns, + metadata, + tool, + command, + }); + const detail = buildOpenCodePermissionDetail({ + permission: event.properties.permission, + input, + command, + cwd, + }); + const description = buildOpenCodePermissionDescription({ + reason, + patterns, + }); + events.push({ type: "permission_requested", provider: "opencode", @@ -1328,9 +1504,10 @@ export function translateOpenCodeEvent( provider: "opencode", name: event.properties.permission, kind: "tool", - title: event.properties.permission, - description: event.properties.patterns?.join(", "), - input: event.properties.metadata, + title: toHumanReadablePermissionTitle(event.properties.permission), + ...(description ? { description } : {}), + input, + detail, }, }); break; @@ -1383,6 +1560,32 @@ export function translateOpenCodeEvent( break; } + case "todo.updated": { + if (event.properties.sessionID !== state.sessionId) { + break; + } + + events.push({ + type: "timeline", + provider: "opencode", + item: mapOpenCodeTodosToTimelineItems(event.properties.todos), + }); + break; + } + + case "session.compacted": { + if (event.properties.sessionID !== state.sessionId) { + break; + } + + events.push({ + type: "timeline", + provider: "opencode", + item: createCompactionTimelineItem("completed"), + }); + break; + } + case "session.idle": { if (event.properties.sessionID === state.sessionId) { state.streamedPartKeys.clear(); diff --git a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts index 72986fe28..7e5522306 100644 --- a/packages/server/src/server/agent/providers/opencode/event-translator.test.ts +++ b/packages/server/src/server/agent/providers/opencode/event-translator.test.ts @@ -255,6 +255,114 @@ describe("translateOpenCodeEvent", () => { ]); }); + it("humanizes permission requests and includes shell detail when command metadata exists", () => { + const state = createState(); + + const result = translateOpenCodeEvent( + { + type: "permission.asked", + properties: { + id: "perm-1", + sessionID: "session-1", + permission: "external_directory", + patterns: ["/home/user/secrets/*"], + metadata: { + command: "ls /home/user/secrets", + reason: "Need to inspect generated files", + }, + tool: { + messageID: "message-1", + callID: "call-1", + }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "permission_requested", + provider: "opencode", + request: { + id: "perm-1", + provider: "opencode", + name: "external_directory", + kind: "tool", + title: "Access external directory", + description: "Need to inspect generated files - Scope: /home/user/secrets/*", + input: { + patterns: ["/home/user/secrets/*"], + metadata: { + command: "ls /home/user/secrets", + reason: "Need to inspect generated files", + }, + tool: { + messageID: "message-1", + callID: "call-1", + }, + command: "ls /home/user/secrets", + }, + detail: { + type: "shell", + command: "ls /home/user/secrets", + }, + }, + }, + ]); + }); + + it("falls back to unknown permission detail when command metadata is absent", () => { + const state = createState(); + + const result = translateOpenCodeEvent( + { + type: "permission.asked", + properties: { + id: "perm-2", + sessionID: "session-1", + permission: "external_directory", + patterns: ["/tmp/outside/*"], + metadata: { + reason: "Need to access temporary checkout", + }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "permission_requested", + provider: "opencode", + request: { + id: "perm-2", + provider: "opencode", + name: "external_directory", + kind: "tool", + title: "Access external directory", + description: "Need to access temporary checkout - Scope: /tmp/outside/*", + input: { + patterns: ["/tmp/outside/*"], + metadata: { + reason: "Need to access temporary checkout", + }, + }, + detail: { + type: "unknown", + input: { + permission: "external_directory", + patterns: ["/tmp/outside/*"], + metadata: { + reason: "Need to access temporary checkout", + }, + }, + output: null, + }, + }, + }, + ]); + }); + it("emits usage_updated after step-finish parts", () => { const state = createState(); state.accumulatedUsage.contextWindowMaxTokens = 400_000; @@ -310,6 +418,96 @@ describe("translateOpenCodeEvent", () => { }); }); + it("emits normalized todo timeline items from todo.updated", () => { + const state = createState(); + + const events = translateOpenCodeEvent( + { + type: "todo.updated", + properties: { + sessionID: "session-1", + todos: [ + { content: "Outline", status: "pending", priority: "high" }, + { content: "Ship", status: "completed", priority: "medium" }, + { content: " ", status: "completed", priority: "low" }, + ], + }, + }, + state, + ); + + expect(events).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "todo", + items: [ + { text: "Outline", completed: false }, + { text: "Ship", completed: true }, + ], + }, + }, + ]); + }); + + it("emits compaction loading timeline items from compaction parts", () => { + const state = createState(); + + const events = translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "compaction-part-1", + sessionID: "session-1", + messageID: "message-compaction-1", + type: "compaction", + auto: true, + }, + }, + }, + state, + ); + + expect(events).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "compaction", + status: "loading", + trigger: "auto", + }, + }, + ]); + }); + + it("emits compaction completed timeline items from session.compacted", () => { + const state = createState(); + + const events = translateOpenCodeEvent( + { + type: "session.compacted", + properties: { + sessionID: "session-1", + }, + }, + state, + ); + + expect(events).toEqual([ + { + type: "timeline", + provider: "opencode", + item: { + type: "compaction", + status: "completed", + }, + }, + ]); + }); + it("emits reasoning from message.part.delta events", () => { const state = createState(); @@ -510,6 +708,99 @@ describe("translateOpenCodeEvent", () => { expect(result).toEqual([]); }); + it("emits turn_completed from session.status idle", () => { + const state = createState(); + state.streamedPartKeys.add("text:part-1"); + state.partTypes.set("part-1", "text"); + + const result = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { type: "idle" }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "turn_completed", + provider: "opencode", + usage: undefined, + }, + ]); + expect(state.streamedPartKeys.size).toBe(0); + expect(state.partTypes.size).toBe(0); + }); + + it("emits turn_failed from fatal session.status retry", () => { + const state = createState(); + state.streamedPartKeys.add("text:part-1"); + state.partTypes.set("part-1", "text"); + + const result = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { + type: "retry", + attempt: 2, + message: "Invalid API key", + next: Date.now() + 1000, + }, + }, + }, + state, + ); + + expect(result).toEqual([ + { + type: "turn_failed", + provider: "opencode", + error: "Invalid API key", + }, + ]); + expect(state.streamedPartKeys.size).toBe(0); + expect(state.partTypes.size).toBe(0); + }); + + it("ignores transient session.status updates", () => { + const state = createState(); + + const busy = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { type: "busy" }, + }, + }, + state, + ); + + const retry = translateOpenCodeEvent( + { + type: "session.status", + properties: { + sessionID: "session-1", + status: { + type: "retry", + attempt: 1, + message: "rate limited", + next: Date.now() + 1000, + }, + }, + }, + state, + ); + + expect(busy).toEqual([]); + expect(retry).toEqual([]); + }); + it("emits structured assistant output when schema mode completes without text parts", () => { const state = createState(); diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index d8d2ec314..d7a61025d 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -1,9 +1,11 @@ -import type pino from "pino"; - import type { AgentManager } from "./agent/agent-manager.js"; -import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js"; +import type { + AgentPersistenceHandle, + AgentProvider, + AgentSessionConfig, +} from "./agent/agent-sdk-types.js"; import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js"; -import { isValidAgentProvider } from "./agent/provider-manifest.js"; +import { buildProviderRegistry } from "./agent/provider-registry.js"; type LoggerLike = { child(bindings: Record): LoggerLike; @@ -11,6 +13,8 @@ type LoggerLike = { warn(...args: any[]): void; }; +const DEFAULT_AGENT_PROVIDER = "claude"; + function getLogger(logger: LoggerLike): LoggerLike { return logger.child({ module: "persistence" }); } @@ -23,6 +27,18 @@ type BuildSessionConfigOptions = { logger?: LoggerLike; }; +type RegisteredProviders = ReturnType | Iterable; + +function isProviderRegistry( + registeredProviders: RegisteredProviders, +): registeredProviders is ReturnType { + return ( + typeof registeredProviders === "object" && + registeredProviders !== null && + !(Symbol.iterator in registeredProviders) + ); +} + /** * Attach AgentStorage persistence to an AgentManager instance so every * agent_state snapshot is flushed to disk. @@ -90,30 +106,6 @@ export function buildSessionConfig( }; } -export function toAgentPersistenceHandle( - logger: pino.Logger, - handle: StoredAgentRecord["persistence"], -) { - if (!handle) { - return null; - } - const provider = handle.provider; - if (!isValidAgentProvider(provider)) { - logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`); - return null; - } - if (!handle.sessionId) { - logger.warn("Ignoring persistence handle missing sessionId"); - return null; - } - return { - provider, - sessionId: handle.sessionId, - nativeHandle: handle.nativeHandle, - metadata: handle.metadata, - }; -} - export function extractTimestamps(record: StoredAgentRecord): { createdAt: Date; updatedAt: Date; @@ -127,3 +119,58 @@ export function extractTimestamps(record: StoredAgentRecord): { labels: record.labels, }; } + +function hasRegisteredProvider(registeredProviders: RegisteredProviders, value: string): boolean { + if (isProviderRegistry(registeredProviders)) { + return Object.prototype.hasOwnProperty.call(registeredProviders, value); + } + return new Set(registeredProviders).has(value as AgentProvider); +} + +export function isRegisteredProvider( + providerRegistry: ReturnType, + value: string, +): boolean { + return hasRegisteredProvider(providerRegistry, value); +} + +export function coerceAgentProvider( + logger: LoggerLike, + providerRegistry: ReturnType, + value: string, + agentId?: string, +): AgentProvider { + if (isRegisteredProvider(providerRegistry, value)) { + return value; + } + logger.warn( + { value, agentId, defaultProvider: DEFAULT_AGENT_PROVIDER }, + `Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'`, + ); + return DEFAULT_AGENT_PROVIDER; +} + +export function toAgentPersistenceHandle( + logger: LoggerLike, + registeredProviders: RegisteredProviders, + handle: StoredAgentRecord["persistence"], +): AgentPersistenceHandle | null { + if (!handle) { + return null; + } + const provider = handle.provider; + if (!hasRegisteredProvider(registeredProviders, provider)) { + logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`); + return null; + } + if (!handle.sessionId) { + logger.warn("Ignoring persistence handle missing sessionId"); + return null; + } + return { + provider, + sessionId: handle.sessionId, + nativeHandle: handle.nativeHandle, + metadata: handle.metadata, + } satisfies AgentPersistenceHandle; +} diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index 9547d3e7e..0a539667c 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -265,4 +265,62 @@ describe("ScheduleService", () => { expect(inspected.runs).toHaveLength(1); expect(inspected.runs[0]?.status).toBe("succeeded"); }); + + test("rejects archived target agents before loading them", async () => { + const manager = new AgentManager({ logger: createTestLogger() }); + const service = new ScheduleService({ + paseoHome: tempDir, + logger: createTestLogger(), + agentManager: manager, + agentStorage, + now: () => now, + }); + + await agentStorage.upsert({ + id: "archived-agent", + provider: "claude", + cwd: tempDir, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + lastActivityAt: now.toISOString(), + lastUserMessageAt: null, + title: "Archived Agent", + labels: {}, + lastStatus: "closed", + lastModeId: "default", + config: { + modeId: "default", + }, + runtimeInfo: null, + features: [], + persistence: null, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + internal: false, + archivedAt: "2026-01-02T00:00:00.000Z", + }); + + await expect( + (service as any).executeSchedule({ + id: "schedule-1", + name: null, + prompt: "Check archived agent", + cadence: { type: "every", everyMs: 60_000 }, + target: { + type: "agent", + agentId: "archived-agent", + }, + status: "active", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + nextRunAt: now.toISOString(), + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }), + ).rejects.toThrow("Agent archived-agent is archived"); + }); }); diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 76370c032..8832fbf4a 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -2,15 +2,10 @@ import { randomUUID } from "node:crypto"; import { join } from "node:path"; import type { Logger } from "pino"; import { AgentManager } from "../agent/agent-manager.js"; -import type { ManagedAgent } from "../agent/agent-manager.js"; import type { AgentStorage } from "../agent/agent-storage.js"; import type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js"; import { curateAgentActivity } from "../agent/activity-curator.js"; -import { - buildConfigOverrides, - buildSessionConfig, - extractTimestamps, -} from "../persistence-hooks.js"; +import { ensureAgentLoaded } from "../agent/agent-loading.js"; import { ScheduleStore } from "./store.js"; import { computeNextRunAt, validateScheduleCadence } from "./cron.js"; import type { @@ -21,7 +16,6 @@ import type { } from "./types.js"; const SCHEDULE_TICK_INTERVAL_MS = 1000; -const pendingAgentInitializations = new Map>(); function trimOptionalName(value: string | null | undefined): string | null { if (typeof value !== "string") { @@ -386,7 +380,16 @@ export class ScheduleService { private async executeSchedule(schedule: StoredSchedule): Promise { if (schedule.target.type === "agent") { - const agent = await this.ensureAgentLoaded(schedule.target.agentId); + const record = await this.agentStorage.get(schedule.target.agentId); + if (record?.archivedAt) { + throw new Error(`Agent ${schedule.target.agentId} is archived`); + } + + const agent = await ensureAgentLoaded(schedule.target.agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.logger, + }); if (this.agentManager.hasInFlightRun(agent.id)) { throw new Error(`Agent ${agent.id} already has an active run`); } @@ -435,64 +438,4 @@ export class ScheduleService { }), }; } - - private async ensureAgentLoaded(agentId: string): Promise { - const existing = this.agentManager.getAgent(agentId); - if (existing) { - return existing; - } - - const inflight = pendingAgentInitializations.get(agentId); - if (inflight) { - return inflight; - } - - const initPromise = (async () => { - const record = await this.agentStorage.get(agentId); - if (!record) { - throw new Error(`Agent not found: ${agentId}`); - } - if (record.archivedAt) { - throw new Error(`Agent ${agentId} is archived`); - } - - let snapshot: ManagedAgent; - if (record.persistence?.provider && record.persistence?.sessionId) { - snapshot = await this.agentManager.resumeAgentFromPersistence( - { - provider: record.persistence.provider as AgentSessionConfig["provider"], - sessionId: record.persistence.sessionId, - nativeHandle: record.persistence.nativeHandle, - metadata: record.persistence.metadata, - }, - buildConfigOverrides(record), - agentId, - extractTimestamps(record), - ); - } else { - const config = buildSessionConfig(record, { - validProviders: this.agentManager.getRegisteredProviderIds(), - logger: this.logger, - }); - if (!config) { - throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`); - } - snapshot = await this.agentManager.createAgent(config, agentId, { - labels: record.labels, - }); - } - - await this.agentManager.hydrateTimelineFromProvider(agentId); - return this.agentManager.getAgent(agentId) ?? snapshot; - })(); - - pendingAgentInitializations.set(agentId, initPromise); - try { - return await initPromise; - } finally { - if (pendingAgentInitializations.get(agentId) === initPromise) { - pendingAgentInitializations.delete(agentId); - } - } - } } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 493c8b467..05dc7c0aa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -62,6 +62,12 @@ import { createVoiceTurnController, type VoiceTurnController, } from "./voice/voice-turn-controller.js"; +import { + buildConfigOverrides, + extractTimestamps, + toAgentPersistenceHandle, +} from "./persistence-hooks.js"; +import { ensureAgentLoaded } from "./agent/agent-loading.js"; import { experimental_createMCPClient } from "ai"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; @@ -86,7 +92,12 @@ import type { ManagedAgent, } from "./agent/agent-manager.js"; import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js"; -import { resolveEffectiveThinkingOptionId, toAgentPayload } from "./agent/agent-projections.js"; +import { + buildStoredAgentPayload, + resolveEffectiveThinkingOptionId, + resolveStoredAgentPayloadUpdatedAt, + toAgentPayload, +} from "./agent/agent-projections.js"; import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent/agent-title-limits.js"; import { appendTimelineItemIfAgentKnown, @@ -104,19 +115,18 @@ import { generateStructuredAgentResponseWithFallback, } from "./agent/agent-response-loop.js"; import type { + AgentPersistenceHandle, AgentPermissionResponse, + AgentProvider, AgentPromptContentBlock, AgentPromptInput, AgentRunOptions, AgentSessionConfig, AgentStreamEvent, - AgentProvider, - AgentPersistenceHandle, ProviderSnapshotEntry, } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; -import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId as normalizePersistedWorkspaceId, @@ -135,7 +145,6 @@ import { type ProjectRegistry, type WorkspaceRegistry, } from "./workspace-registry.js"; -import { AgentLoadingService } from "./agent-loading-service.js"; import { buildVoiceModeSystemPrompt, stripVoiceModeSystemPrompt, @@ -195,7 +204,6 @@ import { const execAsync = promisify(exec); const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS); -const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; // TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings. @@ -464,7 +472,6 @@ export type SessionOptions = { scheduleService: ScheduleService; loopService: LoopService; checkoutDiffManager: CheckoutDiffManager; - agentLoadingService?: AgentLoadingService; createAgentMcpTransport?: AgentMcpTransportFactory; workspaceGitService: WorkspaceGitService; daemonConfigStore: DaemonConfigStore; @@ -570,54 +577,6 @@ function convertPCMToWavBuffer( return wavBuffer; } -function isRegisteredProvider( - providerRegistry: ReturnType, - value: string, -): boolean { - return Object.prototype.hasOwnProperty.call(providerRegistry, value); -} - -function coerceAgentProvider( - logger: pino.Logger, - providerRegistry: ReturnType, - value: string, - agentId?: string, -): AgentProvider { - if (isRegisteredProvider(providerRegistry, value)) { - return value; - } - logger.warn( - { value, agentId, defaultProvider: DEFAULT_AGENT_PROVIDER }, - `Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'`, - ); - return DEFAULT_AGENT_PROVIDER; -} - -function toAgentPersistenceHandle( - logger: pino.Logger, - providerRegistry: ReturnType, - handle: StoredAgentRecord["persistence"], -): AgentPersistenceHandle | null { - if (!handle) { - return null; - } - const provider = handle.provider; - if (!isRegisteredProvider(providerRegistry, provider)) { - logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`); - return null; - } - if (!handle.sessionId) { - logger.warn("Ignoring persistence handle missing sessionId"); - return null; - } - return { - provider, - sessionId: handle.sessionId, - nativeHandle: handle.nativeHandle, - metadata: handle.metadata, - } satisfies AgentPersistenceHandle; -} - /** * Session represents a single connected client session. * It owns all state management, orchestration logic, and message processing. @@ -673,7 +632,6 @@ export class Session { private readonly scheduleService: ScheduleService; private readonly loopService: LoopService; private readonly checkoutDiffManager: CheckoutDiffManager; - private readonly agentLoadingService: AgentLoadingService; private readonly workspaceGitService: WorkspaceGitService; private readonly daemonConfigStore: DaemonConfigStore; private readonly mcpBaseUrl: string | null; @@ -752,7 +710,6 @@ export class Session { scheduleService, loopService, checkoutDiffManager, - agentLoadingService, workspaceGitService, daemonConfigStore, mcpBaseUrl, @@ -794,13 +751,6 @@ export class Session { this.scheduleService = scheduleService; this.loopService = loopService; this.checkoutDiffManager = checkoutDiffManager; - this.agentLoadingService = - agentLoadingService ?? - new AgentLoadingService({ - agentManager: this.agentManager, - agentStorage: this.agentStorage, - logger: this.sessionLogger, - }); this.workspaceGitService = workspaceGitService; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl ?? null; @@ -1184,9 +1134,7 @@ export class Session { const storedRecord = await this.agentStorage.get(agent.id); const title = storedRecord?.title ?? storedRecord?.config?.title ?? null; const payload = toAgentPayload(agent, { title }); - const storedUpdatedAt = storedRecord - ? this.resolveStoredAgentPayloadUpdatedAt(storedRecord) - : null; + const storedUpdatedAt = storedRecord ? resolveStoredAgentPayloadUpdatedAt(storedRecord) : null; if (storedUpdatedAt) { const liveUpdatedAt = Date.parse(payload.updatedAt); const persistedUpdatedAt = Date.parse(storedUpdatedAt); @@ -1199,90 +1147,7 @@ export class Session { } private buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload { - const defaultCapabilities = { - supportsStreaming: false, - supportsSessionPersistence: true, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: true, - } as const; - - const createdAt = new Date(record.createdAt); - const updatedAt = new Date(this.resolveStoredAgentPayloadUpdatedAt(record)); - const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null; - - const provider = coerceAgentProvider( - this.sessionLogger, - this.providerRegistry, - record.provider, - record.id, - ); - const runtimeInfo = record.runtimeInfo - ? { - provider: coerceAgentProvider( - this.sessionLogger, - this.providerRegistry, - record.runtimeInfo.provider, - record.id, - ), - sessionId: record.runtimeInfo.sessionId, - model: record.runtimeInfo.model ?? null, - thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null, - modeId: record.runtimeInfo.modeId ?? null, - ...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}), - } - : undefined; - return { - id: record.id, - provider, - cwd: record.cwd, - model: record.config?.model ?? null, - thinkingOptionId: record.config?.thinkingOptionId ?? null, - effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({ - runtimeInfo, - configuredThinkingOptionId: record.config?.thinkingOptionId ?? null, - }), - ...(runtimeInfo ? { runtimeInfo } : {}), - createdAt: createdAt.toISOString(), - updatedAt: updatedAt.toISOString(), - lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null, - status: record.lastStatus, - capabilities: defaultCapabilities, - currentModeId: record.lastModeId ?? null, - availableModes: [], - pendingPermissions: [], - persistence: toAgentPersistenceHandle( - this.sessionLogger, - this.providerRegistry, - record.persistence, - ), - lastUsage: undefined, - lastError: record.lastError ?? undefined, - title: record.title ?? record.config?.title ?? null, - requiresAttention: record.requiresAttention ?? false, - attentionReason: record.attentionReason ?? null, - attentionTimestamp: record.attentionTimestamp ?? null, - archivedAt: record.archivedAt ?? null, - labels: record.labels, - }; - } - - private resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string { - const timestamps = [record.updatedAt, record.lastActivityAt] - .filter((value): value is string => typeof value === "string" && value.length > 0) - .map((value) => ({ - raw: value, - parsed: Date.parse(value), - })) - .filter((value) => !Number.isNaN(value.parsed)); - - timestamps.sort((a, b) => b.parsed - a.parsed); - return timestamps[0].raw; - } - - private async ensureAgentLoaded(agentId: string): Promise { - return this.agentLoadingService.ensureAgentLoaded({ agentId }); + return buildStoredAgentPayload(record, this.providerRegistry, this.sessionLogger); } private isProviderVisibleToClient(provider: string): boolean { @@ -2633,7 +2498,11 @@ export class Session { private async enableVoiceModeForAgent(agentId: string): Promise { const startedAt = Date.now(); this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start"); - const existing = await this.ensureAgentLoaded(agentId); + const existing = await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); this.sessionLogger.info( { agentId, elapsedMs: Date.now() - startedAt }, "enableVoiceModeForAgent.ensureAgentLoaded.done", @@ -2857,7 +2726,11 @@ export class Session { await this.unarchiveAgentState(agentId); try { - await this.ensureAgentLoaded(agentId); + await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); } catch (error) { this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt"); return { @@ -3057,11 +2930,9 @@ export class Session { ); try { await this.unarchiveAgentByHandle(handle); - const snapshot = await this.agentLoadingService.resumeAgent({ - handle, - overrides, - }); + const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides); await this.unarchiveAgentState(snapshot.id); + await this.agentManager.hydrateTimelineFromProvider(snapshot.id); await this.forwardAgentUpdate(snapshot); const timelineSize = this.agentManager.getTimeline(snapshot.id).length; if (requestId) { @@ -3099,10 +2970,32 @@ export class Session { try { await this.unarchiveAgentState(agentId); - if (this.agentManager.getAgent(agentId)) { + let snapshot: ManagedAgent; + const existing = this.agentManager.getAgent(agentId); + if (existing) { await this.interruptAgentIfRunning(agentId); + snapshot = await this.agentManager.reloadAgentSession(agentId); + } else { + const record = await this.agentStorage.get(agentId); + if (!record) { + throw new Error(`Agent not found: ${agentId}`); + } + const handle = toAgentPersistenceHandle( + this.sessionLogger, + this.providerRegistry, + record.persistence, + ); + if (!handle) { + throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`); + } + snapshot = await this.agentManager.resumeAgentFromPersistence( + handle, + buildConfigOverrides(record), + agentId, + extractTimestamps(record), + ); } - const snapshot = await this.agentLoadingService.refreshAgent({ agentId }); + await this.agentManager.hydrateTimelineFromProvider(agentId); await this.forwardAgentUpdate(snapshot); const timelineSize = this.agentManager.getTimeline(agentId).length; if (requestId) { @@ -3352,7 +3245,10 @@ export class Session { private async handleRefreshProvidersSnapshotRequest( msg: Extract, ): Promise { - this.providerSnapshotManager?.refresh(msg.cwd ? expandTilde(msg.cwd) : undefined); + await this.providerSnapshotManager?.refresh({ + cwd: msg.cwd ? expandTilde(msg.cwd) : undefined, + providers: msg.providers, + }); this.emit({ type: "refresh_providers_snapshot_response", payload: { @@ -6699,7 +6595,11 @@ export class Session { : undefined; try { - const snapshot = await this.ensureAgentLoaded(msg.agentId); + const snapshot = await ensureAgentLoaded(msg.agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); const agentPayload = await this.buildAgentPayload(snapshot); let timeline = this.agentManager.fetchTimeline(msg.agentId, { @@ -6853,7 +6753,11 @@ export class Session { const agentId = resolved.agentId; await this.unarchiveAgentState(agentId); - await this.ensureAgentLoaded(agentId); + await ensureAgentLoaded(agentId, { + agentManager: this.agentManager, + agentStorage: this.agentStorage, + logger: this.sessionLogger, + }); this.sessionLogger.trace( { agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) }, diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index c5698c901..4bb6692c7 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -207,6 +207,44 @@ describe("shared messages stream parsing", () => { } }); + it("parses permission request detail compatibly", () => { + const parsed = AgentStreamMessageSchema.parse({ + type: "agent_stream", + payload: { + agentId: "agent_live", + timestamp: "2026-02-08T20:10:00.000Z", + event: { + type: "permission_requested", + provider: "opencode", + request: { + id: "perm-shell-1", + provider: "opencode", + name: "external_directory", + kind: "tool", + title: "Access external directory", + input: { + command: "ls /tmp/outside", + }, + detail: { + type: "shell", + command: "ls /tmp/outside", + cwd: "/home/dev/project", + }, + }, + }, + }, + }); + + expect(parsed.payload.event.type).toBe("permission_requested"); + if (parsed.payload.event.type === "permission_requested") { + expect(parsed.payload.event.request.detail).toEqual({ + type: "shell", + command: "ls /tmp/outside", + cwd: "/home/dev/project", + }); + } + }); + it("rejects removed initialize_agent_request inbound payload", () => { const parsed = SessionInboundMessageSchema.safeParse({ type: "initialize_agent_request", diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 0c5559d53..4ee1167bd 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -259,6 +259,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType ToolCallDetailPayloadSchema).optional(), suggestions: z.array(AgentPermissionUpdateSchema).optional(), actions: z.array(AgentPermissionActionSchema).optional(), metadata: z.record(z.unknown()).optional(), @@ -883,6 +884,7 @@ export const GetProvidersSnapshotRequestMessageSchema = z.object({ export const RefreshProvidersSnapshotRequestMessageSchema = z.object({ type: z.literal("refresh_providers_snapshot_request"), cwd: z.string().optional(), + providers: z.array(AgentProviderSchema).optional(), requestId: z.string(), }); diff --git a/packages/website/package.json b/packages/website/package.json index 055c9800c..d2b5bc0b0 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.1.56", + "version": "0.1.57-rc.1", "private": true, "type": "module", "scripts": {