Merge remote-tracking branch 'origin/main' into dev

# Conflicts:
#	nix/package.nix
#	packages/app/src/components/adaptive-modal-sheet.tsx
#	packages/app/src/hooks/use-providers-snapshot.ts
#	packages/app/src/screens/workspace/workspace-screen.tsx
#	packages/server/src/server/agent/agent-management-mcp.ts
#	packages/server/src/server/agent/mcp-server.ts
#	packages/server/src/server/persistence-hooks.ts
#	packages/server/src/server/schedule/service.ts
#	packages/server/src/server/session.ts
This commit is contained in:
Mohamed Boudra
2026-04-16 14:14:32 +07:00
67 changed files with 2795 additions and 1683 deletions

View File

@@ -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<string, string>` | 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`

View File

@@ -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.

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-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).

38
package-lock.json generated
View File

@@ -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",

View File

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

View File

@@ -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",

View File

@@ -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;
}
</style>
</head>
<body>

View File

@@ -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 (
<BottomSheetModal
@@ -205,20 +250,27 @@ export function AdaptiveModalSheet({
>
<View style={styles.bottomSheetHeader}>
<View style={styles.headerTitleGroup}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
{scrollable ? (
<BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
) : (
<View style={styles.bottomSheetStaticContent}>{children}</View>
)}
</BottomSheetModal>
);
}
@@ -227,21 +279,28 @@ export function AdaptiveModalSheet({
<>
<View style={styles.header}>
<View style={styles.headerTitleGroup}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
{scrollable ? (
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
)}
</>
);

View File

@@ -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) {
</View>
{/* Content */}
<ScrollView
style={styles.contentScroll}
style={[styles.contentScroll, webScrollbarStyle]}
contentContainerStyle={styles.contentScrollContainer}
>
{artifact.type === "image" ? (
@@ -200,7 +203,11 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
</View>
) : (
<View style={styles.codeContainer}>
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text style={styles.codeText}>{content}</Text>
</ScrollView>
</View>

View File

@@ -45,7 +45,7 @@ export function BranchSwitcher({
const titleContent = (
<>
<GitBranch size={14} color={theme.colors.foregroundMuted} />
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>

View File

@@ -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 (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={style}
style={[style, webScrollbarStyle]}
contentContainerStyle={contentContainerStyle}
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
>

View File

@@ -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)}
>

View File

@@ -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)

View File

@@ -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);
});
});

View File

@@ -0,0 +1,4 @@
export function isRenderedMarkdownFile(filePath: string): boolean {
const normalizedPath = filePath.trim().toLowerCase();
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
}

View File

