refactor(agent): unify timeline events with tool_call schema and add resume/refresh capabilities

This commit is contained in:
Mohamed Boudra
2025-11-15 11:58:41 +01:00
parent 8cf808b14d
commit c37e499562
23 changed files with 2636 additions and 454 deletions
+42 -1
View File
@@ -13,7 +13,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { MoreVertical, GitBranch, Folder } from "lucide-react-native"; import { MoreVertical, GitBranch, Folder, RotateCcw } from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header"; import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area"; import { AgentInputArea } from "@/components/agent-input-area";
@@ -34,6 +34,7 @@ export default function AgentScreen() {
pendingPermissions, pendingPermissions,
respondToPermission, respondToPermission,
initializeAgent, initializeAgent,
refreshAgent,
} = useSession(); } = useSession();
const { registerFooterControls, unregisterFooterControls } = useFooterControls(); const { registerFooterControls, unregisterFooterControls } = useFooterControls();
const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const { width: windowWidth, height: windowHeight } = useWindowDimensions();
@@ -192,6 +193,18 @@ export default function AgentScreen() {
} }
}, [handleCloseMenu, id, router]); }, [handleCloseMenu, id, router]);
const handleRefreshAgent = useCallback(() => {
if (!id) {
return;
}
if (!agent?.persistence) {
handleCloseMenu();
return;
}
handleCloseMenu();
refreshAgent({ agentId: id });
}, [agent?.persistence, handleCloseMenu, id, refreshAgent]);
if (!agent) { if (!agent) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -268,6 +281,28 @@ export default function AgentScreen() {
<Folder size={20} color={theme.colors.foreground} /> <Folder size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>Browse Files</Text> <Text style={styles.menuItemText}>Browse Files</Text>
</Pressable> </Pressable>
{agent.persistence && (
<Pressable
onPress={handleRefreshAgent}
style={[
styles.menuItem,
isInitializing ? styles.menuItemDisabled : null,
]}
disabled={isInitializing}
>
<RotateCcw size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>
{isInitializing ? "Refreshing..." : "Refresh from disk"}
</Text>
{isInitializing && (
<ActivityIndicator
size="small"
color={theme.colors.primary}
style={styles.menuItemSpinner}
/>
)}
</Pressable>
)}
</View> </View>
</View> </View>
</Modal> </Modal>
@@ -334,6 +369,12 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
}, },
menuItemDisabled: {
opacity: 0.6,
},
menuItemSpinner: {
marginLeft: "auto",
},
menuItemText: { menuItemText: {
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
color: theme.colors.foreground, color: theme.colors.foreground,
+270 -51
View File
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Image, FlatList,
Image as RNImage,
ListRenderItemInfo,
NativeScrollEvent,
NativeSyntheticEvent,
Pressable, Pressable,
ScrollView, ScrollView,
Text, Text,
@@ -10,7 +14,15 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { Copy, Check, ArrowLeft } from "lucide-react-native"; import {
Copy,
Check,
ArrowLeft,
File,
FileText,
Folder,
Image as ImageIcon,
} from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header"; import { BackHeader } from "@/components/headers/back-header";
import { useSession, type ExplorerEntry } from "@/contexts/session-context"; import { useSession, type ExplorerEntry } from "@/contexts/session-context";
@@ -36,6 +48,8 @@ export default function FileExplorerScreen() {
const pendingFileParamRef = useRef<string | null>(null); const pendingFileParamRef = useRef<string | null>(null);
const [copiedPath, setCopiedPath] = useState<string | null>(null); const [copiedPath, setCopiedPath] = useState<string | null>(null);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const listScrollRef = useRef<FlatList<ExplorerEntry> | null>(null);
const listScrollOffsetRef = useRef(0);
const normalizedPathParam = normalizePathParam(getFirstParam(pathParamRaw)); const normalizedPathParam = normalizePathParam(getFirstParam(pathParamRaw));
const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw)); const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw));
@@ -47,9 +61,21 @@ export default function FileExplorerScreen() {
const agent = agentId ? agents.get(agentId) : undefined; const agent = agentId ? agents.get(agentId) : undefined;
const explorerState = agentId ? fileExplorer.get(agentId) : undefined; const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
const currentPath = explorerState?.currentPath ?? "."; const currentPath = explorerState?.currentPath ?? ".";
const directory = explorerState?.directories.get(currentPath); const pendingRequest = explorerState?.pendingRequest ?? null;
const isExplorerLoading = explorerState?.isLoading ?? false;
const isListingLoading = Boolean(
isExplorerLoading && pendingRequest?.mode === "list"
);
const pendingDirectoryPath =
isListingLoading && pendingRequest ? pendingRequest.path : null;
const activePath = pendingDirectoryPath ?? currentPath;
const directory = explorerState?.directories.get(activePath);
const entries = directory?.entries ?? []; const entries = directory?.entries ?? [];
const isLoading = explorerState?.isLoading ?? false; const showInitialListLoading = isListingLoading && entries.length === 0;
const showListLoadingBanner = isListingLoading && entries.length > 0;
const isPreviewLoading = Boolean(
isExplorerLoading && pendingRequest?.mode === "file"
);
const error = explorerState?.lastError ?? null; const error = explorerState?.lastError ?? null;
const preview = selectedEntryPath const preview = selectedEntryPath
? explorerState?.files.get(selectedEntryPath) ? explorerState?.files.get(selectedEntryPath)
@@ -58,21 +84,39 @@ export default function FileExplorerScreen() {
useEffect(() => { useEffect(() => {
setSelectedEntryPath(null); setSelectedEntryPath(null);
}, [currentPath]); }, [activePath]);
useEffect(() => {
if (shouldShowPreview) {
return;
}
const targetOffset = listScrollOffsetRef.current;
if (!listScrollRef.current) {
return;
}
listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false });
}, [shouldShowPreview]);
useEffect(() => {
listScrollOffsetRef.current = 0;
listScrollRef.current?.scrollToOffset({ offset: 0, animated: false });
}, [activePath]);
const parentPath = useMemo(() => { const parentPath = useMemo(() => {
if (currentPath === ".") { if (activePath === ".") {
return null; return null;
} }
const segments = currentPath.split("/"); const segments = activePath.split("/");
segments.pop(); segments.pop();
const nextPath = segments.join("/"); const nextPath = segments.join("/");
return nextPath.length === 0 ? "." : nextPath; return nextPath.length === 0 ? "." : nextPath;
}, [currentPath]); }, [activePath]);
useEffect(() => { useEffect(() => {
setCopiedPath(null); setCopiedPath(null);
}, [currentPath]); }, [activePath]);
useEffect(() => { useEffect(() => {
if (!agentId || !initialTargetDirectory) { if (!agentId || !initialTargetDirectory) {
@@ -163,6 +207,76 @@ export default function FileExplorerScreen() {
}; };
}, []); }, []);
const handleListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
listScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
},
[]
);
const renderEntry = useCallback(
({ item }: ListRenderItemInfo<ExplorerEntry>) => {
const displayKind = getEntryDisplayKind(item);
return (
<Pressable
style={[
styles.entryRow,
item.kind === "directory" ? styles.directoryRow : styles.fileRow,
]}
onPress={() => handleEntryPress(item)}
>
<View style={styles.entryInfo}>
<View style={styles.entryIcon}>
{renderEntryIcon(displayKind, theme.colors)}
</View>
<View style={styles.entryTextContainer}>
<Text style={styles.entryName}>{item.name}</Text>
<Text style={styles.entryMeta}>
{item.kind.toUpperCase()} · {formatFileSize({ size: item.size })} ·{" "}
{formatModifiedTime({ value: item.modifiedAt })}
</Text>
</View>
</View>
<Pressable
onPress={(event) => {
event.stopPropagation();
handleCopyPath(item.path);
}}
hitSlop={8}
style={styles.copyButton}
>
{copiedPath === item.path ? (
<Check size={16} color={theme.colors.primary} />
) : (
<Copy size={16} color={theme.colors.foreground} />
)}
</Pressable>
</Pressable>
);
},
[copiedPath, handleCopyPath, handleEntryPress, theme.colors]
);
const listHeaderComponent = useMemo(() => {
if (!parentPath && !showListLoadingBanner) {
return null;
}
return (
<View style={styles.headerContainer}>
{parentPath && <UpRow label=".." onPress={handleNavigateUp} />}
{showListLoadingBanner && (
<View style={styles.loadingBanner}>
<ActivityIndicator size="small" />
<Text style={styles.loadingBannerText}>
Loading {formatDirectoryLabel(activePath)}...
</Text>
</View>
)}
</View>
);
}, [activePath, handleNavigateUp, parentPath, showListLoadingBanner]);
if (!agent) { if (!agent) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -176,14 +290,14 @@ export default function FileExplorerScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader title={selectedEntryPath ?? (currentPath || ".")} /> <BackHeader title={selectedEntryPath ?? (activePath || ".")} />
<View style={styles.content}> <View style={styles.content}>
{shouldShowPreview ? ( {shouldShowPreview ? (
<View style={styles.previewWrapper}> <View style={styles.previewWrapper}>
<UpRow label="Back to directory" onPress={() => setSelectedEntryPath(null)} /> <UpRow label="Back to directory" onPress={() => setSelectedEntryPath(null)} />
<View style={styles.previewSection}> <View style={styles.previewSection}>
{isLoading && !preview ? ( {isPreviewLoading && !preview ? (
<View style={styles.centerState}> <View style={styles.centerState}>
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file...</Text> <Text style={styles.loadingText}>Loading file...</Text>
@@ -204,7 +318,7 @@ export default function FileExplorerScreen() {
</ScrollView> </ScrollView>
) : preview.kind === "image" && preview.content ? ( ) : preview.kind === "image" && preview.content ? (
<View style={styles.imagePreviewContainer}> <View style={styles.imagePreviewContainer}>
<Image <RNImage
source={{ source={{
uri: `data:${preview.mimeType ?? "image/png"};base64,${ uri: `data:${preview.mimeType ?? "image/png"};base64,${
preview.content preview.content
@@ -230,52 +344,30 @@ export default function FileExplorerScreen() {
<View style={styles.centerState}> <View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text> <Text style={styles.errorText}>{error}</Text>
</View> </View>
) : isLoading && entries.length === 0 ? ( ) : showInitialListLoading ? (
<View style={styles.centerState}> <View style={styles.centerState}>
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading...</Text> <Text style={styles.loadingText}>Loading directory...</Text>
</View> </View>
) : entries.length === 0 ? ( ) : entries.length === 0 ? (
<View style={styles.centerState}> <View style={styles.centerState}>
<Text style={styles.emptyText}>Directory is empty</Text> <Text style={styles.emptyText}>Directory is empty</Text>
</View> </View>
) : ( ) : (
<ScrollView contentContainerStyle={styles.entriesContent}> <FlatList
{parentPath && <UpRow label=".." onPress={handleNavigateUp} />} ref={listScrollRef}
{entries.map((entry) => ( data={entries}
<Pressable renderItem={renderEntry}
key={entry.path} keyExtractor={(item) => item.path}
style={[ contentContainerStyle={styles.entriesContent}
styles.entryRow, onScroll={handleListScroll}
entry.kind === "directory" scrollEventThrottle={16}
? styles.directoryRow ListHeaderComponent={listHeaderComponent}
: styles.fileRow, extraData={copiedPath}
]} initialNumToRender={20}
onPress={() => handleEntryPress(entry)} maxToRenderPerBatch={30}
> windowSize={10}
<View style={styles.entryTextContainer}> />
<Text style={styles.entryName}>{entry.name}</Text>
<Text style={styles.entryMeta}>
{entry.kind.toUpperCase()} · {formatFileSize({ size: entry.size })} · {formatModifiedTime({ value: entry.modifiedAt })}
</Text>
</View>
<Pressable
onPress={(event) => {
event.stopPropagation();
handleCopyPath(entry.path);
}}
hitSlop={8}
style={styles.copyButton}
>
{copiedPath === entry.path ? (
<Check size={16} color={theme.colors.primary} />
) : (
<Copy size={16} color={theme.colors.foreground} />
)}
</Pressable>
</Pressable>
))}
</ScrollView>
)} )}
</View> </View>
)} )}
@@ -284,6 +376,10 @@ export default function FileExplorerScreen() {
); );
} }
function formatDirectoryLabel(path: string): string {
return path === "." ? "workspace root" : path;
}
function formatFileSize({ size }: { size: number }): string { function formatFileSize({ size }: { size: number }): string {
if (size < 1024) { if (size < 1024) {
return `${size} B`; return `${size} B`;
@@ -351,6 +447,104 @@ function UpRow({ label, onPress }: { label: string; onPress: () => void }) {
); );
} }
type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"bmp",
"svg",
"webp",
"ico",
]);
const TEXT_EXTENSIONS = new Set([
"txt",
"md",
"markdown",
"ts",
"tsx",
"js",
"jsx",
"json",
"yml",
"yaml",
"toml",
"py",
"rb",
"go",
"rs",
"java",
"kt",
"c",
"cpp",
"cc",
"h",
"hpp",
"cs",
"swift",
"php",
"html",
"css",
"scss",
"less",
"xml",
"sh",
"bash",
"zsh",
"ini",
"cfg",
"conf",
]);
function renderEntryIcon(
kind: EntryDisplayKind,
colors: { foreground: string; primary: string }
) {
const color = colors.foreground;
switch (kind) {
case "directory":
return <Folder size={18} color={colors.primary} />;
case "image":
return <ImageIcon size={18} color={color} />;
case "text":
return <FileText size={18} color={color} />;
default:
return <File size={18} color={color} />;
}
}
function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind {
if (entry.kind === "directory") {
return "directory";
}
const extension = getExtension(entry.name);
if (extension === null) {
return "other";
}
if (IMAGE_EXTENSIONS.has(extension)) {
return "image";
}
if (TEXT_EXTENSIONS.has(extension)) {
return "text";
}
return "other";
}
function getExtension(name: string): string | null {
const index = name.lastIndexOf(".");
if (index === -1 || index === name.length - 1) {
return null;
}
return name.slice(index + 1).toLowerCase();
}
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
flex: 1, flex: 1,
@@ -369,6 +563,21 @@ const styles = StyleSheet.create((theme) => ({
entriesContent: { entriesContent: {
paddingBottom: theme.spacing[4], paddingBottom: theme.spacing[4],
}, },
headerContainer: {
gap: theme.spacing[2],
paddingBottom: theme.spacing[2],
},
loadingBanner: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
},
loadingBannerText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
previewWrapper: { previewWrapper: {
flex: 1, flex: 1,
gap: theme.spacing[2], gap: theme.spacing[2],
@@ -421,9 +630,19 @@ const styles = StyleSheet.create((theme) => ({
selectedRow: { selectedRow: {
borderColor: theme.colors.primary, borderColor: theme.colors.primary,
}, },
entryInfo: {
flex: 1,
flexDirection: "row",
alignItems: "center",
columnGap: theme.spacing[2],
marginRight: theme.spacing[3],
},
entryIcon: {
width: 28,
alignItems: "center",
},
entryTextContainer: { entryTextContainer: {
flex: 1, flex: 1,
marginRight: theme.spacing[3],
}, },
entryName: { entryName: {
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -15,6 +15,7 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import type { Agent } from "@/contexts/session-context"; import type { Agent } from "@/contexts/session-context";
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status"; import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
interface AgentSidebarProps { interface AgentSidebarProps {
isOpen: boolean; isOpen: boolean;
@@ -179,6 +180,7 @@ export function AgentSidebar({
const isActive = agent.id === activeAgentId; const isActive = agent.id === activeAgentId;
const statusColor = getAgentStatusColor(agent.status); const statusColor = getAgentStatusColor(agent.status);
const statusLabel = getAgentStatusLabel(agent.status); const statusLabel = getAgentStatusLabel(agent.status);
const providerLabel = getAgentProviderDefinition(agent.provider).label;
return ( return (
<Pressable <Pressable
@@ -209,6 +211,18 @@ export function AgentSidebar({
</Text> </Text>
<View style={styles.statusBadge}> <View style={styles.statusBadge}>
<View style={[styles.providerBadge, { backgroundColor: theme.colors.muted }]}>
<Text
style={[
styles.providerText,
{ color: theme.colors.mutedForeground },
]}
numberOfLines={1}
>
{providerLabel}
</Text>
</View>
<View <View
style={[styles.statusDot, { backgroundColor: statusColor }]} style={[styles.statusDot, { backgroundColor: statusColor }]}
/> />
@@ -309,6 +323,15 @@ const styles = StyleSheet.create({
alignItems: "center", alignItems: "center",
gap: 4, gap: 4,
}, },
providerBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
},
providerText: {
fontSize: 11,
fontWeight: "600",
},
statusDot: { statusDot: {
width: 6, width: 6,
height: 6, height: 6,
@@ -235,9 +235,11 @@ export function AgentStreamView({
if (payload.source === "agent") { if (payload.source === "agent") {
const data = payload.data; const data = payload.data;
const toolLabel = data.displayName ?? `${data.server}/${data.tool}`;
return ( return (
<ToolCall <ToolCall
toolName={`${data.server}/${data.tool}`} toolName={toolLabel}
kind={data.kind}
args={data.raw} args={data.raw}
status={data.status as "executing" | "completed" | "failed"} status={data.status as "executing" | "completed" | "failed"}
onOpenDetails={() => handleOpenToolCallDetails({ payload })} onOpenDetails={() => handleOpenToolCallDetails({ payload })}
@@ -5,12 +5,14 @@ import {
Text, Text,
Pressable, Pressable,
ScrollView, ScrollView,
FlatList,
ActivityIndicator, ActivityIndicator,
InteractionManager, InteractionManager,
TextInput, TextInput,
Modal, Modal,
useWindowDimensions, useWindowDimensions,
type LayoutChangeEvent, type LayoutChangeEvent,
type ListRenderItem,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
@@ -32,7 +34,8 @@ import {
AGENT_PROVIDER_DEFINITIONS, AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition, type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest"; } from "@server/server/agent/provider-manifest";
import type { AgentProvider, AgentMode, AgentSessionConfig } from "@server/server/agent/agent-sdk-types"; import type { AgentProvider, AgentMode, AgentSessionConfig, AgentPersistenceHandle } from "@server/server/agent/agent-sdk-types";
import type { WSInboundMessage } from "@server/server/messages";
interface CreateAgentModalProps { interface CreateAgentModalProps {
isVisible: boolean; isVisible: boolean;
@@ -48,6 +51,40 @@ const fallbackDefinition = providerDefinitions[0];
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude"; const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? ""; const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "";
const BACKDROP_OPACITY = 0.55; const BACKDROP_OPACITY = 0.55;
const RESUME_PAGE_SIZE = 20;
type ResumeCandidate = {
provider: AgentProvider;
sessionId: string;
cwd: string;
title: string;
lastActivityAt: Date;
persistence: AgentPersistenceHandle;
};
type ResumeTab = "new" | "resume";
type ProviderFilter = "all" | AgentProvider;
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diffMs = now - date.getTime();
if (!Number.isFinite(diffMs)) {
return "unknown";
}
const minutes = Math.max(0, Math.floor(diffMs / 60000));
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
export function CreateAgentModal({ export function CreateAgentModal({
isVisible, isVisible,
@@ -61,7 +98,7 @@ export function CreateAgentModal({
const isCompactLayout = screenWidth < 720; const isCompactLayout = screenWidth < 720;
const { recentPaths, addRecentPath } = useRecentPaths(); const { recentPaths, addRecentPath } = useRecentPaths();
const { ws, createAgent } = useSession(); const { ws, createAgent, resumeAgent } = useSession();
const router = useRouter(); const router = useRouter();
const [isMounted, setIsMounted] = useState(isVisible); const [isMounted, setIsMounted] = useState(isVisible);
@@ -73,12 +110,54 @@ export function CreateAgentModal({
const [worktreeName, setWorktreeName] = useState(""); const [worktreeName, setWorktreeName] = useState("");
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null); const [activeTab, setActiveTab] = useState<ResumeTab>("new");
const [resumeProviderFilter, setResumeProviderFilter] = useState<ProviderFilter>("all");
const [resumeSearchQuery, setResumeSearchQuery] = useState("");
const [resumeCandidates, setResumeCandidates] = useState<ResumeCandidate[]>([]);
const [isResumeLoading, setIsResumeLoading] = useState(false);
const [resumeError, setResumeError] = useState<string | null>(null);
const pendingRequestIdRef = useRef<string | null>(null);
const pendingNavigationAgentIdRef = useRef<string | null>(null); const pendingNavigationAgentIdRef = useRef<string | null>(null);
const tabOptions = useMemo(
() => [
{ id: "new" as ResumeTab, label: "New Agent" },
{ id: "resume" as ResumeTab, label: "Resume Agent" },
],
[]
);
const providerFilterOptions = useMemo(
() => [
{ id: "all" as ProviderFilter, label: "All" },
...providerDefinitions.map((definition) => ({
id: definition.id as ProviderFilter,
label: definition.label,
})),
],
[]
);
const getProviderLabel = useCallback(
(provider: AgentProvider) => providerDefinitionMap.get(provider)?.label ?? provider,
[]
);
const agentDefinition = providerDefinitionMap.get(selectedProvider); const agentDefinition = providerDefinitionMap.get(selectedProvider);
const modeOptions = agentDefinition?.modes ?? []; const modeOptions = agentDefinition?.modes ?? [];
const filteredResumeCandidates = useMemo(() => {
const providerFilter = resumeProviderFilter;
const query = resumeSearchQuery.trim().toLowerCase();
return resumeCandidates
.filter((candidate) => providerFilter === "all" || candidate.provider === providerFilter)
.filter((candidate) => {
if (query.length === 0) {
return true;
}
const titleText = candidate.title.toLowerCase();
const cwdText = candidate.cwd.toLowerCase();
return titleText.includes(query) || cwdText.includes(query);
})
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
}, [resumeCandidates, resumeProviderFilter, resumeSearchQuery]);
useEffect(() => { useEffect(() => {
if (!agentDefinition) { if (!agentDefinition) {
@@ -108,7 +187,7 @@ export function CreateAgentModal({
setSelectedMode(DEFAULT_MODE_FOR_DEFAULT_PROVIDER); setSelectedMode(DEFAULT_MODE_FOR_DEFAULT_PROVIDER);
setErrorMessage(""); setErrorMessage("");
setIsLoading(false); setIsLoading(false);
setPendingRequestId(null); pendingRequestIdRef.current = null;
}, []); }, []);
const navigateToAgentIfNeeded = useCallback(() => { const navigateToAgentIfNeeded = useCallback(() => {
@@ -130,6 +209,29 @@ export function CreateAgentModal({
navigateToAgentIfNeeded(); navigateToAgentIfNeeded();
}, [navigateToAgentIfNeeded, resetFormState]); }, [navigateToAgentIfNeeded, resetFormState]);
const requestResumeCandidates = useCallback(
(provider?: AgentProvider) => {
setIsResumeLoading(true);
setResumeError(null);
const msg: WSInboundMessage = {
type: "session",
message: {
type: "list_persisted_agents_request",
...(provider ? { provider } : {}),
limit: RESUME_PAGE_SIZE,
},
};
try {
ws.send(msg);
} catch (error) {
console.error("[CreateAgentModal] Failed to request persisted agents:", error);
setIsResumeLoading(false);
setResumeError("Unable to load saved agents. Please try again.");
}
},
[ws]
);
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
console.log("[CreateAgentModal] visibility effect skipped (isVisible is false)", { console.log("[CreateAgentModal] visibility effect skipped (isVisible is false)", {
@@ -284,8 +386,8 @@ export function CreateAgentModal({
const requestId = generateMessageId(); const requestId = generateMessageId();
pendingRequestIdRef.current = requestId;
setIsLoading(true); setIsLoading(true);
setPendingRequestId(requestId);
setErrorMessage(""); setErrorMessage("");
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
@@ -306,7 +408,7 @@ export function CreateAgentModal({
console.error("[CreateAgentModal] Failed to create agent:", error); console.error("[CreateAgentModal] Failed to create agent:", error);
setErrorMessage("Failed to create agent. Please try again."); setErrorMessage("Failed to create agent. Please try again.");
setIsLoading(false); setIsLoading(false);
setPendingRequestId(null); pendingRequestIdRef.current = null;
} }
}, [ }, [
workingDir, workingDir,
@@ -319,26 +421,96 @@ export function CreateAgentModal({
addRecentPath, addRecentPath,
createAgent, createAgent,
]); ]);
const handleResumeCandidatePress = useCallback(
(candidate: ResumeCandidate) => {
if (isLoading) {
return;
}
setErrorMessage("");
const requestId = generateMessageId();
pendingRequestIdRef.current = requestId;
setIsLoading(true);
resumeAgent({
handle: candidate.persistence,
requestId,
});
},
[isLoading, resumeAgent]
);
const renderResumeItem = useCallback<ListRenderItem<ResumeCandidate>>(
({ item }) => (
<Pressable
onPress={() => handleResumeCandidatePress(item)}
disabled={isLoading}
style={styles.resumeItem}
>
<View style={styles.resumeItemHeader}>
<Text style={styles.resumeItemTitle} numberOfLines={1}>
{item.title}
</Text>
<Text style={styles.resumeItemTimestamp}>{formatRelativeTime(item.lastActivityAt)}</Text>
</View>
<Text style={styles.resumeItemPath} numberOfLines={1}>
{item.cwd}
</Text>
<View style={styles.resumeItemMetaRow}>
<View style={styles.resumeProviderBadge}>
<Text style={styles.resumeProviderBadgeText}>{getProviderLabel(item.provider)}</Text>
</View>
<Text style={styles.resumeItemHint}>Tap to resume</Text>
</View>
</Pressable>
),
[getProviderLabel, handleResumeCandidatePress, isLoading]
);
useEffect(() => { useEffect(() => {
if (!pendingRequestId) { const unsubscribe = ws.on("list_persisted_agents_response", (message) => {
return; if (message.type !== "list_persisted_agents_response") {
} return;
}
const mapped = message.payload.items.map((item) => ({
provider: item.provider,
sessionId: item.sessionId,
cwd: item.cwd,
title: item.title ?? `Session ${item.sessionId.slice(0, 8)}`,
lastActivityAt: new Date(item.lastActivityAt),
persistence: item.persistence,
})) as ResumeCandidate[];
setResumeCandidates(mapped);
setIsResumeLoading(false);
});
return () => {
unsubscribe();
};
}, [ws]);
useEffect(() => {
const unsubscribe = ws.on("status", (message) => { const unsubscribe = ws.on("status", (message) => {
if (message.type !== "status") { if (message.type !== "status") {
return; return;
} }
const payload = message.payload as { status: string; agentId?: string; requestId?: string }; const payload = message.payload as { status: string; agentId?: string; requestId?: string };
if (payload.status !== "agent_created") { if (
(payload.status !== "agent_created" && payload.status !== "agent_resumed") ||
!payload.agentId
) {
return; return;
} }
if (payload.requestId !== pendingRequestId || !payload.agentId) {
const expectedRequestId = pendingRequestIdRef.current;
if (!expectedRequestId || payload.requestId !== expectedRequestId) {
return; return;
} }
console.log("[CreateAgentModal] Agent created:", payload.agentId); console.log("[CreateAgentModal] Agent created:", payload.agentId);
pendingRequestIdRef.current = null;
setIsLoading(false); setIsLoading(false);
setPendingRequestId(null);
pendingNavigationAgentIdRef.current = payload.agentId; pendingNavigationAgentIdRef.current = payload.agentId;
handleClose(); handleClose();
}); });
@@ -346,7 +518,20 @@ export function CreateAgentModal({
return () => { return () => {
unsubscribe(); unsubscribe();
}; };
}, [pendingRequestId, ws, handleClose]); }, [ws, handleClose]);
useEffect(() => {
if (!isVisible || activeTab !== "resume") {
return;
}
const provider = resumeProviderFilter === "all" ? undefined : resumeProviderFilter;
requestResumeCandidates(provider);
}, [activeTab, isVisible, requestResumeCandidates, resumeProviderFilter]);
const refreshResumeList = useCallback(() => {
const provider = resumeProviderFilter === "all" ? undefined : resumeProviderFilter;
requestResumeCandidates(provider);
}, [requestResumeCandidates, resumeProviderFilter]);
const shouldRender = isVisible || isMounted; const shouldRender = isVisible || isMounted;
@@ -395,93 +580,194 @@ export function CreateAgentModal({
paddingLeft={horizontalPaddingLeft} paddingLeft={horizontalPaddingLeft}
paddingRight={horizontalPaddingRight} paddingRight={horizontalPaddingRight}
onClose={handleClose} onClose={handleClose}
title={activeTab === "resume" ? "Resume Agent" : "Create New Agent"}
/> />
<ScrollView <View
style={styles.scroll} style={[
contentContainerStyle={[ styles.tabSelector,
styles.scrollContent,
{ {
paddingBottom: insets.bottom + defaultTheme.spacing[16],
paddingLeft: horizontalPaddingLeft, paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight, paddingRight: horizontalPaddingRight,
}, },
]} ]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
> >
<WorkingDirectorySection {tabOptions.map((tab) => {
errorMessage={errorMessage} const isActive = activeTab === tab.id;
isLoading={isLoading} return (
recentPaths={recentPaths} <Pressable
workingDir={workingDir} key={tab.id}
onChangeWorkingDir={(value) => { onPress={() => setActiveTab(tab.id)}
setWorkingDir(value); style={[styles.tabButton, isActive && styles.tabButtonActive]}
setErrorMessage(""); >
}} <Text style={[styles.tabButtonText, isActive && styles.tabButtonTextActive]}>{tab.label}</Text>
/> </Pressable>
);
})}
</View>
{activeTab === "new" ? (
<>
<ScrollView
style={styles.scroll}
contentContainerStyle={[
styles.scrollContent,
{
paddingBottom: insets.bottom + defaultTheme.spacing[16],
paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight,
},
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<WorkingDirectorySection
errorMessage={errorMessage}
isLoading={isLoading}
recentPaths={recentPaths}
workingDir={workingDir}
onChangeWorkingDir={(value) => {
setWorkingDir(value);
setErrorMessage("");
}}
/>
<View
style={[
styles.selectorRow,
isCompactLayout && styles.selectorRowStacked,
]}
>
<AssistantSelector
providerDefinitions={providerDefinitions}
disabled={isLoading}
isStacked={isCompactLayout}
selectedProvider={selectedProvider}
onSelect={setSelectedProvider}
/>
<ModeSelector
disabled={isLoading}
isStacked={isCompactLayout}
modeOptions={modeOptions}
selectedMode={selectedMode}
onSelect={setSelectedMode}
/>
</View>
<WorktreeSection
isLoading={isLoading}
value={worktreeName}
onChange={(text) => {
const slugified = slugifyWorktreeName(text);
setWorktreeName(slugified);
setErrorMessage("");
}}
/>
</ScrollView>
<Animated.View
style={[
styles.footer,
{
paddingBottom: insets.bottom + defaultTheme.spacing[4],
paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight,
},
footerAnimatedStyle,
]}
>
<Pressable
style={[
styles.createButton,
(workingDirIsEmpty || isLoading) && styles.createButtonDisabled,
]}
onPress={handleCreate}
disabled={workingDirIsEmpty || isLoading}
>
{isLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={defaultTheme.colors.palette.white} />
<Text style={styles.createButtonText}>Creating...</Text>
</View>
) : (
<Text style={styles.createButtonText}>Create Agent</Text>
)}
</Pressable>
</Animated.View>
</>
) : (
<View <View
style={[ style={[
styles.selectorRow, styles.resumeContainer,
isCompactLayout && styles.selectorRowStacked, {
paddingLeft: horizontalPaddingLeft,
paddingRight: horizontalPaddingRight,
paddingBottom: insets.bottom + defaultTheme.spacing[4],
},
]} ]}
> >
<AssistantSelector <View style={styles.resumeFilters}>
providerDefinitions={providerDefinitions} <View style={styles.providerFilterRow}>
disabled={isLoading} {providerFilterOptions.map((option) => {
isStacked={isCompactLayout} const isActive = resumeProviderFilter === option.id;
selectedProvider={selectedProvider} return (
onSelect={setSelectedProvider} <Pressable
/> key={option.id}
<ModeSelector onPress={() => setResumeProviderFilter(option.id)}
disabled={isLoading} style={[styles.providerFilterButton, isActive && styles.providerFilterButtonActive]}
isStacked={isCompactLayout} >
modeOptions={modeOptions} <Text
selectedMode={selectedMode} style={[
onSelect={setSelectedMode} styles.providerFilterText,
/> isActive && styles.providerFilterTextActive,
</View> ]}
>
<WorktreeSection {option.label}
isLoading={isLoading} </Text>
value={worktreeName} </Pressable>
onChange={(text) => { );
const slugified = slugifyWorktreeName(text); })}
setWorktreeName(slugified); </View>
setErrorMessage(""); <View style={styles.resumeSearchRow}>
}} <TextInput
/> style={styles.resumeSearchInput}
</ScrollView> placeholder="Search by title or path"
placeholderTextColor={defaultTheme.colors.mutedForeground}
<Animated.View value={resumeSearchQuery}
style={[ onChangeText={setResumeSearchQuery}
styles.footer, />
{ <Pressable style={styles.refreshButton} onPress={refreshResumeList} disabled={isResumeLoading}>
paddingBottom: insets.bottom + defaultTheme.spacing[4], <Text style={styles.refreshButtonText}>Refresh</Text>
paddingLeft: horizontalPaddingLeft, </Pressable>
paddingRight: horizontalPaddingRight, </View>
}, </View>
footerAnimatedStyle, {resumeError ? <Text style={styles.resumeErrorText}>{resumeError}</Text> : null}
]} {isResumeLoading ? (
> <View style={styles.resumeLoading}>
<Pressable <ActivityIndicator color={defaultTheme.colors.mutedForeground} />
style={[ <Text style={styles.resumeLoadingText}>Loading saved agents...</Text>
styles.createButton, </View>
(workingDirIsEmpty || isLoading) && styles.createButtonDisabled, ) : filteredResumeCandidates.length === 0 ? (
]} <View style={styles.resumeEmptyState}>
onPress={handleCreate} <Text style={styles.resumeEmptyTitle}>No agents found</Text>
disabled={workingDirIsEmpty || isLoading} <Text style={styles.resumeEmptySubtitle}>
> We'll load the latest Claude and Codex sessions from your local history.
{isLoading ? ( </Text>
<View style={styles.loadingContainer}> <Pressable style={styles.refreshButtonAlt} onPress={refreshResumeList}>
<ActivityIndicator color={defaultTheme.colors.palette.white} /> <Text style={styles.refreshButtonAltText}>Try Again</Text>
<Text style={styles.createButtonText}>Creating...</Text> </Pressable>
</View> </View>
) : ( ) : (
<Text style={styles.createButtonText}>Create Agent</Text> <FlatList
data={filteredResumeCandidates}
renderItem={renderResumeItem}
keyExtractor={(item) => `${item.provider}:${item.sessionId}`}
ItemSeparatorComponent={() => <View style={styles.resumeItemSeparator} />}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.resumeListContent}
/>
)} )}
</Pressable> </View>
</Animated.View> )}
</View> </View>
</Animated.View> </Animated.View>
</View> </View>
@@ -494,12 +780,13 @@ interface ModalHeaderProps {
paddingLeft: number; paddingLeft: number;
paddingRight: number; paddingRight: number;
onClose: () => void; onClose: () => void;
title: string;
} }
function ModalHeader({ paddingTop, paddingLeft, paddingRight, onClose }: ModalHeaderProps): ReactElement { function ModalHeader({ paddingTop, paddingLeft, paddingRight, onClose, title }: ModalHeaderProps): ReactElement {
return ( return (
<View style={[styles.header, { paddingTop, paddingLeft, paddingRight }]}> <View style={[styles.header, { paddingTop, paddingLeft, paddingRight }]}>
<Text style={styles.headerTitle}>Create New Agent</Text> <Text style={styles.headerTitle}>{title}</Text>
<Pressable onPress={onClose} style={styles.closeButton} hitSlop={8}> <Pressable onPress={onClose} style={styles.closeButton} hitSlop={8}>
<X size={20} color={defaultTheme.colors.mutedForeground} /> <X size={20} color={defaultTheme.colors.mutedForeground} />
</Pressable> </Pressable>
@@ -742,6 +1029,35 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
tabSelector: {
flexDirection: "row",
gap: theme.spacing[2],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
tabButton: {
flex: 1,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingVertical: theme.spacing[3],
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.background,
},
tabButtonActive: {
backgroundColor: theme.colors.muted,
borderColor: theme.colors.palette.blue[500],
},
tabButtonText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
tabButtonTextActive: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
scroll: { scroll: {
flex: 1, flex: 1,
}, },
@@ -894,4 +1210,161 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
resumeContainer: {
flex: 1,
gap: theme.spacing[4],
},
resumeFilters: {
gap: theme.spacing[3],
},
providerFilterRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerFilterButton: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.background,
},
providerFilterButtonActive: {
borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.muted,
},
providerFilterText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
providerFilterTextActive: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
resumeSearchRow: {
flexDirection: "row",
gap: theme.spacing[3],
alignItems: "center",
},
resumeSearchInput: {
flex: 1,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.background,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
},
refreshButton: {
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
},
refreshButtonText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
resumeErrorText: {
color: theme.colors.palette.red[500],
fontSize: theme.fontSize.sm,
},
resumeLoading: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
resumeLoadingText: {
color: theme.colors.mutedForeground,
},
resumeEmptyState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[6],
textAlign: "center",
},
resumeEmptyTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
textAlign: "center",
},
resumeEmptySubtitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
refreshButtonAlt: {
marginTop: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.palette.blue[500],
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[3],
},
refreshButtonAltText: {
color: theme.colors.palette.white,
fontWeight: theme.fontWeight.semibold,
},
resumeListContent: {
paddingBottom: theme.spacing[8],
gap: theme.spacing[2],
},
resumeItem: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[4],
backgroundColor: theme.colors.background,
gap: theme.spacing[2],
},
resumeItemHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
gap: theme.spacing[2],
},
resumeItemTitle: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
flex: 1,
},
resumeItemTimestamp: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
resumeItemPath: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
resumeItemMetaRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
resumeProviderBadge: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.muted,
},
resumeProviderBadgeText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.semibold,
},
resumeItemHint: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.xs,
},
resumeItemSeparator: {
height: theme.spacing[2],
},
})); }));
+2
View File
@@ -17,6 +17,7 @@ import {
Eye, Eye,
SquareTerminal, SquareTerminal,
Brain, Brain,
Search,
} from "lucide-react-native"; } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme"; import { baseColors, theme } from "@/styles/theme";
@@ -778,6 +779,7 @@ const toolKindIcons: Record<string, any> = {
edit: Pencil, edit: Pencil,
read: Eye, read: Eye,
execute: SquareTerminal, execute: SquareTerminal,
search: Search,
// Add more mappings as needed // Add more mappings as needed
}; };
@@ -212,10 +212,10 @@ export function ToolCallBottomSheet({
if (payload.source === "agent") { if (payload.source === "agent") {
const data = payload.data; const data = payload.data;
return { return {
toolName: `${data.server}/${data.tool}`, toolName: data.displayName ?? `${data.server}/${data.tool}`,
args: data.raw, args: data.raw,
result: undefined, result: data.result,
error: undefined, error: data.error,
}; };
} }
@@ -210,9 +210,11 @@ interface SessionContextValue {
// Helpers // Helpers
initializeAgent: (params: { agentId: string; requestId?: string }) => void; initializeAgent: (params: { agentId: string; requestId?: string }) => void;
refreshAgent: (params: { agentId: string; requestId?: string }) => void;
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>; sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<void>; sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<void>;
createAgent: (options: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => void; createAgent: (options: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => void;
resumeAgent: (options: { handle: AgentPersistenceHandle; overrides?: Partial<AgentSessionConfig>; requestId?: string }) => void;
setAgentMode: (agentId: string, modeId: string) => void; setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (agentId: string, requestId: string, response: AgentPermissionResponse) => void; respondToPermission: (agentId: string, requestId: string, response: AgentPermissionResponse) => void;
} }
@@ -729,6 +731,30 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg); ws.send(msg);
}, [ws]); }, [ws]);
const refreshAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => {
setInitializingAgents((prev) => {
const next = new Map(prev);
next.set(agentId, true);
return next;
});
setAgentStreamState((prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
const msg: WSInboundMessage = {
type: "session",
message: {
type: "refresh_agent_request",
agentId,
requestId,
},
};
ws.send(msg);
}, [ws]);
const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => { const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => {
// Generate unique message ID for deduplication // Generate unique message ID for deduplication
const messageId = generateMessageId(); const messageId = generateMessageId();
@@ -832,6 +858,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg); ws.send(msg);
}, [ws]); }, [ws]);
const resumeAgent = useCallback(({ handle, overrides, requestId }: { handle: AgentPersistenceHandle; overrides?: Partial<AgentSessionConfig>; requestId?: string }) => {
const msg: WSInboundMessage = {
type: "session",
message: {
type: "resume_agent_request",
handle,
...(overrides ? { overrides } : {}),
...(requestId ? { requestId } : {}),
},
};
ws.send(msg);
}, [ws]);
const setAgentMode = useCallback((agentId: string, modeId: string) => { const setAgentMode = useCallback((agentId: string, modeId: string) => {
const msg: WSInboundMessage = { const msg: WSInboundMessage = {
type: "session", type: "session",
@@ -941,9 +980,11 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
requestDirectoryListing, requestDirectoryListing,
requestFilePreview, requestFilePreview,
initializeAgent, initializeAgent,
refreshAgent,
sendAgentMessage, sendAgentMessage,
sendAgentAudio, sendAgentAudio,
createAgent, createAgent,
resumeAgent,
setAgentMode, setAgentMode,
respondToPermission, respondToPermission,
}; };
+206 -126
View File
@@ -68,6 +68,11 @@ export interface AgentToolCallData {
tool: string; tool: string;
status: string; status: string;
raw?: unknown; raw?: unknown;
callId?: string;
displayName?: string;
kind?: string;
result?: unknown;
error?: unknown;
} }
export type ToolCallPayload = export type ToolCallPayload =
@@ -81,6 +86,10 @@ export interface ToolCallItem {
payload: ToolCallPayload; payload: ToolCallPayload;
} }
type AgentToolCallItem = ToolCallItem & {
payload: { source: "agent"; data: AgentToolCallData };
};
type ActivityLogType = "system" | "info" | "success" | "error"; type ActivityLogType = "system" | "info" | "success" | "error";
export interface ActivityLogItem { export interface ActivityLogItem {
@@ -92,12 +101,54 @@ export interface ActivityLogItem {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} }
type FileChangeEntry = { path: string; kind: string };
type TodoEntry = { text: string; completed: boolean }; type TodoEntry = { text: string; completed: boolean };
function normalizeChunk(text: string): { chunk: string; hasContent: boolean } {
if (!text) {
return { chunk: "", hasContent: false };
}
const chunk = text.replace(/\r/g, "");
if (!chunk) {
return { chunk: "", hasContent: false };
}
return { chunk, hasContent: /\S/.test(chunk) };
}
function appendUserMessage(
state: StreamItem[],
text: string,
timestamp: Date,
messageId?: string
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
if (!hasContent) {
return state;
}
const entryId = messageId ?? createTimelineId("user", chunk.trim() || chunk, timestamp);
const existingIndex = state.findIndex(
(entry) => entry.kind === "user_message" && entry.id === entryId
);
const nextItem: UserMessageItem = {
kind: "user_message",
id: entryId,
text: chunk,
timestamp,
};
if (existingIndex >= 0) {
const next = [...state];
next[existingIndex] = nextItem;
return next;
}
return [...state, nextItem];
}
function appendAssistantMessage(state: StreamItem[], text: string, timestamp: Date): StreamItem[] { function appendAssistantMessage(state: StreamItem[], text: string, timestamp: Date): StreamItem[] {
const trimmed = text.trim(); const { chunk, hasContent } = normalizeChunk(text);
if (!trimmed) { if (!chunk) {
return state; return state;
} }
@@ -105,24 +156,29 @@ function appendAssistantMessage(state: StreamItem[], text: string, timestamp: Da
if (last && last.kind === "assistant_message") { if (last && last.kind === "assistant_message") {
const updated: AssistantMessageItem = { const updated: AssistantMessageItem = {
...last, ...last,
text: `${last.text}${trimmed}`, text: `${last.text}${chunk}`,
timestamp, timestamp,
}; };
return [...state.slice(0, -1), updated]; return [...state.slice(0, -1), updated];
} }
if (!hasContent) {
return state;
}
const idSeed = chunk.trim() || chunk;
const item: AssistantMessageItem = { const item: AssistantMessageItem = {
kind: "assistant_message", kind: "assistant_message",
id: createTimelineId("assistant", trimmed, timestamp), id: createTimelineId("assistant", idSeed, timestamp),
text: trimmed, text: chunk,
timestamp, timestamp,
}; };
return [...state, item]; return [...state, item];
} }
function appendThought(state: StreamItem[], text: string, timestamp: Date): StreamItem[] { function appendThought(state: StreamItem[], text: string, timestamp: Date): StreamItem[] {
const trimmed = text.trim(); const { chunk, hasContent } = normalizeChunk(text);
if (!trimmed) { if (!chunk) {
return state; return state;
} }
@@ -130,16 +186,21 @@ function appendThought(state: StreamItem[], text: string, timestamp: Date): Stre
if (last && last.kind === "thought") { if (last && last.kind === "thought") {
const updated: ThoughtItem = { const updated: ThoughtItem = {
...last, ...last,
text: `${last.text}${trimmed}`, text: `${last.text}${chunk}`,
timestamp, timestamp,
}; };
return [...state.slice(0, -1), updated]; return [...state.slice(0, -1), updated];
} }
if (!hasContent) {
return state;
}
const idSeed = chunk.trim() || chunk;
const item: ThoughtItem = { const item: ThoughtItem = {
kind: "thought", kind: "thought",
id: createTimelineId("thought", trimmed, timestamp), id: createTimelineId("thought", idSeed, timestamp),
text: trimmed, text: chunk,
timestamp, timestamp,
}; };
return [...state, item]; return [...state, item];
@@ -150,19 +211,56 @@ function appendAgentToolCall(
data: AgentToolCallData, data: AgentToolCallData,
timestamp: Date timestamp: Date
): StreamItem[] { ): StreamItem[] {
const status = data.status; const normalizedStatus = normalizeToolCallStatus(data.status);
const id = createTimelineId( const callId = data.callId ?? extractToolCallId(data.raw);
"tool",
`${data.provider}:${data.server}:${data.tool}:${status}`,
timestamp
);
const normalizedStatus: "executing" | "completed" | "failed" = const payloadData: AgentToolCallData = {
status === "completed" ...data,
? "completed" status: normalizedStatus,
: status === "failed" callId: callId ?? data.callId,
? "failed" };
: "executing";
if (callId) {
const existingIndex = state.findIndex(
(entry) =>
entry.kind === "tool_call" &&
entry.payload.source === "agent" &&
entry.payload.data.callId === callId
);
if (existingIndex >= 0) {
const next = [...state];
const existing = next[existingIndex] as AgentToolCallItem;
const mergedRaw =
existing.payload.data.raw !== undefined && existing.payload.data.raw !== null
? existing.payload.data.raw
: payloadData.raw;
next[existingIndex] = {
...existing,
timestamp,
payload: {
source: "agent",
data: {
...existing.payload.data,
...payloadData,
raw: mergedRaw,
displayName: payloadData.displayName ?? existing.payload.data.displayName,
kind: payloadData.kind ?? existing.payload.data.kind,
callId,
},
},
};
return next;
}
}
const id = callId
? `agent_tool_${callId}`
: createTimelineId(
"tool",
`${data.provider}:${data.server}:${data.tool}`,
timestamp
);
const item: ToolCallItem = { const item: ToolCallItem = {
kind: "tool_call", kind: "tool_call",
@@ -170,21 +268,82 @@ function appendAgentToolCall(
timestamp, timestamp,
payload: { payload: {
source: "agent", source: "agent",
data: { ...data, status: normalizedStatus }, data: payloadData,
}, },
}; };
// Replace existing entry with same ID if present
const index = state.findIndex((entry) => entry.id === id);
if (index >= 0) {
const next = [...state];
next[index] = item;
return next;
}
return [...state, item]; return [...state, item];
} }
function isPermissionToolCall(raw: unknown): boolean {
if (!raw || typeof raw !== "object") {
return false;
}
const candidate = raw as { server?: string; kind?: string };
return candidate.server === "permission" || candidate.kind === "permission";
}
function normalizeToolCallStatus(status?: string): "executing" | "completed" | "failed" {
if (!status) {
return "executing";
}
const normalized = status.toLowerCase();
if (/fail|error|deny|reject|cancel/.test(normalized)) {
return "failed";
}
if (/complete|success|granted|applied|done|resolved/.test(normalized)) {
return "completed";
}
return "executing";
}
const TOOL_CALL_ID_KEYS = [
"toolCallId",
"tool_call_id",
"callId",
"call_id",
"tool_use_id",
"toolUseId",
];
function extractToolCallId(raw: unknown, depth = 0): string | null {
if (!raw || depth > 4) {
return null;
}
if (typeof raw === "string" || typeof raw === "number") {
return null;
}
if (Array.isArray(raw)) {
for (const entry of raw) {
const nested = extractToolCallId(entry, depth + 1);
if (nested) {
return nested;
}
}
return null;
}
if (typeof raw === "object") {
const record = raw as Record<string, unknown>;
for (const key of TOOL_CALL_ID_KEYS) {
const value = record[key];
if (typeof value === "string" && value.length > 0) {
return value;
}
}
const idValue = record.id;
if (typeof idValue === "string" && /tool|call/i.test(idValue)) {
return idValue;
}
for (const value of Object.values(record)) {
const nested = extractToolCallId(value, depth + 1);
if (nested) {
return nested;
}
}
}
return null;
}
function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamItem[] { function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamItem[] {
const index = state.findIndex((existing) => existing.id === entry.id); const index = state.findIndex((existing) => existing.id === entry.id);
if (index >= 0) { if (index >= 0) {
@@ -195,64 +354,6 @@ function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamI
return [...state, entry]; return [...state, entry];
} }
function toHumanLabel(value?: string): string {
if (!value) {
return "";
}
return value
.replace(/[_-]+/g, " ")
.split(" ")
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function describeCommandStatus(status?: string): ActivityLogType {
if (!status) {
return "info";
}
const normalized = status.toLowerCase();
if (
normalized.includes("fail") ||
normalized.includes("error") ||
normalized.includes("deny") ||
normalized.includes("reject")
) {
return "error";
}
if (
normalized.includes("success") ||
normalized.includes("complete") ||
normalized.includes("granted") ||
normalized.includes("applied")
) {
return "success";
}
return "info";
}
function formatCommandMessage(command: string, status?: string): string {
const label = status ? toHumanLabel(status) : null;
const header = label ? `Command (${label})` : "Command";
return `${header}\n${command}`;
}
function formatFileChangeMessage(files: FileChangeEntry[]): string {
if (!files.length) {
return "File changes";
}
const header = files.length === 1 ? "File change" : `${files.length} file changes`;
const entries = files.map((file) => {
const kindLabel = file.kind ? toHumanLabel(file.kind) : "";
return `• ${kindLabel ? `[${kindLabel}] ` : ""}${file.path}`;
});
return [header, ...entries].join("\n");
}
function formatWebSearchMessage(query: string): string {
return `Web search\n"${query.trim()}"`;
}
function formatTodoMessage(items: TodoEntry[]): string { function formatTodoMessage(items: TodoEntry[]): string {
if (!items.length) { if (!items.length) {
return "Todo list"; return "Todo list";
@@ -278,55 +379,34 @@ export function reduceStreamUpdate(
case "timeline": { case "timeline": {
const item = event.item; const item = event.item;
switch (item.type) { switch (item.type) {
case "user_message":
return appendUserMessage(state, item.text, timestamp, item.messageId);
case "assistant_message": case "assistant_message":
return appendAssistantMessage(state, item.text, timestamp); return appendAssistantMessage(state, item.text, timestamp);
case "reasoning": case "reasoning":
return appendThought(state, item.text, timestamp); return appendThought(state, item.text, timestamp);
case "mcp_tool": case "tool_call": {
if (isPermissionToolCall(item)) {
return state;
}
const rawPayload =
item.raw ?? { input: item.input, output: item.output, error: item.error };
return appendAgentToolCall( return appendAgentToolCall(
state, state,
{ {
provider: event.provider, provider: event.provider,
server: item.server, server: item.server,
tool: item.tool, tool: item.tool,
status: item.status, status: item.status ?? "executing",
raw: item.raw, raw: rawPayload,
callId: item.callId,
displayName: item.displayName,
kind: item.kind,
result: item.output,
error: item.error,
}, },
timestamp timestamp
); );
case "command": {
const activity: ActivityLogItem = {
kind: "activity_log",
id: createTimelineId("command", `${item.command}:${item.status ?? ""}`, timestamp),
timestamp,
activityType: describeCommandStatus(item.status),
message: formatCommandMessage(item.command, item.status),
metadata: item.raw ? { raw: item.raw } : undefined,
};
return appendActivityLog(state, activity);
}
case "file_change": {
const files = (item.files ?? []) as FileChangeEntry[];
const activity: ActivityLogItem = {
kind: "activity_log",
id: createTimelineId("file_change", JSON.stringify(files), timestamp),
timestamp,
activityType: "info",
message: formatFileChangeMessage(files),
metadata: files.length ? { files } : undefined,
};
return appendActivityLog(state, activity);
}
case "web_search": {
const activity: ActivityLogItem = {
kind: "activity_log",
id: createTimelineId("web_search", item.query ?? "", timestamp),
timestamp,
activityType: "info",
message: formatWebSearchMessage(item.query ?? ""),
metadata: item.raw ? { raw: item.raw } : { query: item.query },
};
return appendActivityLog(state, activity);
} }
case "todo": { case "todo": {
const items = (item.items ?? []) as TodoEntry[]; const items = (item.items ?? []) as TodoEntry[];
@@ -46,33 +46,51 @@ export function curateAgentActivity(
for (const item of recentItems) { for (const item of recentItems) {
switch (item.type) { switch (item.type) {
case "user_message":
flushBuffers(lines, buffers);
lines.push(`[User] ${item.text.trim()}`);
break;
case "assistant_message": case "assistant_message":
buffers.message = appendText(buffers.message, item.text); buffers.message = appendText(buffers.message, item.text);
break; break;
case "reasoning": case "reasoning":
buffers.thought = appendText(buffers.thought, item.text); buffers.thought = appendText(buffers.thought, item.text);
break; break;
case "command": case "tool_call": {
flushBuffers(lines, buffers); flushBuffers(lines, buffers);
lines.push(`[Command: ${item.command}] ${item.status}`); const label =
break; item.displayName ??
case "file_change": (item.server && item.tool ? `${item.server}.${item.tool}` : item.tool ?? item.server ?? "Tool");
flushBuffers(lines, buffers); const status = item.status ? ` ${item.status}` : "";
if (item.files.length > 0) { if (item.kind === "execute" || item.server === "command") {
lines.push("[File Changes]"); lines.push(`[Command: ${label}]${status}`);
for (const file of item.files) { } else if (item.kind === "edit" || item.server === "file_change") {
lines.push(`- (${file.kind}) ${file.path}`); const files =
(item.output &&
typeof item.output === "object" &&
item.output !== null &&
Array.isArray((item.output as Record<string, unknown>).files)
? ((item.output as Record<string, unknown>).files as { path: string; kind: string }[])
: []) ?? [];
if (files.length > 0) {
lines.push("[File Changes]");
for (const file of files) {
lines.push(`- (${file.kind}) ${file.path}`);
}
} else {
lines.push(`[Edit] ${label}${status}`);
} }
} else if (item.kind === "search" || item.server === "web_search") {
const query =
typeof item.input === "object" && item.input !== null && "query" in (item.input as Record<string, unknown>)
? String((item.input as Record<string, unknown>).query ?? "")
: "";
lines.push(`[Web Search] ${query || label}`);
} else {
lines.push(`[Tool ${item.server}.${item.tool}]${status}`);
} }
break; break;
case "mcp_tool": }
flushBuffers(lines, buffers);
lines.push(`[MCP ${item.server}.${item.tool}] ${item.status}`);
break;
case "web_search":
flushBuffers(lines, buffers);
lines.push(`[Web Search] ${item.query}`);
break;
case "todo": case "todo":
flushBuffers(lines, buffers); flushBuffers(lines, buffers);
lines.push("[Plan]"); lines.push("[Plan]");
@@ -16,6 +16,8 @@ import type {
AgentStreamEvent, AgentStreamEvent,
AgentTimelineItem, AgentTimelineItem,
AgentUsage, AgentUsage,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
export type AgentLifecycleStatus = export type AgentLifecycleStatus =
@@ -53,6 +55,10 @@ export type SubscribeOptions = {
replayState?: boolean; replayState?: boolean;
}; };
export type PersistedAgentQueryOptions = ListPersistedAgentsOptions & {
provider?: AgentProvider;
};
export type AgentManagerOptions = { export type AgentManagerOptions = {
clients?: Partial<Record<AgentProvider, AgentClient>>; clients?: Partial<Record<AgentProvider, AgentClient>>;
maxTimelineItems?: number; maxTimelineItems?: number;
@@ -143,6 +149,44 @@ export class AgentManager {
); );
} }
async listPersistedAgents(
options?: PersistedAgentQueryOptions
): Promise<PersistedAgentDescriptor[]> {
if (options?.provider) {
const client = this.requireClient(options.provider);
if (!client.listPersistedAgents) {
return [];
}
return client.listPersistedAgents({ limit: options.limit });
}
const descriptors: PersistedAgentDescriptor[] = [];
for (const [provider, client] of this.clients.entries()) {
if (!client.listPersistedAgents) {
continue;
}
try {
const entries = await client.listPersistedAgents({
limit: options?.limit,
});
descriptors.push(...entries);
} catch (error) {
console.warn(
`[AgentManager] Failed to list persisted agents for provider '${provider}':`,
error
);
}
}
const limit = options?.limit ?? 20;
return descriptors
.sort(
(a, b) =>
b.lastActivityAt.getTime() - a.lastActivityAt.getTime()
)
.slice(0, limit);
}
getAgent(id: string): AgentSnapshot | null { getAgent(id: string): AgentSnapshot | null {
const agent = this.agents.get(id); const agent = this.agents.get(id);
return agent ? this.toSnapshot(agent) : null; return agent ? this.toSnapshot(agent) : null;
@@ -182,6 +226,37 @@ export class AgentManager {
); );
} }
async refreshAgentFromPersistence(agentId: string): Promise<AgentSnapshot> {
const existing = this.requireAgent(agentId);
const handle = existing.persistence;
if (!handle) {
throw new Error(
`Agent ${agentId} cannot be refreshed because it has no persistence handle`
);
}
const client = this.requireClient(handle.provider);
const overrides = {
...existing.config,
provider: handle.provider,
};
const session = await client.resumeSession(handle, overrides);
// Remove the existing agent entry before swapping sessions
this.agents.delete(agentId);
try {
await existing.session.close();
} catch (error) {
console.warn(
`[AgentManager] Failed to close previous session for agent ${agentId} during refresh:`,
error
);
}
return this.registerSession(session, overrides, agentId);
}
async closeAgent(agentId: string): Promise<void> { async closeAgent(agentId: string): Promise<void> {
const agent = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
this.agents.delete(agentId); this.agents.delete(agentId);
@@ -229,6 +304,27 @@ export class AgentManager {
}; };
} }
recordUserMessage(
agentId: string,
text: string,
options?: { messageId?: string; raw?: unknown }
): void {
const agent = this.requireAgent(agentId);
const item: AgentTimelineItem = {
type: "user_message",
text,
messageId: options?.messageId,
raw: options?.raw,
};
agent.updatedAt = new Date();
this.recordTimeline(agent, item);
this.dispatchStream(agentId, {
type: "timeline",
item,
provider: agent.provider,
});
}
streamAgent( streamAgent(
agentId: string, agentId: string,
prompt: AgentPromptInput, prompt: AgentPromptInput,
@@ -1,7 +1,7 @@
import { describe, expect, test, beforeEach, afterEach } from "vitest"; import { describe, expect, test, beforeEach, afterEach } from "vitest";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
import { AgentRegistry } from "./agent-registry.js"; import { AgentRegistry } from "./agent-registry.js";
import type { AgentSnapshot } from "./agent-manager.js"; import type { AgentSnapshot } from "./agent-manager.js";
@@ -113,4 +113,33 @@ describe("AgentRegistry", () => {
const record = await registry.get("agent-3"); const record = await registry.get("agent-3");
expect(record?.lastModeId).toBe("plan"); expect(record?.lastModeId).toBe("plan");
}); });
test("recovers from trailing garbage in agents.json", async () => {
const payload = [
{
id: "agent-4",
provider: "claude",
cwd: "/tmp/project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
title: "Recovered agent",
lastStatus: "idle",
lastModeId: "plan",
config: null,
persistence: null,
},
];
writeFileSync(
filePath,
`${JSON.stringify(payload, null, 2)}\nGARBAGE-TRAILING`
);
const reloaded = new AgentRegistry(filePath);
const result = await reloaded.list();
expect(result).toHaveLength(1);
expect(result[0]?.title).toBe("Recovered agent");
const sanitized = readFileSync(filePath, "utf8");
expect(sanitized.includes("GARBAGE-TRAILING")).toBe(false);
});
}); });
@@ -1,5 +1,8 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod"; import { z } from "zod";
import type { AgentSnapshot } from "./agent-manager.js"; import type { AgentSnapshot } from "./agent-manager.js";
@@ -51,13 +54,7 @@ export class AgentRegistry {
constructor(filePath?: string) { constructor(filePath?: string) {
this.filePath = this.filePath =
filePath ?? filePath ?? path.join(resolveServerPackageRoot(), "agents.json");
path.join(
process.cwd(),
"packages",
"server",
"agents.json"
);
} }
async load(): Promise<StoredAgentRecord[]> { async load(): Promise<StoredAgentRecord[]> {
@@ -66,22 +63,9 @@ export class AgentRegistry {
} }
try { try {
const content = await fs.readFile(this.filePath, "utf8"); const content = await fs.readFile(this.filePath, "utf8");
const parsed = JSON.parse(content); const parsed = await this.parseContent(content);
if (!Array.isArray(parsed)) {
throw new Error("Invalid agents.json format");
}
const records: StoredAgentRecord[] = [];
for (const entry of parsed) {
try {
const record = STORED_AGENT_SCHEMA.parse(entry);
records.push(record);
this.cache.set(record.id, record);
} catch (error) {
console.error("[AgentRegistry] Skipping invalid record:", error);
}
}
this.loaded = true; this.loaded = true;
return records; return parsed;
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") { if ((error as NodeJS.ErrnoException).code === "ENOENT") {
this.loaded = true; this.loaded = true;
@@ -194,7 +178,63 @@ export class AgentRegistry {
private async flush(): Promise<void> { private async flush(): Promise<void> {
const payload = JSON.stringify(Array.from(this.cache.values()), null, 2); const payload = JSON.stringify(Array.from(this.cache.values()), null, 2);
await fs.mkdir(path.dirname(this.filePath), { recursive: true }); await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.writeFile(this.filePath, payload, "utf8"); await writeFileAtomically(this.filePath, payload);
}
private async parseContent(content: string): Promise<StoredAgentRecord[]> {
try {
return this.parseRecords(content);
} catch (error) {
if (error instanceof SyntaxError) {
const recovered = await this.tryRecoverCorruptedContent(content);
if (recovered) {
return recovered;
}
}
throw error;
}
}
private parseRecords(content: string): StoredAgentRecord[] {
const parsed = JSON.parse(content);
if (!Array.isArray(parsed)) {
throw new Error("Invalid agents.json format");
}
const records: StoredAgentRecord[] = [];
for (const entry of parsed) {
try {
const record = STORED_AGENT_SCHEMA.parse(entry);
records.push(record);
this.cache.set(record.id, record);
} catch (error) {
console.error("[AgentRegistry] Skipping invalid record:", error);
}
}
return records;
}
private async tryRecoverCorruptedContent(
content: string
): Promise<StoredAgentRecord[] | null> {
const start = content.indexOf("[");
const end = content.lastIndexOf("]");
if (start === -1 || end === -1 || end <= start) {
return null;
}
const candidate = content.slice(start, end + 1);
try {
this.cache.clear();
const records = this.parseRecords(candidate);
console.warn(
"[AgentRegistry] Recovered corrupted agents.json payload; rewrote sanitized copy"
);
const sanitizedPayload = JSON.stringify(records, null, 2);
await writeFileAtomically(this.filePath, sanitizedPayload);
return records;
} catch (error) {
this.cache.clear();
return null;
}
} }
} }
@@ -210,3 +250,29 @@ function sanitizeConfig(
if (config.extra) cleaned.extra = JSON.parse(JSON.stringify(config.extra)); if (config.extra) cleaned.extra = JSON.parse(JSON.stringify(config.extra));
return cleaned; return cleaned;
} }
function resolveServerPackageRoot(): string {
let currentDir = path.dirname(fileURLToPath(import.meta.url));
while (true) {
if (existsSync(path.join(currentDir, "package.json"))) {
return currentDir;
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
throw new Error(
"[AgentRegistry] Failed to locate server package root for agents.json"
);
}
currentDir = parentDir;
}
}
async function writeFileAtomically(targetPath: string, payload: string) {
const directory = path.dirname(targetPath);
const tempPath = path.join(
directory,
`.agents.json.tmp-${process.pid}-${Date.now()}-${randomUUID()}`
);
await fs.writeFile(tempPath, payload, "utf8");
await fs.rename(tempPath, targetPath);
}
@@ -45,12 +45,22 @@ export type AgentUsage = {
}; };
export type AgentTimelineItem = export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string; raw?: unknown }
| { type: "assistant_message"; text: string; raw?: unknown } | { type: "assistant_message"; text: string; raw?: unknown }
| { type: "reasoning"; text: string; raw?: unknown } | { type: "reasoning"; text: string; raw?: unknown }
| { type: "command"; command: string; status: string; raw?: unknown } | {
| { type: "file_change"; files: { path: string; kind: string }[]; raw?: unknown } type: "tool_call";
| { type: "mcp_tool"; server: string; tool: string; status: string; raw?: unknown } server: string;
| { type: "web_search"; query: string; raw?: unknown } tool: string;
status?: string;
callId?: string;
displayName?: string;
kind?: string;
input?: unknown;
output?: unknown;
error?: unknown;
raw?: unknown;
}
| { type: "todo"; items: { text: string; completed: boolean }[]; raw?: unknown } | { type: "todo"; items: { text: string; completed: boolean }[]; raw?: unknown }
| { type: "error"; message: string; raw?: unknown }; | { type: "error"; message: string; raw?: unknown };
@@ -105,6 +115,19 @@ export type AgentRunResult = {
timeline: AgentTimelineItem[]; timeline: AgentTimelineItem[];
}; };
export type ListPersistedAgentsOptions = {
limit?: number;
};
export type PersistedAgentDescriptor = {
provider: AgentProvider;
sessionId: string;
cwd: string;
title: string | null;
lastActivityAt: Date;
persistence: AgentPersistenceHandle;
};
export type AgentSessionConfig = { export type AgentSessionConfig = {
provider: AgentProvider; provider: AgentProvider;
cwd: string; cwd: string;
@@ -143,4 +166,5 @@ export interface AgentClient {
readonly capabilities: AgentCapabilityFlags; readonly capabilities: AgentCapabilityFlags;
createSession(config: AgentSessionConfig): Promise<AgentSession>; createSession(config: AgentSessionConfig): Promise<AgentSession>;
resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>): Promise<AgentSession>; resumeSession(handle: AgentPersistenceHandle, overrides?: Partial<AgentSessionConfig>): Promise<AgentSession>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
} }
@@ -0,0 +1,27 @@
import { describe, expect, test } from "vitest";
import { extractUserMessageText } from "./claude-agent.js";
describe("extractUserMessageText", () => {
test("returns trimmed string content", () => {
expect(extractUserMessageText(" Hello world ")).toBe("Hello world");
});
test("combines multiple text blocks", () => {
const content = [
{ type: "text", text: "First line" },
{ type: "text", text: "Second line" },
];
expect(extractUserMessageText(content)).toBe("First line\n\nSecond line");
});
test("returns null when no textual content is present", () => {
const content = [
{ type: "image", source: "foo.png" },
{ type: "file", path: "bar.txt" },
];
expect(extractUserMessageText(content)).toBeNull();
});
});
@@ -111,21 +111,33 @@ describe("ClaudeAgentClient (SDK integration)", () => {
} }
} }
const toolEvent = timeline.find((item) => item.type === "mcp_tool"); const toolCalls = timeline.filter(
const commandEvents = timeline.filter( (item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
(item): item is Extract<AgentTimelineItem, { type: "command" }> => item.type === "tool_call"
item.type === "command" && typeof item.command === "string" && !item.command.startsWith("permission:")
); );
const fileChangeEvent = timeline.find( const commandEvents = toolCalls.filter(
(item) => (item) =>
item.type === "file_change" && (item.kind === "execute" || item.server === "command") &&
item.files.some((file) => file.path.includes("tool-test.txt")) typeof item.displayName === "string" &&
!item.displayName.startsWith("permission:")
); );
const fileChangeEvent = toolCalls.find((item) => {
if (!item.output || typeof item.output !== "object") {
return false;
}
const files = (item.output as Record<string, unknown>).files;
if (!Array.isArray(files)) {
return false;
}
return files.some((file) => typeof file?.path === "string" && file.path.includes("tool-test.txt"));
});
const sawPwdCommand = commandEvents.some((item) => item.command.toLowerCase().includes("pwd") && item.status === "completed"); const sawPwdCommand = commandEvents.some(
(item) => (item.displayName ?? "").toLowerCase().includes("pwd") && item.status === "completed"
);
expect(completed).toBe(true); expect(completed).toBe(true);
expect(toolEvent).toBeTruthy(); expect(toolCalls.length).toBeGreaterThan(0);
expect(sawPwdCommand).toBe(true); expect(sawPwdCommand).toBe(true);
expect(fileChangeEvent).toBeTruthy(); expect(fileChangeEvent).toBeTruthy();
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import fs from "node:fs"; import fs, { promises as fsPromises } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { import {
@@ -35,6 +35,8 @@ import type {
AgentStreamEvent, AgentStreamEvent,
AgentTimelineItem, AgentTimelineItem,
AgentUsage, AgentUsage,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = { const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
@@ -71,7 +73,43 @@ const DEFAULT_MODES: AgentMode[] = [
type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" }; type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
type ClaudeContentChunk = { type: string; [key: string]: any }; export type ClaudeContentChunk = { type: string; [key: string]: any };
export function extractUserMessageText(
content: string | ClaudeContentChunk[]
): string | null {
if (typeof content === "string") {
const normalized = content.trim();
return normalized.length > 0 ? normalized : null;
}
if (!Array.isArray(content)) {
return null;
}
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const text = typeof block.text === "string" ? block.text : undefined;
if (text && text.trim()) {
parts.push(text.trim());
continue;
}
const input = typeof block.input === "string" ? block.input : undefined;
if (input && input.trim()) {
parts.push(input.trim());
}
}
if (parts.length === 0) {
return null;
}
const combined = parts.join("\n\n").trim();
return combined.length > 0 ? combined : null;
}
type PendingPermission = { type PendingPermission = {
request: AgentPermissionRequest; request: AgentPermissionRequest;
@@ -81,6 +119,7 @@ type PendingPermission = {
}; };
type ToolUseClassification = "generic" | "command" | "file_change"; type ToolUseClassification = "generic" | "command" | "file_change";
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
type ToolUseCacheEntry = { type ToolUseCacheEntry = {
id: string; id: string;
@@ -90,6 +129,7 @@ type ToolUseCacheEntry = {
started: boolean; started: boolean;
commandText?: string; commandText?: string;
files?: { path: string; kind: string }[]; files?: { path: string; kind: string }[];
input?: Record<string, unknown> | null;
}; };
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000; const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
@@ -123,6 +163,28 @@ export class ClaudeAgentClient implements AgentClient {
return new ClaudeAgentSession(claudeConfig, this.defaults, handle); return new ClaudeAgentSession(claudeConfig, this.defaults, handle);
} }
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
const projectsRoot = path.join(os.homedir(), ".claude", "projects");
if (!(await pathExists(projectsRoot))) {
return [];
}
const limit = options?.limit ?? 20;
const candidates = await collectRecentClaudeSessions(projectsRoot, limit * 3);
const descriptors: PersistedAgentDescriptor[] = [];
for (const candidate of candidates) {
const descriptor = await parseClaudeSessionDescriptor(candidate.path, candidate.mtime);
if (descriptor) {
descriptors.push(descriptor);
}
if (descriptors.length >= limit) {
break;
}
}
return descriptors;
}
private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig { private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig {
if (config.provider !== "claude") { if (config.provider !== "claude") {
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`); throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
@@ -269,10 +331,13 @@ class ClaudeAgentSession implements AgentSession {
if (response.behavior === "allow") { if (response.behavior === "allow") {
if (pending.request.kind === "plan") { if (pending.request.kind === "plan") {
await this.setMode("acceptEdits"); await this.setMode("acceptEdits");
this.enqueueTimeline({ this.pushToolCall({
type: "command", server: "plan",
command: "plan:approved", tool: "plan_approval",
status: "granted", status: "granted",
callId: pending.request.id,
displayName: "Plan approved",
kind: "plan",
raw: pending.request, raw: pending.request,
}); });
} }
@@ -282,10 +347,14 @@ class ClaudeAgentSession implements AgentSession {
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions), updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
}; };
pending.resolve(result); pending.resolve(result);
this.enqueueTimeline({ this.pushToolCall({
type: "command", server: "permission",
command: `permission:${pending.request.name}`, tool: pending.request.name,
status: "granted", status: "granted",
callId: pending.request.id,
displayName: pending.request.title ?? pending.request.name,
kind: "permission",
input: pending.request.input,
raw: { request: pending.request, response }, raw: { request: pending.request, response },
}); });
} else { } else {
@@ -295,10 +364,14 @@ class ClaudeAgentSession implements AgentSession {
interrupt: response.interrupt, interrupt: response.interrupt,
}; };
pending.resolve(result); pending.resolve(result);
this.enqueueTimeline({ this.pushToolCall({
type: "command", server: "permission",
command: `permission:${pending.request.name}`, tool: pending.request.name,
status: "denied", status: "denied",
callId: pending.request.id,
displayName: pending.request.title ?? pending.request.name,
kind: "permission",
input: pending.request.input,
raw: { request: pending.request, response }, raw: { request: pending.request, response },
}); });
} }
@@ -551,10 +624,14 @@ class ClaudeAgentSession implements AgentSession {
raw: { toolName, input, options }, raw: { toolName, input, options },
}; };
this.enqueueTimeline({ this.pushToolCall({
type: "command", server: "permission",
command: `permission:${toolName}`, tool: toolName,
status: "requested", status: "requested",
callId: requestId,
displayName: request.title ?? toolName,
kind: "permission",
input,
raw: { toolName, input }, raw: { toolName, input },
}); });
@@ -576,10 +653,14 @@ class ClaudeAgentSession implements AgentSession {
this.pendingPermissions.delete(requestId); this.pendingPermissions.delete(requestId);
cleanup(); cleanup();
const error = new Error("Permission request timed out"); const error = new Error("Permission request timed out");
this.enqueueTimeline({ this.pushToolCall({
type: "command", server: "permission",
command: `permission:${toolName}`, tool: toolName,
status: "denied", status: "denied",
callId: requestId,
displayName: request.title ?? toolName,
kind: "permission",
input,
raw: { reason: "timeout", toolName, input }, raw: { reason: "timeout", toolName, input },
}); });
this.pushEvent({ this.pushEvent({
@@ -634,6 +715,37 @@ class ClaudeAgentSession implements AgentSession {
this.pushEvent({ type: "timeline", item, provider: "claude" }); this.pushEvent({ type: "timeline", item, provider: "claude" });
} }
private pushToolCall(
data: Omit<ToolCallTimelineItem, "type">,
target?: AgentTimelineItem[]
) {
const item: AgentTimelineItem = { type: "tool_call", ...data };
if (target) {
target.push(item);
return;
}
this.enqueueTimeline(item);
}
private getToolKind(classification?: ToolUseClassification): string | undefined {
switch (classification) {
case "command":
return "execute";
case "file_change":
return "edit";
case "generic":
default:
return "tool";
}
}
private buildToolDisplayName(entry?: ToolUseCacheEntry): string | undefined {
if (!entry) {
return undefined;
}
return entry.commandText ?? entry.name;
}
private pushEvent(event: AgentStreamEvent) { private pushEvent(event: AgentStreamEvent) {
if (this.eventQueue) { if (this.eventQueue) {
this.eventQueue.push(event); this.eventQueue.push(event);
@@ -700,20 +812,39 @@ class ClaudeAgentSession implements AgentSession {
private convertHistoryEntry(entry: any): AgentTimelineItem[] { private convertHistoryEntry(entry: any): AgentTimelineItem[] {
const message = entry?.message; const message = entry?.message;
if (!message) { if (!message || !("content" in message)) {
return []; return [];
} }
if (entry.type === "assistant" && message.content) { const content = message.content as string | ClaudeContentChunk[];
return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]);
if (entry.type === "assistant" && content) {
return this.mapBlocksToTimeline(content);
} }
if (Array.isArray(message.content)) {
const hasToolBlock = message.content.some((block: ClaudeContentChunk) => if (entry.type === "user") {
typeof block?.type === "string" && block.type.includes("tool") const text = extractUserMessageText(content);
if (text) {
return [
{
type: "user_message",
text,
raw: message,
},
];
}
return [];
}
if (Array.isArray(content)) {
const hasToolBlock = content.some(
(block: ClaudeContentChunk) =>
typeof block?.type === "string" && block.type.includes("tool")
); );
if (hasToolBlock) { if (hasToolBlock) {
return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]); return this.mapBlocksToTimeline(content);
} }
} }
return []; return [];
} }
@@ -773,28 +904,19 @@ class ClaudeAgentSession implements AgentSession {
} }
entry.started = true; entry.started = true;
this.toolUseCache.set(entry.id, entry); this.toolUseCache.set(entry.id, entry);
this.enqueueTimeline({ this.pushToolCall(
type: "command", {
command: `permission:${entry.name}`, server: entry.server,
status: "granted", tool: entry.name,
raw: block, status: "pending",
}); callId: entry.id,
if (entry.classification === "command") { displayName: this.buildToolDisplayName(entry),
const commandText = entry.commandText ?? entry.name; kind: this.getToolKind(entry.classification),
items.push({ input: entry.input ?? this.normalizeToolInput(block.input),
type: "command",
command: commandText,
status: "running",
raw: block, raw: block,
}); },
} items
items.push({ );
type: "mcp_tool",
server: entry.server,
tool: entry.name,
status: "pending",
raw: block,
});
} }
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
@@ -802,29 +924,25 @@ class ClaudeAgentSession implements AgentSession {
const server = entry?.server ?? block.server ?? "tool"; const server = entry?.server ?? block.server ?? "tool";
const tool = entry?.name ?? block.tool_name ?? "tool"; const tool = entry?.name ?? block.tool_name ?? "tool";
const status = block.is_error ? "failed" : "completed"; const status = block.is_error ? "failed" : "completed";
items.push({ const rawPayload =
type: "mcp_tool", !block.is_error && entry?.classification === "file_change" && entry.files?.length
server, ? { block, files: entry.files }
tool, : block;
status, this.pushToolCall(
raw: block, {
}); server,
if (entry?.classification === "command") { tool,
const commandText = entry.commandText ?? entry.name;
items.push({
type: "command",
command: commandText,
status, status,
raw: block, callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
}); displayName: this.buildToolDisplayName(entry),
} kind: this.getToolKind(entry?.classification),
if (!block.is_error && entry?.classification === "file_change" && entry.files?.length) { input: entry?.input,
items.push({ output: !block.is_error && entry?.files?.length ? { files: entry.files } : undefined,
type: "file_change", error: block.is_error ? block : undefined,
files: entry.files, raw: rawPayload,
raw: { block, files: entry.files }, },
}); items
} );
if (typeof block.tool_use_id === "string") { if (typeof block.tool_use_id === "string") {
this.toolUseCache.delete(block.tool_use_id); this.toolUseCache.delete(block.tool_use_id);
} }
@@ -872,6 +990,7 @@ class ClaudeAgentSession implements AgentSession {
if (block.type === "tool_use") { if (block.type === "tool_use") {
const input = this.normalizeToolInput(block.input); const input = this.normalizeToolInput(block.input);
existing.input = input;
if (input) { if (input) {
if (this.isCommandTool(existing.name, input)) { if (this.isCommandTool(existing.name, input)) {
existing.classification = "command"; existing.classification = "command";
@@ -1043,3 +1162,142 @@ class Pushable<T> implements AsyncIterable<T> {
}; };
} }
} }
type ClaudeSessionCandidate = {
path: string;
mtime: Date;
};
async function pathExists(target: string): Promise<boolean> {
try {
await fsPromises.access(target);
return true;
} catch {
return false;
}
}
async function collectRecentClaudeSessions(root: string, limit: number): Promise<ClaudeSessionCandidate[]> {
let projectDirs: string[];
try {
projectDirs = await fsPromises.readdir(root);
} catch {
return [];
}
const candidates: ClaudeSessionCandidate[] = [];
for (const dirName of projectDirs) {
const projectPath = path.join(root, dirName);
let stats: fs.Stats;
try {
stats = await fsPromises.stat(projectPath);
} catch {
continue;
}
if (!stats.isDirectory()) {
continue;
}
let files: string[];
try {
files = await fsPromises.readdir(projectPath);
} catch {
continue;
}
for (const file of files) {
if (!file.endsWith(".jsonl")) {
continue;
}
const fullPath = path.join(projectPath, file);
try {
const fileStats = await fsPromises.stat(fullPath);
candidates.push({ path: fullPath, mtime: fileStats.mtime });
} catch {
// ignore stat errors for individual files
}
}
}
return candidates
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
.slice(0, limit);
}
async function parseClaudeSessionDescriptor(
filePath: string,
mtime: Date
): Promise<PersistedAgentDescriptor | null> {
let content: string;
try {
content = await fsPromises.readFile(filePath, "utf8");
} catch {
return null;
}
let sessionId: string | null = null;
let cwd: string | null = null;
let title: string | null = null;
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
let entry: any;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (!sessionId && typeof entry.sessionId === "string") {
sessionId = entry.sessionId;
}
if (!cwd && typeof entry.cwd === "string") {
cwd = entry.cwd;
}
if (!title && entry.type === "user" && entry.message) {
title = extractClaudeUserText(entry.message);
}
if (sessionId && cwd && title) {
break;
}
}
if (!sessionId || !cwd) {
return null;
}
const persistence: AgentPersistenceHandle = {
provider: "claude",
sessionId,
nativeHandle: sessionId,
metadata: {
provider: "claude",
cwd,
},
};
return {
provider: "claude",
sessionId,
cwd,
title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`,
lastActivityAt: mtime,
persistence,
};
}
function extractClaudeUserText(message: any): string | null {
if (!message) {
return null;
}
if (typeof message.content === "string") {
return message.content.trim();
}
if (typeof message.text === "string") {
return message.text.trim();
}
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block && typeof block.text === "string") {
return block.text.trim();
}
}
}
return null;
}
@@ -110,7 +110,7 @@ describe("CodexAgentClient (SDK integration)", () => {
if ( if (
event.type === "timeline" && event.type === "timeline" &&
event.provider === "codex" && event.provider === "codex" &&
(event.item.type === "file_change" || event.item.type === "command" || event.item.type === "mcp_tool") event.item.type === "tool_call"
) { ) {
sawActivity = true; sawActivity = true;
} }
@@ -34,6 +34,8 @@ import type {
AgentStreamEvent, AgentStreamEvent,
AgentTimelineItem, AgentTimelineItem,
AgentUsage, AgentUsage,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
type CodexAgentConfig = AgentSessionConfig & { provider: "codex" }; type CodexAgentConfig = AgentSessionConfig & { provider: "codex" };
@@ -96,6 +98,33 @@ const MODE_PRESETS: Record<
type CodexOptionsOverrides = Partial<ThreadOptions> & { skipGitRepoCheck?: boolean }; type CodexOptionsOverrides = Partial<ThreadOptions> & { skipGitRepoCheck?: boolean };
const MAX_ROLLOUT_SEARCH_DEPTH = 5; const MAX_ROLLOUT_SEARCH_DEPTH = 5;
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
function createToolCallTimelineItem(
data: Omit<ToolCallTimelineItem, "type">
): AgentTimelineItem {
return { type: "tool_call", ...data };
}
function buildCommandDisplayName(command?: unknown): string {
if (typeof command === "string") {
const trimmed = command.trim();
if (trimmed.length) {
return trimmed;
}
}
return "Command";
}
function buildFileChangeSummary(files: { path: string; kind: string }[]): string {
if (!files.length) {
return "File change";
}
if (files.length === 1) {
return `${files[0].kind ?? "edit"}: ${files[0].path}`;
}
return `${files.length} file changes`;
}
function coerceSandboxMode(value?: string): SandboxMode | undefined { function coerceSandboxMode(value?: string): SandboxMode | undefined {
if (!value) return undefined; if (!value) return undefined;
@@ -166,6 +195,29 @@ export class CodexAgentClient implements AgentClient {
return CodexAgentSession.create(this.codex, codexConfig, handle); return CodexAgentSession.create(this.codex, codexConfig, handle);
} }
async listPersistedAgents(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]> {
const sessionRoot = resolveCodexSessionRoot();
if (!sessionRoot) {
return [];
}
if (!(await fileExists(sessionRoot))) {
return [];
}
const limit = options?.limit ?? 20;
const candidates = await collectRecentRolloutFiles(sessionRoot, limit * 3);
const descriptors: PersistedAgentDescriptor[] = [];
for (const candidate of candidates) {
const descriptor = await parseRolloutDescriptor(candidate.path, candidate.mtime, sessionRoot);
if (descriptor) {
descriptors.push(descriptor);
}
if (descriptors.length >= limit) {
break;
}
}
return descriptors;
}
private assertConfig(config: AgentSessionConfig): CodexAgentConfig { private assertConfig(config: AgentSessionConfig): CodexAgentConfig {
if (config.provider !== "codex") { if (config.provider !== "codex") {
throw new Error(`CodexAgentClient received config for provider '${config.provider}'`); throw new Error(`CodexAgentClient received config for provider '${config.provider}'`);
@@ -323,12 +375,16 @@ class CodexAgentSession implements AgentSession {
this.enqueueHistoryEvent({ this.enqueueHistoryEvent({
type: "timeline", type: "timeline",
provider: "codex", provider: "codex",
item: { item: createToolCallTimelineItem({
type: "command", server: "permission",
command: `permission:${request.name}`, tool: request.name,
status, status,
callId: request.id,
displayName: request.title ?? request.name,
kind: "permission",
input: request.input,
raw: { request, response }, raw: { request, response },
}, }),
}); });
this.enqueueHistoryEvent({ this.enqueueHistoryEvent({
@@ -579,12 +635,16 @@ class CodexAgentSession implements AgentSession {
{ {
type: "timeline", type: "timeline",
provider: "codex", provider: "codex",
item: { item: createToolCallTimelineItem({
type: "command", server: "permission",
command: `permission:${request.name}`, tool: request.name,
status: "requested", status: "requested",
callId: request.id,
displayName: request.title ?? request.name,
kind: "permission",
input: request.input,
raw: request.raw, raw: request.raw,
}, }),
}, },
{ type: "permission_requested", provider: "codex", request }, { type: "permission_requested", provider: "codex", request },
]; ];
@@ -698,23 +758,54 @@ class CodexAgentSession implements AgentSession {
case "reasoning": case "reasoning":
return { type: "reasoning", text: item.text, raw: item }; return { type: "reasoning", text: item.text, raw: item };
case "command_execution": case "command_execution":
return { type: "command", command: item.command, status: item.status, raw: item }; return createToolCallTimelineItem({
case "file_change": server: "command",
return { tool: "shell",
type: "file_change", status: item.status,
files: item.changes.map((change) => ({ path: change.path, kind: change.kind })), callId: (item as any)?.call_id,
displayName: buildCommandDisplayName(item.command),
kind: "execute",
input: { command: item.command, cwd: (item as any)?.cwd },
output: (item as any)?.output,
error: (item as any)?.error,
raw: item, raw: item,
}; });
case "file_change": {
const files = item.changes.map((change) => ({ path: change.path, kind: change.kind }));
return createToolCallTimelineItem({
server: "file_change",
tool: "apply_patch",
status: "completed",
callId: (item as any)?.call_id,
displayName: buildFileChangeSummary(files),
kind: "edit",
output: { files },
raw: item,
});
}
case "mcp_tool_call": case "mcp_tool_call":
return { return createToolCallTimelineItem({
type: "mcp_tool",
server: item.server, server: item.server,
tool: item.tool, tool: item.tool,
status: item.status, status: item.status,
callId: (item as any)?.call_id,
displayName: `${item.server}.${item.tool}`,
kind: "tool",
input: (item as any)?.input,
output: (item as any)?.output,
raw: item, raw: item,
}; });
case "web_search": case "web_search":
return { type: "web_search", query: item.query, raw: item }; return createToolCallTimelineItem({
server: "web_search",
tool: "web_search",
status: (item as any)?.status ?? "completed",
callId: (item as any)?.call_id,
displayName: item.query ? `Web search: ${item.query}` : "Web search",
kind: "search",
input: { query: item.query },
raw: item,
});
case "todo_list": case "todo_list":
return { type: "todo", items: item.items, raw: item }; return { type: "todo", items: item.items, raw: item };
case "error": case "error":
@@ -894,13 +985,16 @@ function handleRolloutFunctionCall(
events.push({ events.push({
type: "timeline", type: "timeline",
provider: "codex", provider: "codex",
item: { item: createToolCallTimelineItem({
type: "mcp_tool",
server: "codex", server: "codex",
tool: name, tool: name,
status: payload.status ?? "in_progress", status: payload.status ?? "in_progress",
callId: typeof payload.call_id === "string" ? payload.call_id : undefined,
displayName: `${name}`,
kind: "tool",
input: safeJsonParse(payload.arguments),
raw: payload, raw: payload,
}, }),
}); });
} }
@@ -922,12 +1016,17 @@ function finalizeRolloutFunctionCall(
events.push({ events.push({
type: "timeline", type: "timeline",
provider: "codex", provider: "codex",
item: { item: createToolCallTimelineItem({
type: "command", server: "command",
command: command.command, tool: "shell",
status, status,
callId: payload.call_id,
displayName: buildCommandDisplayName(command.command),
kind: "execute",
input: { command: command.command },
output: result,
raw: { payload, result }, raw: { payload, result },
}, }),
}); });
commandCalls.delete(payload.call_id); commandCalls.delete(payload.call_id);
} }
@@ -939,11 +1038,15 @@ function handleRolloutCustomToolCall(payload: any, events: AgentStreamEvent[]):
events.push({ events.push({
type: "timeline", type: "timeline",
provider: "codex", provider: "codex",
item: { item: createToolCallTimelineItem({
type: "file_change", server: "file_change",
files, tool: "apply_patch",
status: "completed",
displayName: buildFileChangeSummary(files),
kind: "edit",
output: { files },
raw: payload, raw: payload,
}, }),
}); });
} }
return; return;
@@ -953,17 +1056,147 @@ function handleRolloutCustomToolCall(payload: any, events: AgentStreamEvent[]):
events.push({ events.push({
type: "timeline", type: "timeline",
provider: "codex", provider: "codex",
item: { item: createToolCallTimelineItem({
type: "mcp_tool",
server: "codex", server: "codex",
tool: payload.name, tool: payload.name,
status: payload.status ?? "completed", status: payload.status ?? "completed",
displayName: payload.name,
kind: "tool",
input: payload.input,
output: payload.output,
raw: payload, raw: payload,
}, }),
}); });
} }
} }
type RolloutCandidate = {
path: string;
mtime: Date;
};
async function collectRecentRolloutFiles(rootDir: string, limit: number): Promise<RolloutCandidate[]> {
const candidates: RolloutCandidate[] = [];
async function traverse(dir: string): Promise<void> {
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
const files = entries.filter(
(entry) =>
entry.isFile() &&
entry.name.startsWith("rollout-") &&
(entry.name.endsWith(".json") || entry.name.endsWith(".jsonl"))
);
for (const entry of files) {
const fullPath = path.join(dir, entry.name);
try {
const stat = await fs.stat(fullPath);
candidates.push({ path: fullPath, mtime: stat.mtime });
} catch {
// ignore stat failures
}
}
const directories = entries
.filter((entry) => entry.isDirectory())
.sort((a, b) => b.name.localeCompare(a.name));
for (const dirEntry of directories) {
await traverse(path.join(dir, dirEntry.name));
}
}
await traverse(rootDir);
return candidates
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
.slice(0, limit);
}
async function parseRolloutDescriptor(
filePath: string,
mtime: Date,
sessionRoot: string
): Promise<PersistedAgentDescriptor | null> {
let content: string;
try {
content = await fs.readFile(filePath, "utf8");
} catch {
return null;
}
let sessionId: string | null = null;
let cwd: string | null = null;
let title: string | null = null;
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
let entry: any;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (!sessionId && entry?.type === "session_meta" && entry.payload) {
sessionId = typeof entry.payload.id === "string" ? entry.payload.id : null;
if (typeof entry.payload.cwd === "string" && entry.payload.cwd.length > 0) {
cwd = entry.payload.cwd;
}
} else if (!title && entry?.type === "response_item" && entry.payload?.role === "user") {
title = extractCodexUserPrompt(entry.payload);
}
if (sessionId && cwd && title) {
break;
}
}
if (!sessionId || !cwd) {
return null;
}
const persistence: AgentPersistenceHandle = {
provider: "codex",
sessionId,
nativeHandle: sessionId,
metadata: sanitizeMetadata({
provider: "codex",
cwd,
codexSessionDir: sessionRoot,
codexRolloutPath: filePath,
}),
};
return {
provider: "codex",
sessionId,
cwd,
title: (title ?? "").trim() || `Codex session ${sessionId.slice(0, 8)}`,
lastActivityAt: mtime,
persistence,
};
}
function extractCodexUserPrompt(payload: any): string | null {
const content = payload?.content;
if (!Array.isArray(content)) {
return null;
}
for (const block of content) {
if (block && typeof block === "object" && typeof block.text === "string") {
return block.text.trim();
}
}
return null;
}
function resolveCodexSessionRoot(): string | null {
if (process.env.CODEX_SESSION_DIR) {
return process.env.CODEX_SESSION_DIR;
}
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
return path.join(codexHome, "sessions");
}
function extractMessageText(content: unknown): string { function extractMessageText(content: unknown): string {
if (!Array.isArray(content)) { if (!Array.isArray(content)) {
return ""; return "";
+58 -23
View File
@@ -112,6 +112,12 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> = export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
z.discriminatedUnion("type", [ z.discriminatedUnion("type", [
z.object({
type: z.literal("user_message"),
text: z.string(),
messageId: z.string().optional(),
raw: z.unknown().optional(),
}),
z.object({ z.object({
type: z.literal("assistant_message"), type: z.literal("assistant_message"),
text: z.string(), text: z.string(),
@@ -123,31 +129,16 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
raw: z.unknown().optional(), raw: z.unknown().optional(),
}), }),
z.object({ z.object({
type: z.literal("command"), type: z.literal("tool_call"),
command: z.string(),
status: z.string(),
raw: z.unknown().optional(),
}),
z.object({
type: z.literal("file_change"),
files: z.array(
z.object({
path: z.string(),
kind: z.string(),
})
),
raw: z.unknown().optional(),
}),
z.object({
type: z.literal("mcp_tool"),
server: z.string(), server: z.string(),
tool: z.string(), tool: z.string(),
status: z.string(), status: z.string().optional(),
raw: z.unknown().optional(), callId: z.string().optional(),
}), displayName: z.string().optional(),
z.object({ kind: z.string().optional(),
type: z.literal("web_search"), input: z.unknown().optional(),
query: z.string(), output: z.unknown().optional(),
error: z.unknown().optional(),
raw: z.unknown().optional(), raw: z.unknown().optional(),
}), }),
z.object({ z.object({
@@ -334,6 +325,25 @@ export const CreateAgentRequestMessageSchema = z.object({
requestId: z.string().optional(), requestId: z.string().optional(),
}); });
export const ResumeAgentRequestMessageSchema = z.object({
type: z.literal("resume_agent_request"),
handle: AgentPersistenceHandleSchema,
overrides: AgentSessionConfigSchema.partial().optional(),
requestId: z.string().optional(),
});
export const RefreshAgentRequestMessageSchema = z.object({
type: z.literal("refresh_agent_request"),
agentId: z.string(),
requestId: z.string().optional(),
});
export const ListPersistedAgentsRequestMessageSchema = z.object({
type: z.literal("list_persisted_agents_request"),
provider: AgentProviderSchema.optional(),
limit: z.number().int().positive().optional(),
});
export const InitializeAgentRequestMessageSchema = z.object({ export const InitializeAgentRequestMessageSchema = z.object({
type: z.literal("initialize_agent_request"), type: z.literal("initialize_agent_request"),
agentId: z.string(), agentId: z.string(),
@@ -400,11 +410,14 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SendAgentMessageSchema, SendAgentMessageSchema,
SendAgentAudioSchema, SendAgentAudioSchema,
CreateAgentRequestMessageSchema, CreateAgentRequestMessageSchema,
ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema,
InitializeAgentRequestMessageSchema, InitializeAgentRequestMessageSchema,
SetAgentModeMessageSchema, SetAgentModeMessageSchema,
AgentPermissionResponseMessageSchema, AgentPermissionResponseMessageSchema,
GitDiffRequestSchema, GitDiffRequestSchema,
FileExplorerRequestSchema, FileExplorerRequestSchema,
ListPersistedAgentsRequestMessageSchema,
]); ]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>; export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -580,6 +593,24 @@ export const AgentPermissionResolvedMessageSchema = z.object({
}), }),
}); });
const PersistedAgentDescriptorPayloadSchema = z.object({
provider: AgentProviderSchema,
sessionId: z.string(),
cwd: z.string(),
title: z.string(),
lastActivityAt: z.string(),
persistence: AgentPersistenceHandleSchema,
});
export type PersistedAgentDescriptorPayload = z.infer<typeof PersistedAgentDescriptorPayloadSchema>;
export const ListPersistedAgentsResponseSchema = z.object({
type: z.literal("list_persisted_agents_response"),
payload: z.object({
items: z.array(PersistedAgentDescriptorPayloadSchema),
}),
});
export const GitDiffResponseSchema = z.object({ export const GitDiffResponseSchema = z.object({
type: z.literal("git_diff_response"), type: z.literal("git_diff_response"),
payload: z.object({ payload: z.object({
@@ -618,6 +649,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
DeleteConversationResponseMessageSchema, DeleteConversationResponseMessageSchema,
AgentPermissionRequestMessageSchema, AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema, AgentPermissionResolvedMessageSchema,
ListPersistedAgentsResponseSchema,
GitDiffResponseSchema, GitDiffResponseSchema,
FileExplorerResponseSchema, FileExplorerResponseSchema,
]); ]);
@@ -645,6 +677,7 @@ export type ListConversationsResponseMessage = z.infer<typeof ListConversationsR
export type DeleteConversationResponseMessage = z.infer<typeof DeleteConversationResponseMessageSchema>; export type DeleteConversationResponseMessage = z.infer<typeof DeleteConversationResponseMessageSchema>;
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>; export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>; export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
export type ListPersistedAgentsResponseMessage = z.infer<typeof ListPersistedAgentsResponseSchema>;
// Type exports for payload types // Type exports for payload types
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>; export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
@@ -655,6 +688,8 @@ export type RealtimeAudioChunkMessage = z.infer<typeof RealtimeAudioChunkMessage
export type SendAgentMessage = z.infer<typeof SendAgentMessageSchema>; export type SendAgentMessage = z.infer<typeof SendAgentMessageSchema>;
export type SendAgentAudio = z.infer<typeof SendAgentAudioSchema>; export type SendAgentAudio = z.infer<typeof SendAgentAudioSchema>;
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>; export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type ListPersistedAgentsRequestMessage = z.infer<typeof ListPersistedAgentsRequestMessageSchema>;
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>; export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>; export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>; export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
+226 -5
View File
@@ -191,6 +191,40 @@ export class Session {
return `${base}\n\n[Image attachments]\n${attachmentSummary}\n(Actual image bytes omitted; request a screenshot or file if needed.)`; return `${base}\n\n[Image attachments]\n${attachmentSummary}\n(Actual image bytes omitted; request a screenshot or file if needed.)`;
} }
/**
* Interrupt the agent's active run so the next prompt starts a fresh turn.
* Returns once the manager confirms the stream has been cancelled.
*/
private async interruptAgentIfRunning(agentId: string): Promise<void> {
const snapshot = this.agentManager.getAgent(agentId);
if (!snapshot) {
throw new Error(`Agent ${agentId} not found`);
}
if (snapshot.status !== "running") {
return;
}
console.log(
`[Session ${this.clientId}] Interrupting active run for agent ${agentId}`
);
try {
const cancelled = await this.agentManager.cancelAgentRun(agentId);
if (!cancelled) {
console.warn(
`[Session ${this.clientId}] Agent ${agentId} reported running but no active run was cancelled`
);
}
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to interrupt agent ${agentId}:`,
error
);
throw error;
}
}
/** /**
* Start streaming an agent run and forward results via the websocket broadcast * Start streaming an agent run and forward results via the websocket broadcast
*/ */
@@ -516,6 +550,14 @@ export class Session {
await this.handleCreateAgentRequest(msg); await this.handleCreateAgentRequest(msg);
break; break;
case "resume_agent_request":
await this.handleResumeAgentRequest(msg);
break;
case "refresh_agent_request":
await this.handleRefreshAgentRequest(msg);
break;
case "initialize_agent_request": case "initialize_agent_request":
await this.handleInitializeAgentRequest(msg.agentId, msg.requestId); await this.handleInitializeAgentRequest(msg.agentId, msg.requestId);
break; break;
@@ -539,6 +581,10 @@ export class Session {
case "file_explorer_request": case "file_explorer_request":
await this.handleFileExplorerRequest(msg); await this.handleFileExplorerRequest(msg);
break; break;
case "list_persisted_agents_request":
await this.handleListPersistedAgentsRequest(msg);
break;
} }
} catch (error: any) { } catch (error: any) {
console.error( console.error(
@@ -657,7 +703,7 @@ export class Session {
private async handleSendAgentMessage( private async handleSendAgentMessage(
agentId: string, agentId: string,
text: string, text: string,
_messageId?: string, messageId?: string,
images?: Array<{ data: string; mimeType: string }> images?: Array<{ data: string; mimeType: string }>
): Promise<void> { ): Promise<void> {
console.log( console.log(
@@ -666,7 +712,28 @@ export class Session {
}] Sending text to agent ${agentId}: ${text.substring(0, 50)}...${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}` }] Sending text to agent ${agentId}: ${text.substring(0, 50)}...${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}`
); );
try {
await this.interruptAgentIfRunning(agentId);
} catch (error) {
this.handleAgentRunError(
agentId,
error,
"Failed to interrupt running agent before sending prompt"
);
return;
}
const prompt = this.buildAgentPrompt(text, images); const prompt = this.buildAgentPrompt(text, images);
try {
this.agentManager.recordUserMessage(agentId, text, { messageId });
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to record user message for agent ${agentId}:`,
error
);
}
this.startAgentStream(agentId, prompt); this.startAgentStream(agentId, prompt);
} }
@@ -722,6 +789,26 @@ export class Session {
}, },
}); });
try {
await this.interruptAgentIfRunning(agentId);
} catch (error) {
this.handleAgentRunError(
agentId,
error,
"Failed to interrupt running agent before sending audio prompt"
);
return;
}
try {
this.agentManager.recordUserMessage(agentId, transcriptText);
} catch (recordError) {
console.error(
`[Session ${this.clientId}] Failed to record transcribed user message for agent ${agentId}:`,
recordError
);
}
// Send transcribed text to agent // Send transcribed text to agent
this.startAgentStream(agentId, transcriptText); this.startAgentStream(agentId, transcriptText);
console.log( console.log(
@@ -869,6 +956,103 @@ export class Session {
} }
} }
private async handleResumeAgentRequest(
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>
): Promise<void> {
const { handle, overrides, requestId } = msg;
if (!handle) {
console.warn(
`[Session ${this.clientId}] Resume request missing persistence handle`
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: "Unable to resume agent: missing persistence handle",
},
});
return;
}
console.log(
`[Session ${this.clientId}] Resuming agent ${handle.sessionId} (${handle.provider})`
);
try {
const snapshot = await this.agentManager.resumeAgent(handle, overrides);
this.setCachedTitle(snapshot.id, null);
await this.forwardAgentState(snapshot);
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
if (requestId) {
this.emit({
type: "status",
payload: {
status: "agent_resumed",
agentId: snapshot.id,
requestId,
timelineSize,
},
});
}
} catch (error: any) {
console.error(
`[Session ${this.clientId}] Failed to resume agent:`,
error
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to resume agent: ${error.message}`,
},
});
}
}
private async handleRefreshAgentRequest(
msg: Extract<SessionInboundMessage, { type: "refresh_agent_request" }>
): Promise<void> {
const { agentId, requestId } = msg;
console.log(
`[Session ${this.clientId}] Refreshing agent ${agentId} from persistence`
);
try {
const snapshot = await this.agentManager.refreshAgentFromPersistence(
agentId
);
await this.forwardAgentState(snapshot);
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
if (requestId) {
this.emit({
type: "status",
payload: {
status: "agent_refreshed",
agentId,
requestId,
timelineSize,
},
});
}
} catch (error: any) {
console.error(
`[Session ${this.clientId}] Failed to refresh agent ${agentId}:`,
error
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to refresh agent: ${error.message}`,
},
});
}
}
private async buildAgentSessionConfig( private async buildAgentSessionConfig(
config: AgentSessionConfig, config: AgentSessionConfig,
worktreeName?: string worktreeName?: string
@@ -893,6 +1077,47 @@ export class Session {
}; };
} }
private async handleListPersistedAgentsRequest(
msg: Extract<SessionInboundMessage, { type: "list_persisted_agents_request" }>
): Promise<void> {
const { provider, limit } = msg;
try {
const entries = await this.agentManager.listPersistedAgents({
provider,
limit,
});
this.emit({
type: "list_persisted_agents_response",
payload: {
items: entries.map((entry) => ({
provider: entry.provider,
sessionId: entry.sessionId,
cwd: entry.cwd,
title: entry.title ?? `Session ${entry.sessionId.slice(0, 8)}`,
lastActivityAt: entry.lastActivityAt.toISOString(),
persistence: entry.persistence,
})),
},
});
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to list persisted agents:`,
error
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to list saved agents: ${
(error as Error)?.message ?? error
}`,
},
});
}
}
/** /**
* Handle set agent mode request * Handle set agent mode request
*/ */
@@ -1154,10 +1379,6 @@ export class Session {
private emitAgentTimelineSnapshot(agent: AgentSnapshot): number { private emitAgentTimelineSnapshot(agent: AgentSnapshot): number {
const timeline = this.agentManager.getTimeline(agent.id); const timeline = this.agentManager.getTimeline(agent.id);
if (timeline.length === 0) {
return 0;
}
const events = timeline.map((item) => ({ const events = timeline.map((item) => ({
event: serializeAgentStreamEvent({ event: serializeAgentStreamEvent({
type: "timeline", type: "timeline",
+103
View File
@@ -1,6 +1,8 @@
Refactor plan: full migration from ACP to the SDK AgentManager stack. This is a hard refactor—no shims, no temporary adapters. It’s acceptable (and expected) that TypeScript won’t compile in intermediate steps Refactor plan: full migration from ACP to the SDK AgentManager stack. This is a hard refactor—no shims, no temporary adapters. It’s acceptable (and expected) that TypeScript won’t compile in intermediate steps
as long as we keep pushing forward toward the final architecture, and have strong typing in place. as long as we keep pushing forward toward the final architecture, and have strong typing in place.
Commit after each task with a descriptive commit message.
These tasks need to be done sequentially by different agents: These tasks need to be done sequentially by different agents:
- [x] Inventory every ACP dependency (server session/websocket/messages/MCP/title generator/frontend reducers) and document the new AgentManager equivalents + message contracts (see docs/acp-dependency-inventory.md) - [x] Inventory every ACP dependency (server session/websocket/messages/MCP/title generator/frontend reducers) and document the new AgentManager equivalents + message contracts (see docs/acp-dependency-inventory.md)
@@ -20,6 +22,107 @@ These tasks need to be done sequentially by different agents:
- [x] Peer review of backend changes; address feedback, then review frontend changes; final regression pass before merging - [x] Peer review of backend changes; address feedback, then review frontend changes; final regression pass before merging
- [x] Final review of the codebase: check for duplicated code, untyped code, unused imports, etc. - [x] Final review of the codebase: check for duplicated code, untyped code, unused imports, etc.
# User testing and review
- [x] The create new agent modal stay in "Creating agent..." state forever, but the agent is created, if I close it I can see the agent in the list, hmm but wait when I click on the agent it says "Loading agent..." forever, I can see clean logs in the server, no errors:
Fixed by ensuring the server always emits an `agent_stream_snapshot` (even when empty) so the frontend clears the initializing state after a successful agent creation/initialization.
````log
[WS] create_agent_request details: {
cwd: '~/dev/voice-dev',
initialMode: 'full-access',
worktreeName: undefined,
requestId: 'msg_1763196834129_jtfh2egsk'
}
[Session client-4] Creating agent in ~/dev/voice-dev (codex)
[Session client-4] Created agent 29d1ee14-7687-44ce-8cd2-7c58b74288fa (codex)
[WS] Received message type: session {
type: 'session',
message: {
type: 'initialize_agent_request',
agentId: '29d1ee14-7687-44ce-8cd2-7c58b74288fa'
}
}
[Session client-4] Initializing agent 29d1ee14-7687-44ce-8cd2-7c58b74288fa on demand
[Session client-4] Agent 29d1ee14-7687-44ce-8cd2-7c58b74288fa initialized with 0 timeline item(s)
``
- [x] Super important: sending a prompt to an agent doesnt interrupt it, it must interrupt it and start a new turn, on top of their partial already streamed response.
Fixed by interrupting the agent's active run (Session.interruptAgentIfRunning) before starting a new stream so each prompt spins up a fresh turn immediately after the partial response ends.
- [x] We should not have separate edit, command and tool call events. Everything should be a tool call and be treated the same. Web fetch and editing a file should be shown the same for example in the agent stream view. Right now the commands and file edit are shown differently. Tool calls should show a loading pill when executing and then a completed pill when it's done. That pill when click we show the tool call bottom sheet. This was already working before.
- `AgentTimelineItem` now only uses `assistant_message`, `reasoning`, `tool_call`, `todo`, and `error` entries—Codex/Claude providers emit unified tool call payloads (callId, kind, displayName, input/output/error) for commands, MCP tools, file edits, web search, and permission prompts.
- The frontend stream reducer consumes the new tool call schema directly, so pills/bottom sheet share the same loading/completed lifecycle with raw inputs preserved for diff rendering again.
- `activity-curator`, tests, and docs updated to describe the shared tool call contract. Follow-up: the dedicated permission-hiding task below can now simply filter `tool_call` entries with `server === "permission"` or `kind === "permission"`.
- [x] I also noticed that with tool calls, we're not showing the input? we're dropping it somewhere, i notcied this with Claude tool calls, make sure you add a test for this, ask it to write a file or run a specific non-destructive command and assert that tool call are being emitted with the correct data. (Stream reducer now preserves the initial raw tool input payload, and `test-idempotent-stream.ts` includes `testToolCallInputPreservation` to guard against regressions.)
- [x] For the agent stream assistant message. I don't know how it happens but we lose spaces, like all the words are merged together. Maybe we are trimming chunks? (Fixed by preserving whitespace in `packages/app/src/types/stream.ts`; `test-idempotent-stream.ts` now includes `testAssistantWhitespacePreservation`—needs `ts-node/tsx` or similar runner since `node test-idempotent-stream.ts` currently fails on TS imports.)
- [x] Agents are being wiped out on restart: [AgentRegistry] Failed to load agents: SyntaxError: Unexpected non-whitespace character after JSON at position 368 (line 16 column 2)
- AgentRegistry now recovers when `agents.json` has trailing garbage (e.g. write crashes) by trimming to the last valid array, rewriting a sanitized copy, and continuing to load cached agents. Covered by `recovers from trailing garbage in agents.json` in `agent-registry.test.ts`.
- [x] We should not be showing permission granted/denied messages in the agent stream view. Permissions are only relevant when they're awaiting for a response, and we already had thism make sure its wirded up. (Filtered permission tool_call entries with `server === "permission" || kind === "permission"` in `packages/app/src/types/stream.ts`; `test-idempotent-stream.ts` now has `testPermissionToolCallFiltering`.)
- [x] In the file browser, we should show an icon for the file type on the right, for a quick visual clue of the file type. Support directory, image, text file or other. (Implemented directory/image/text/other detection in `file-explorer.tsx` with lucide icons.)
- [x] In the file browser we should remember the scroll when we come back from the file preview. It's frustrating to have to scroll back to where you were.
- Scroll position is now captured per directory view and restored when leaving a preview; changing directories still resets the offset to the top so navigation semantics stay predictable.
- [x] Add an agent kind indicator in the agent list, so we can quickly identify the agent kind (Claude, Codex, etc.). On the left of the status pill. (AgentSidebar now pulls the provider label from `getAgentProviderDefinition` and renders a muted badge between the cwd and status pill so Claude/Codex are visible at a glance.)
# User testing round 2
- [x] File browser icons are not showing up, they should be on the left side of the file name. (Icons now render before the filename with a fixed-width container so alignment stays consistent.)
- [x] Creating an agent doesnt redirect to the agent screen, it stays on the create agent modal. Hmm wait, its a bit of a hit and miss? Ah wait, i know, it only works for Codex for some reason. Claude hangs but the agent is in the list.
- Fixed by subscribing once to `status` messages inside `create-agent-modal.tsx` and matching against a persistent pending request ref so we can't miss the `agent_created` ack if it arrives before the effect registers. Both Codex and Claude creation now close the modal and redirect reliably.
- [x] User messages are not showing up across deamon restarts when loading agents, at least for Codex. I see assistant messages though, review Claude too just in case, add a regression test for this.
- AgentManager now records `user_message` timeline entries whenever text/audio prompts are sent (including message IDs) and emits them through `agent_stream_snapshot` so codex/claude history hydrates correctly after reconnects. The frontend reducer dedupes optimistic entries using the shared messageId, and `test-idempotent-stream.ts` includes `testUserMessageHydration` to guard against regressions.
- [x] Improve the file browser loading states, at the moment when we load a big directory, it just hangs on click, we should navigate immediately and show a loading state. (File explorer now switches paths optimistically and shows dedicated directory-loading banners/spinners driven by the pending request so clicks provide instant feedback even before entries stream back.)
- [x] Make the browser file list be a virtualized list to support large dirs. (File explorer now renders entries through a virtualized FlatList so scroll state + loading banners remain intact even for thousands of files.)
- [x] This is still happening, especially when the dev server restarts due to changes. I winder if we're not writing that file atomically or something?
[AgentRegistry] Failed to load agents: SyntaxError: Unexpected non-whitespace character after JSON at position 1848 (line 69 column 1)
at JSON.parse (<anonymous>)
at AgentRegistry.parseRecords (
Might be related I noticed we wrote this file:
ls -lah packages/server/packages/server/agents.json
-rw-r--r--@ 1 moboudra staff 1.8K 15 Nov 10:53 packages/server/packages/server/agents.json
It's in the wrong place, make sure all file writes are atomic and single source of truth. File being written in different places is a big red flag.
- AgentRegistry now resolves the server workspace root (so writes always land in `packages/server/agents.json` regardless of cwd) and flushes via temp-file+rename to avoid partial writes; sanitized rewrites reuse the same atomic helper.
- [x] something is clearly wrong with the storage:
[Session client-1] Failed to record agent config for f74ce920-d398-41a3-a9ca-cf5151f1afad: Error: ENOENT: no such file or directory, rename '/Users/moboudra/dev/voice-dev/packages/server/.agents.json.tmp-67975-1763201899064' -> '/Users/moboudra/dev/voice-dev/packages/server/agents.json'
at async Object.rename (node:internal/fs/promises:786:10)
at async writeFileAtomically (/Users/moboudra/dev/voice-dev/packages/server/src/server/agent/agent-registry.ts:276:3)
at async AgentRegistry.flush (/Users/moboudra/dev/voice-dev/packages/server/src/server/agent/agent-registry.ts:180:5)
at async AgentRegistry.recordConfig (/Users/moboudra/dev/voice-dev/packages/server/src/server/agent/agent-registry.ts:130:5)
at async Session.handleCreateAgentRequest (/Users/moboudra/dev/voice-dev/packages/server/src/server/session.ts:897:9)
at async Session.handleMessage (/Users/moboudra/dev/voice-dev/packages/server/src/server/session.ts:550:11)
at async VoiceAssistantWebSocketServer.handleMessage (/Users/moboudra/dev/voice-dev/packages/server/src/server/websocket-server.ts:184:15) {
errno: -2,
code: 'ENOENT',
syscall: 'rename',
path: '/Users/moboudra/dev/voice-dev/packages/server/.agents.json.tmp-67975-1763201899064',
dest: '/Users/moboudra/dev/voice-dev/packages/server/agents.json'
}
Cause was colliding temp filenames when multiple flushes landed in the same millisecond; `writeFileAtomically` now adds a `randomUUID` suffix so each flush writes to a unique tmp file before renaming.
- [x] I am not seeing my user messages in the agent stream view, on deamon restart. This was on Claude. Make sure you test this. Create agent, then hydrate it only from the persitance assert that you see the same events you received when creating the agent.
- Claude history hydration now recognizes `user` entries inside the provider JSONL logs and converts them into `user_message` timeline items (via `extractUserMessageText`), so persisted agents replay the full turn order after a daemon restart. Added `claude-agent.history.test.ts` to lock in the parser behavior.
# New feature, resuming agents that were created outside of the app
The new architecture supports this, which is great, lets take advantage of it and implement this.
- [x] In the new agent screen, we should add a toggle at the top, "new agent" or "resume agent", and when we toggle to "resume agent", we should show a list of agents that were created outside of the app, be able to filter by agent provider, you can leverage the ~/.claude and ~/.codex directories to list the agents available. Make sure you use a flat list as the agent list can be long. Allow searching by title. If not tile is provided by the persisted state, use the first message in the chat. For each item show the title, directory, time since last activity (sort by this). When we click on an item, we should hydrate this agent and put it into our own agent list. Maybe just load the last 20 or something for now sorted by date. Implement the list functionality in the providers to abstract this logic per provider.
- Server now exposes `list_persisted_agents_request` + `resume_agent_request`; provider clients look at `~/.claude/projects` + `~/.codex/sessions` and hydrate the latest 20 entries (title falls back to the first user message). The modal has a New/Resume toggle, provider chips, search, and a refreshed FlatList; tapping a card calls the new resume flow and shows a spinner until the `agent_resumed` status arrives. Follow-up: pagination/empty states beyond the first 20 items and persisting the fetched list client-side might be worth exploring if we need quicker refreshes.
- [x] The resume flow works well, but I'd like that when I use the Codex/Claude session in my laptop, which changes the storage in the home dir, we hydrate those somehow? dot know whats the best way, maybe we poll them and if tere are changes we re-emit new events? This way jumping between laptop and mobile is seamless. Maybe it's client driven, so when we navigate to an agent screen we check for new persised messages we haven't seen yet? If this is challenging but doable lets do it. If it's super hard, lets just add a "Refresh button" in the agent three dot menu, that esentially reloads the agent from scratch so we dont ahve to keep track of the messages we have seen and not seen. But the ideal solution is that its automatic for best UX.
- Added a manual "Refresh from disk" action in the agent three-dot menu; it rehydrates the agent via a new `refresh_agent_request`, swapping in a freshly resumed SDK session and replaying its full persisted timeline. This is a client-triggered refresh (automatic polling still TBD so future agents can revisit if we want background sync).
# Context # Context
Session now subscribes directly to `AgentManager` events and forwards `agent_state`, `agent_stream`, and permission messages; the websocket layer is back to a thin transport. Next agent should verify downstream consumers (frontend + MCP services) can ingest the new stream schema. Session now subscribes directly to `AgentManager` events and forwards `agent_state`, `agent_stream`, and permission messages; the websocket layer is back to a thin transport. Next agent should verify downstream consumers (frontend + MCP services) can ingest the new stream schema.
```
```
````
+182 -3
View File
@@ -1,4 +1,4 @@
import { reduceStreamUpdate, hydrateStreamState, type StreamItem } from "./packages/app/src/types/stream"; import { reduceStreamUpdate, hydrateStreamState, type StreamItem, type ToolCallItem } from "./packages/app/src/types/stream";
type AgentStreamEventPayload = Parameters<typeof reduceStreamUpdate>[1]; type AgentStreamEventPayload = Parameters<typeof reduceStreamUpdate>[1];
@@ -18,15 +18,47 @@ function reasoningTimeline(text: string): AgentStreamEventPayload {
}; };
} }
function toolTimeline(id: string, status: string): AgentStreamEventPayload { function toolTimeline(id: string, status: string, raw?: unknown): AgentStreamEventPayload {
return { return {
type: "timeline", type: "timeline",
provider: "claude", provider: "claude",
item: { item: {
type: "mcp_tool", type: "tool_call",
server: "terminal", server: "terminal",
tool: id, tool: id,
status, status,
callId: id,
displayName: id,
kind: "execute",
raw,
},
};
}
function permissionTimeline(id: string, status: string): AgentStreamEventPayload {
return {
type: "timeline",
provider: "claude",
item: {
type: "tool_call",
server: "permission",
tool: "permission_request",
status,
callId: id,
displayName: "Permission",
kind: "permission",
},
};
}
function userTimeline(text: string, messageId?: string): AgentStreamEventPayload {
return {
type: "timeline",
provider: "claude",
item: {
type: "user_message",
text,
messageId,
}, },
}; };
} }
@@ -147,6 +179,149 @@ function testMultipleMessages() {
} }
} }
// Test 4: Tool call raw input should survive completion updates
function testToolCallInputPreservation() {
console.log('\n=== Test 4: Tool Call Input Preservation ===');
const timestampStart = new Date("2025-01-01T10:00:00Z");
const timestampFinish = new Date("2025-01-01T10:00:05Z");
const toolCallId = "tool-raw-test";
const toolInput = {
type: "mcp_tool_use",
tool_use_id: toolCallId,
input: {
command: "pwd",
},
};
const toolResult = {
type: "mcp_tool_result",
tool_use_id: toolCallId,
output: {
stdout: "/tmp",
},
};
const updates = [
{ event: toolTimeline(toolCallId, "pending", toolInput), timestamp: timestampStart },
{ event: toolTimeline(toolCallId, "completed", toolResult), timestamp: timestampFinish },
];
const state = hydrateStreamState(updates);
const toolCallEntry = state.find(
(item): item is ToolCallItem =>
item.kind === "tool_call" && item.payload.source === "agent"
);
if (!toolCallEntry) {
console.log("❌ FAIL: Tool call entry not found");
return;
}
const rawPayload = toolCallEntry.payload.data.raw as Record<string, unknown> | undefined;
const hasInput =
!!rawPayload &&
typeof rawPayload === "object" &&
rawPayload !== null &&
"input" in rawPayload;
const preservedOriginal = rawPayload === toolInput;
if (hasInput && preservedOriginal) {
console.log("✅ PASS: Tool call raw input preserved after completion");
} else {
console.log("❌ FAIL: Tool call input was lost or replaced");
console.log("Raw payload:", rawPayload);
}
}
// Test 5: Assistant message chunks should preserve whitespace between words
function testAssistantWhitespacePreservation() {
console.log('\n=== Test 5: Assistant Message Whitespace Preservation ===');
const timestamp = new Date('2025-01-01T11:00:00Z');
const updates = [
{ event: assistantTimeline("Hello "), timestamp },
{ event: assistantTimeline("world"), timestamp },
{ event: assistantTimeline(" !"), timestamp },
];
const state = hydrateStreamState(updates);
const assistantMsg = state.find((item) => item.kind === "assistant_message");
if (assistantMsg && assistantMsg.text === "Hello world !") {
console.log("✅ PASS: Assistant message whitespace preserved");
} else {
console.log("❌ FAIL: Expected whitespace to be preserved");
console.log("Assistant message:", assistantMsg);
}
}
// Test 6: User messages should persist through hydration and deduplicate with live events
function testUserMessageHydration() {
console.log('\n=== Test 6: User Message Hydration ===');
const timestamp = new Date('2025-01-01T11:30:00Z');
const messageId = 'msg_user_1';
const updates = [
{ event: userTimeline('Run npm test', messageId), timestamp },
{ event: assistantTimeline('On it!'), timestamp },
];
const hydrated = hydrateStreamState(updates);
const hydratedUser = hydrated.find((item) => item.kind === 'user_message');
if (hydratedUser && hydratedUser.text === 'Run npm test' && hydratedUser.id === messageId) {
console.log('✅ PASS: Hydrated stream contains persisted user message');
} else {
console.log('❌ FAIL: Expected user message to survive hydration');
console.log('Hydrated state:', JSON.stringify(hydrated, null, 2));
}
const optimisticState: StreamItem[] = [
{ kind: 'user_message', id: messageId, text: 'Run npm test', timestamp },
];
const afterServerEvent = reduceStreamUpdate(
optimisticState,
userTimeline('Run npm test', messageId),
timestamp
);
if (afterServerEvent.length === 1 && afterServerEvent[0].kind === 'user_message') {
console.log('✅ PASS: Duplicate server event does not create extra user entry');
} else {
console.log('❌ FAIL: Duplicate server event should not add another user message');
console.log('State:', JSON.stringify(afterServerEvent, null, 2));
}
}
// Test 7: Permission tool calls should not show in the timeline
function testPermissionToolCallFiltering() {
console.log('\n=== Test 7: Permission Tool Call Filtering ===');
const timestamp = new Date('2025-01-01T12:00:00Z');
const updates = [
{ event: permissionTimeline('permission-1', 'pending'), timestamp },
{ event: permissionTimeline('permission-1', 'granted'), timestamp },
];
const state = hydrateStreamState(updates);
const permissionEntries = state.filter(
(item) => item.kind === 'tool_call' && item.payload.source === 'agent'
);
if (permissionEntries.length === 0) {
console.log('✅ PASS: Permission tool calls hidden from timeline');
} else {
console.log('❌ FAIL: Permission tool calls should be hidden');
console.log('State:', JSON.stringify(state, null, 2));
}
}
// Run all tests // Run all tests
console.log("Testing Idempotent Stream Reduction"); console.log("Testing Idempotent Stream Reduction");
console.log("===================================="); console.log("====================================");
@@ -154,6 +329,10 @@ console.log("====================================");
testIdempotentReduction(); testIdempotentReduction();
testUserMessageDeduplication(); testUserMessageDeduplication();
testMultipleMessages(); testMultipleMessages();
testToolCallInputPreservation();
testAssistantWhitespacePreservation();
testUserMessageHydration();
testPermissionToolCallFiltering();
console.log("\n===================================="); console.log("\n====================================");
console.log("Tests complete"); console.log("Tests complete");