@@ -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({
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
</View>
<Text style={codeLineStyles.lineText}>
<Text selectable style={codeLineStyles.lineText}>
{tokens.map((token, index) => (
<Text
key={index}
@@ -117,19 +121,23 @@ function FilePreviewBody({
const isDark = theme.colorScheme === "dark";
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
const baseColor = isDark ? "#c9d1d9" : "#24292f";
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
const previewScrollRef = useRef<RNScrollView>(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 (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<Markdown style={markdownStyles} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const codeLines = (
<View>
@@ -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],

View File

@@ -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<MessageInputRef, MessageInputProps>(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) {

View File

@@ -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<string | null>(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 (
<View style={sheetStyles.emptyState}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Loading models</Text>
</View>
);
}
if (models.length === 0 && providerErrorMessage) {
return (
<View style={sheetStyles.emptyState}>
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
</View>
);
}
if (models.length === 0) {
return (
<View style={sheetStyles.emptyState}>
<Text style={sheetStyles.mutedText}>No models detected.</Text>
</View>
);
}
if (filteredModels.length === 0) {
return (
<View style={sheetStyles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>No models match your search</Text>
</View>
);
}
return filteredModels.map((model: AgentModelDefinition, index) => (
<View key={model.id} style={[sheetStyles.modelRow, index > 0 && sheetStyles.modelRowBorder]}>
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
));
}
return (
<AdaptiveModalSheet
title={providerLabel}
visible={visible}
onClose={onClose}
snapPoints={["50%", "85%"]}
>
{loading ? (
<View style={sheetStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.loadingText}>Fetching diagnostic</Text>
</View>
) : diagnostic ? (
<ScrollView
horizontal
style={sheetStyles.scrollContainer}
contentContainerStyle={sheetStyles.scrollContent}
scrollable={false}
headerActions={
<Pressable
onPress={handleRefresh}
disabled={refreshInFlight}
hitSlop={8}
style={({ hovered, pressed }) => [
sheetStyles.iconButton,
(hovered || pressed) && sheetStyles.iconButtonHovered,
refreshInFlight ? sheetStyles.disabled : null,
]}
accessibilityRole="button"
accessibilityLabel={`Refresh ${providerLabel}`}
>
<Text style={sheetStyles.diagnosticText} selectable>
{diagnostic}
</Text>
<SpinningRefreshIcon
spinning={refreshInFlight}
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</Pressable>
}
>
<View style={sheetStyles.section}>
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
<View style={sheetStyles.codeBlock}>
{loading && !diagnostic ? (
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Running diagnostic</Text>
</View>
) : diagnostic ? (
<ScrollView
style={sheetStyles.codeScroll}
contentContainerStyle={sheetStyles.codeContent}
showsVerticalScrollIndicator={false}
>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<Text style={sheetStyles.codeText} selectable>
{diagnostic}
</Text>
</ScrollView>
</ScrollView>
) : (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
</View>
)}
</View>
</View>
<View style={sheetStyles.modelsSection}>
<View style={sheetStyles.modelsHeader}>
<Text style={sheetStyles.sectionTitle}>Models</Text>
<View style={sheetStyles.modelsHeaderMeta}>
<Text style={sheetStyles.countText}>{models.length}</Text>
{fetchedAtLabel ? (
<>
<Text style={sheetStyles.metaDot}>·</Text>
<Text style={sheetStyles.countText}>Updated {fetchedAtLabel}</Text>
</>
) : null}
</View>
</View>
{models.length > 0 ? (
<View style={sheetStyles.searchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
value={query}
onChangeText={setQuery}
placeholder="Search models"
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
// @ts-expect-error - outlineStyle is web-only
style={[sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]}
/>
</View>
) : null}
<ScrollView
style={sheetStyles.modelsScroll}
contentContainerStyle={sheetStyles.modelsScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{renderModelsBody()}
</ScrollView>
) : null}
</View>
</AdaptiveModalSheet>
);
}
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,
},
}));

View File

@@ -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 (
<Animated.View
style={[
{
width: size,
height: size,
alignItems: "center",
justifyContent: "center",
},
animatedStyle,
]}
>
<RefreshCw size={size} color={color} />
</Animated.View>
);
}

View File

@@ -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}
>
<View style={styles.codeLine}>
@@ -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}
>
<View style={styles.codeLine}>
@@ -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}
>
<View style={styles.codeLine}>
@@ -181,12 +189,18 @@ export function ToolCallDetailsContent({
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
@@ -204,12 +218,18 @@ export function ToolCallDetailsContent({
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator={true}
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator={true}>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator={true}
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
@@ -236,12 +256,18 @@ export function ToolCallDetailsContent({
style={[
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
@@ -286,12 +312,18 @@ export function ToolCallDetailsContent({
styles.scrollArea,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
webScrollbarStyle,
]}
contentContainerStyle={styles.scrollContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
</Text>
@@ -356,7 +388,7 @@ export function ToolCallDetailsContent({
<ScrollView
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
style={[styles.jsonScroll, webScrollbarStyle]}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
@@ -378,7 +410,7 @@ export function ToolCallDetailsContent({
<ScrollView
horizontal
nestedScrollEnabled
style={[styles.jsonScroll, styles.jsonScrollError]}
style={[styles.jsonScroll, styles.jsonScrollError, webScrollbarStyle]}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>

View File

@@ -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,

View File

@@ -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({
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={{ flexGrow: 1 }}
>
{children}

View File

@@ -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<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
@@ -411,6 +413,7 @@ export function DropdownMenuContent({
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={{ flexGrow: 1 }}
>
{children}

View File

@@ -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<boolean> => {
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],

View File

@@ -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;

View File

@@ -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<void>;
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,

View File

@@ -0,0 +1 @@
export * from "./use-web-scrollbar-style.web";

View File

@@ -0,0 +1,5 @@
import type { ViewStyle } from "react-native";
export function useWebScrollbarStyle(): ViewStyle | undefined {
return undefined;
}

View File

@@ -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],
);
}

View File

@@ -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<string | null>(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) {
<Text style={settingsStyles.sectionHeaderTitle}>Providers</Text>
{hasServer && isConnected ? (
<Pressable
onPress={refresh}
disabled={isFetching}
style={[settingsStyles.sectionHeaderLink, isFetching ? { opacity: 0.5 } : null]}
onPress={() => {
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"
>
<Text
style={{
color: theme.colors.primary,
fontSize: theme.fontSize.xs,
}}
>
Refresh
</Text>
<SpinningRefreshIcon
spinning={providerRefreshInFlight}
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
</Pressable>
) : null}
</View>
@@ -570,8 +580,15 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
? entry.error.trim()
: null;
const modelCount = entry?.models?.length ?? 0;
return (
<View key={def.id} style={styles.audioRow}>
<Pressable
key={def.id}
style={styles.audioRow}
onPress={() => setDiagnosticProvider(def.id)}
accessibilityRole="button"
>
<View style={styles.audioRowContent}>
<View
style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing[2] }}
@@ -584,31 +601,27 @@ function ProvidersSection({ routeServerId }: ProvidersSectionProps) {
{providerError}
</Text>
) : null}
{status === "ready" && modelCount > 0 ? (
<Text style={styles.audioRowSubtitle}>
{modelCount === 1 ? "1 model" : `${modelCount} models`}
</Text>
) : null}
</View>
<View style={styles.providerActions}>
<StatusBadge
label={
status === "ready"
? "Available"
: status === "error"
? "Error"
: status === "loading"
? "Loading..."
: "Not installed"
}
variant={
status === "ready" ? "success" : status === "error" ? "error" : "muted"
}
/>
<Button
variant="secondary"
size="sm"
onPress={() => setDiagnosticProvider(def.id)}
>
Diagnostic
</Button>
</View>
</View>
<StatusBadge
label={
status === "ready"
? "Available"
: status === "error"
? "Error"
: status === "loading"
? "Loading..."
: "Not installed"
}
variant={
status === "ready" ? "success" : status === "error" ? "error" : "muted"
}
/>
</Pressable>
);
})}
</View>
@@ -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,

View File

@@ -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<DesktopDaemonLogs | null>(null);
const [logsError, setLogsError] = useState<string | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
@@ -283,7 +285,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
<View style={styles.errorScreen}>
<TitlebarDragRegion />
<ScrollView
style={styles.errorScrollView}
style={[styles.errorScrollView, webScrollbarStyle]}
contentContainerStyle={styles.errorScrollContent}
showsVerticalScrollIndicator
>
@@ -304,7 +306,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
<View style={styles.logsContainer}>
<ScrollView
style={styles.logsScroll}
style={[styles.logsScroll, webScrollbarStyle]}
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>

View File

@@ -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(

View File

@@ -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);
});
});

View File

@@ -0,0 +1,6 @@
export function isImeComposingKeyboardEvent(event: {
isComposing?: boolean;
keyCode?: number;
}): boolean {
return Boolean(event.isComposing) || event.keyCode === 229;
}

View File

@@ -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",

View File

@@ -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"

View File

@@ -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 "══════════════════════════════════════════════════════"

View File

@@ -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",

View File

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

View File

@@ -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": {

View File

@@ -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",

View File

@@ -2838,6 +2838,7 @@ export class DaemonClient {
async refreshProvidersSnapshot(options?: {
cwd?: string;
providers?: AgentProvider[];
requestId?: string;
}): Promise<RefreshProvidersSnapshotPayload> {
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,

View File

@@ -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<string, Promise<ManagedAgent>>();
export type AgentLoadingServiceOptions = {
agentManager: Pick<
AgentManager,
| "createAgent"
| "getAgent"
| "hydrateTimelineFromProvider"
| "reloadAgentSession"
| "resumeAgentFromPersistence"
>;
agentStorage: Pick<AgentStorage, "get">;
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<ManagedAgent> {
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<AgentSessionConfig>;
}): Promise<ManagedAgent> {
const snapshot = await this.agentManager.resumeAgentFromPersistence(
options.handle,
options.overrides,
);
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
return snapshot;
}
async refreshAgent(options: { agentId: string }): Promise<ManagedAgent> {
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<ManagedAgent> {
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;
}
}

View File

@@ -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<string, Promise<ManagedAgent>>();
export interface EnsureAgentLoadedDeps {
agentManager: AgentManager;
agentStorage: AgentStorage;
validProviders?: Iterable<AgentProvider>;
logger: Logger;
}
export async function ensureAgentLoaded(
agentId: string,
deps: EnsureAgentLoadedDeps,
): Promise<ManagedAgent> {
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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<typeof buildProviderRegistry>,
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")) {

View File

@@ -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<ProviderDefinition>): Provi
};
}
function createStoredRecord(overrides: Partial<StoredAgentRecord> = {}): 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",
);
});
});

View File

@@ -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<AgentProvider, ProviderDefinition> => {
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);

View File

@@ -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();
});
});

View File

@@ -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<void> {
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 = `<paseo-system>\nAgent ${childAgentId} (${title}) ${reason}.\n</paseo-system>`;

View File

@@ -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(),
})

View File

@@ -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: {

View File

@@ -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,
};
}

View File

@@ -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<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
const claudeFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.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<AgentModelDefinition[]>>()
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
const claudeFetchModels = vi
.fn<() => Promise<AgentModelDefinition[]>>()
.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<AgentModelDefinition[]>>()
.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<AgentModelDefinition[]>();
const fetchModes = deferred<AgentMode[]>();
@@ -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<AgentModelDefinition[]>>()
.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<AgentModelDefinition[]>();
const fetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.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<AgentModelDefinition[]>>()
.mockResolvedValue([createModel("codex", "gpt-5.2")]);
const unavailableIsAvailable = vi
.fn<() => Promise<boolean>>()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const errorFetchModels = vi
.fn<(cwd?: string) => Promise<AgentModelDefinition[]>>()
.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<AgentModelDefinition[]>>()
.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<AgentModelDefinition[]>();
const { registry, handles } = createRegistry([

View File

@@ -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<string, Map<AgentProvider, ProviderSnapshotEntry>>();
private readonly lastCheckedAts = new Map<string, number>();
private readonly warmUps = new Map<string, Promise<void>>();
private readonly events = new EventEmitter();
private destroyed = false;
private readonly ttlMs: number;
private readonly now: () => number;
constructor(
private readonly providerRegistry: Record<AgentProvider, ProviderDefinition>,
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<void> {
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<void> {
private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise<void> {
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<AgentProvider, ProviderSnapshotEntry> {
const existing = this.snapshots.get(cwdKey);
if (existing) {
@@ -185,11 +218,31 @@ export class ProviderSnapshotManager {
return created;
}
private resetSnapshotToLoading(cwdKey: string): Map<AgentProvider, ProviderSnapshotEntry> {
private resetSnapshotToLoading(
cwdKey: string,
providers?: AgentProvider[],
): Map<AgentProvider, ProviderSnapshotEntry> {
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 {

View File

@@ -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);
}

View File

@@ -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"],
},
]);
});
});

View File

@@ -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[]],

View File

@@ -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<AgentTimelineItem, { type: "todo" }> {
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<AgentTimelineItem, { type: "compaction" }>["status"],
trigger?: Extract<AgentTimelineItem, { type: "compaction" }>["trigger"],
): Extract<AgentTimelineItem, { type: "compaction" }> {
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<string, string> = {
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null;
tool: Record<string, unknown> | null;
command: string | null;
}): Record<string, unknown> {
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<string, unknown>;
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();

View File

@@ -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();

View File

@@ -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<string, unknown>): 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<typeof buildProviderRegistry> | Iterable<AgentProvider>;
function isProviderRegistry(
registeredProviders: RegisteredProviders,
): registeredProviders is ReturnType<typeof buildProviderRegistry> {
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<typeof buildProviderRegistry>,
value: string,
): boolean {
return hasRegisteredProvider(providerRegistry, value);
}
export function coerceAgentProvider(
logger: LoggerLike,
providerRegistry: ReturnType<typeof buildProviderRegistry>,
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;
}

View File

@@ -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");
});
});

View File

@@ -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<string, Promise<ManagedAgent>>();
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<ScheduleExecutionResult> {
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<ManagedAgent> {
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);
}
}
}
}

View File

@@ -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<typeof buildProviderRegistry>,
value: string,
): boolean {
return Object.prototype.hasOwnProperty.call(providerRegistry, value);
}
function coerceAgentProvider(
logger: pino.Logger,
providerRegistry: ReturnType<typeof buildProviderRegistry>,
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<typeof buildProviderRegistry>,
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<ManagedAgent> {
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<string> {
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<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
): Promise<void> {
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) },

View File

@@ -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",

View File

@@ -259,6 +259,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
title: z.string().optional(),
description: z.string().optional(),
input: z.record(z.unknown()).optional(),
detail: z.lazy(() => 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(),
});

View File

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