refactor(agent): unify timeline events with tool_call schema and add resume/refresh capabilities
This commit is contained in:
@@ -13,7 +13,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
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 { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
@@ -34,6 +34,7 @@ export default function AgentScreen() {
|
||||
pendingPermissions,
|
||||
respondToPermission,
|
||||
initializeAgent,
|
||||
refreshAgent,
|
||||
} = useSession();
|
||||
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
@@ -192,6 +193,18 @@ export default function AgentScreen() {
|
||||
}
|
||||
}, [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) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -268,6 +281,28 @@ export default function AgentScreen() {
|
||||
<Folder size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>Browse Files</Text>
|
||||
</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>
|
||||
</Modal>
|
||||
@@ -334,6 +369,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
menuItemDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
menuItemSpinner: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
FlatList,
|
||||
Image as RNImage,
|
||||
ListRenderItemInfo,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
@@ -10,7 +14,15 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
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 { useSession, type ExplorerEntry } from "@/contexts/session-context";
|
||||
|
||||
@@ -36,6 +48,8 @@ export default function FileExplorerScreen() {
|
||||
const pendingFileParamRef = useRef<string | null>(null);
|
||||
const [copiedPath, setCopiedPath] = useState<string | 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 normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw));
|
||||
@@ -47,9 +61,21 @@ export default function FileExplorerScreen() {
|
||||
const agent = agentId ? agents.get(agentId) : undefined;
|
||||
const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
|
||||
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 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 preview = selectedEntryPath
|
||||
? explorerState?.files.get(selectedEntryPath)
|
||||
@@ -58,21 +84,39 @@ export default function FileExplorerScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
if (currentPath === ".") {
|
||||
if (activePath === ".") {
|
||||
return null;
|
||||
}
|
||||
const segments = currentPath.split("/");
|
||||
const segments = activePath.split("/");
|
||||
segments.pop();
|
||||
const nextPath = segments.join("/");
|
||||
return nextPath.length === 0 ? "." : nextPath;
|
||||
}, [currentPath]);
|
||||
}, [activePath]);
|
||||
|
||||
useEffect(() => {
|
||||
setCopiedPath(null);
|
||||
}, [currentPath]);
|
||||
}, [activePath]);
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -176,14 +290,14 @@ export default function FileExplorerScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title={selectedEntryPath ?? (currentPath || ".")} />
|
||||
<BackHeader title={selectedEntryPath ?? (activePath || ".")} />
|
||||
|
||||
<View style={styles.content}>
|
||||
{shouldShowPreview ? (
|
||||
<View style={styles.previewWrapper}>
|
||||
<UpRow label="Back to directory" onPress={() => setSelectedEntryPath(null)} />
|
||||
<View style={styles.previewSection}>
|
||||
{isLoading && !preview ? (
|
||||
{isPreviewLoading && !preview ? (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file...</Text>
|
||||
@@ -204,7 +318,7 @@ export default function FileExplorerScreen() {
|
||||
</ScrollView>
|
||||
) : preview.kind === "image" && preview.content ? (
|
||||
<View style={styles.imagePreviewContainer}>
|
||||
<Image
|
||||
<RNImage
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${
|
||||
preview.content
|
||||
@@ -230,52 +344,30 @@ export default function FileExplorerScreen() {
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
) : isLoading && entries.length === 0 ? (
|
||||
) : showInitialListLoading ? (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading...</Text>
|
||||
<Text style={styles.loadingText}>Loading directory...</Text>
|
||||
</View>
|
||||
) : entries.length === 0 ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.emptyText}>Directory is empty</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={styles.entriesContent}>
|
||||
{parentPath && <UpRow label=".." onPress={handleNavigateUp} />}
|
||||
{entries.map((entry) => (
|
||||
<Pressable
|
||||
key={entry.path}
|
||||
style={[
|
||||
styles.entryRow,
|
||||
entry.kind === "directory"
|
||||
? styles.directoryRow
|
||||
: styles.fileRow,
|
||||
]}
|
||||
onPress={() => handleEntryPress(entry)}
|
||||
>
|
||||
<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>
|
||||
<FlatList
|
||||
ref={listScrollRef}
|
||||
data={entries}
|
||||
renderItem={renderEntry}
|
||||
keyExtractor={(item) => item.path}
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onScroll={handleListScroll}
|
||||
scrollEventThrottle={16}
|
||||
ListHeaderComponent={listHeaderComponent}
|
||||
extraData={copiedPath}
|
||||
initialNumToRender={20}
|
||||
maxToRenderPerBatch={30}
|
||||
windowSize={10}
|
||||
/>
|
||||
)}
|
||||
</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 {
|
||||
if (size < 1024) {
|
||||
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) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -369,6 +563,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
entriesContent: {
|
||||
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: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[2],
|
||||
@@ -421,9 +630,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
selectedRow: {
|
||||
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: {
|
||||
flex: 1,
|
||||
marginRight: theme.spacing[3],
|
||||
},
|
||||
entryName: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -15,6 +15,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status";
|
||||
import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
|
||||
interface AgentSidebarProps {
|
||||
isOpen: boolean;
|
||||
@@ -179,6 +180,7 @@ export function AgentSidebar({
|
||||
const isActive = agent.id === activeAgentId;
|
||||
const statusColor = getAgentStatusColor(agent.status);
|
||||
const statusLabel = getAgentStatusLabel(agent.status);
|
||||
const providerLabel = getAgentProviderDefinition(agent.provider).label;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -209,6 +211,18 @@ export function AgentSidebar({
|
||||
</Text>
|
||||
|
||||
<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
|
||||
style={[styles.statusDot, { backgroundColor: statusColor }]}
|
||||
/>
|
||||
@@ -309,6 +323,15 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
},
|
||||
providerBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
},
|
||||
providerText: {
|
||||
fontSize: 11,
|
||||
fontWeight: "600",
|
||||
},
|
||||
statusDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
|
||||
@@ -235,9 +235,11 @@ export function AgentStreamView({
|
||||
|
||||
if (payload.source === "agent") {
|
||||
const data = payload.data;
|
||||
const toolLabel = data.displayName ?? `${data.server}/${data.tool}`;
|
||||
return (
|
||||
<ToolCall
|
||||
toolName={`${data.server}/${data.tool}`}
|
||||
toolName={toolLabel}
|
||||
kind={data.kind}
|
||||
args={data.raw}
|
||||
status={data.status as "executing" | "completed" | "failed"}
|
||||
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
InteractionManager,
|
||||
TextInput,
|
||||
Modal,
|
||||
useWindowDimensions,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItem,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
@@ -32,7 +34,8 @@ import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
type AgentProviderDefinition,
|
||||
} 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 {
|
||||
isVisible: boolean;
|
||||
@@ -48,6 +51,40 @@ const fallbackDefinition = providerDefinitions[0];
|
||||
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "";
|
||||
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({
|
||||
isVisible,
|
||||
@@ -61,7 +98,7 @@ export function CreateAgentModal({
|
||||
const isCompactLayout = screenWidth < 720;
|
||||
|
||||
const { recentPaths, addRecentPath } = useRecentPaths();
|
||||
const { ws, createAgent } = useSession();
|
||||
const { ws, createAgent, resumeAgent } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const [isMounted, setIsMounted] = useState(isVisible);
|
||||
@@ -73,12 +110,54 @@ export function CreateAgentModal({
|
||||
const [worktreeName, setWorktreeName] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
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 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 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(() => {
|
||||
if (!agentDefinition) {
|
||||
@@ -108,7 +187,7 @@ export function CreateAgentModal({
|
||||
setSelectedMode(DEFAULT_MODE_FOR_DEFAULT_PROVIDER);
|
||||
setErrorMessage("");
|
||||
setIsLoading(false);
|
||||
setPendingRequestId(null);
|
||||
pendingRequestIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
const navigateToAgentIfNeeded = useCallback(() => {
|
||||
@@ -130,6 +209,29 @@ export function CreateAgentModal({
|
||||
navigateToAgentIfNeeded();
|
||||
}, [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(() => {
|
||||
if (!isVisible) {
|
||||
console.log("[CreateAgentModal] visibility effect skipped (isVisible is false)", {
|
||||
@@ -284,8 +386,8 @@ export function CreateAgentModal({
|
||||
|
||||
const requestId = generateMessageId();
|
||||
|
||||
pendingRequestIdRef.current = requestId;
|
||||
setIsLoading(true);
|
||||
setPendingRequestId(requestId);
|
||||
setErrorMessage("");
|
||||
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||
@@ -306,7 +408,7 @@ export function CreateAgentModal({
|
||||
console.error("[CreateAgentModal] Failed to create agent:", error);
|
||||
setErrorMessage("Failed to create agent. Please try again.");
|
||||
setIsLoading(false);
|
||||
setPendingRequestId(null);
|
||||
pendingRequestIdRef.current = null;
|
||||
}
|
||||
}, [
|
||||
workingDir,
|
||||
@@ -319,26 +421,96 @@ export function CreateAgentModal({
|
||||
addRecentPath,
|
||||
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(() => {
|
||||
if (!pendingRequestId) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = ws.on("list_persisted_agents_response", (message) => {
|
||||
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) => {
|
||||
if (message.type !== "status") {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (payload.requestId !== pendingRequestId || !payload.agentId) {
|
||||
|
||||
const expectedRequestId = pendingRequestIdRef.current;
|
||||
if (!expectedRequestId || payload.requestId !== expectedRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[CreateAgentModal] Agent created:", payload.agentId);
|
||||
pendingRequestIdRef.current = null;
|
||||
setIsLoading(false);
|
||||
setPendingRequestId(null);
|
||||
pendingNavigationAgentIdRef.current = payload.agentId;
|
||||
handleClose();
|
||||
});
|
||||
@@ -346,7 +518,20 @@ export function CreateAgentModal({
|
||||
return () => {
|
||||
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;
|
||||
|
||||
@@ -395,93 +580,194 @@ export function CreateAgentModal({
|
||||
paddingLeft={horizontalPaddingLeft}
|
||||
paddingRight={horizontalPaddingRight}
|
||||
onClose={handleClose}
|
||||
title={activeTab === "resume" ? "Resume Agent" : "Create New Agent"}
|
||||
/>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
<View
|
||||
style={[
|
||||
styles.tabSelector,
|
||||
{
|
||||
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("");
|
||||
}}
|
||||
/>
|
||||
{tabOptions.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.id}
|
||||
onPress={() => setActiveTab(tab.id)}
|
||||
style={[styles.tabButton, isActive && styles.tabButtonActive]}
|
||||
>
|
||||
<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
|
||||
style={[
|
||||
styles.selectorRow,
|
||||
isCompactLayout && styles.selectorRowStacked,
|
||||
styles.resumeContainer,
|
||||
{
|
||||
paddingLeft: horizontalPaddingLeft,
|
||||
paddingRight: horizontalPaddingRight,
|
||||
paddingBottom: insets.bottom + defaultTheme.spacing[4],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<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 style={styles.resumeFilters}>
|
||||
<View style={styles.providerFilterRow}>
|
||||
{providerFilterOptions.map((option) => {
|
||||
const isActive = resumeProviderFilter === option.id;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
onPress={() => setResumeProviderFilter(option.id)}
|
||||
style={[styles.providerFilterButton, isActive && styles.providerFilterButtonActive]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.providerFilterText,
|
||||
isActive && styles.providerFilterTextActive,
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.resumeSearchRow}>
|
||||
<TextInput
|
||||
style={styles.resumeSearchInput}
|
||||
placeholder="Search by title or path"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
value={resumeSearchQuery}
|
||||
onChangeText={setResumeSearchQuery}
|
||||
/>
|
||||
<Pressable style={styles.refreshButton} onPress={refreshResumeList} disabled={isResumeLoading}>
|
||||
<Text style={styles.refreshButtonText}>Refresh</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
{resumeError ? <Text style={styles.resumeErrorText}>{resumeError}</Text> : null}
|
||||
{isResumeLoading ? (
|
||||
<View style={styles.resumeLoading}>
|
||||
<ActivityIndicator color={defaultTheme.colors.mutedForeground} />
|
||||
<Text style={styles.resumeLoadingText}>Loading saved agents...</Text>
|
||||
</View>
|
||||
) : filteredResumeCandidates.length === 0 ? (
|
||||
<View style={styles.resumeEmptyState}>
|
||||
<Text style={styles.resumeEmptyTitle}>No agents found</Text>
|
||||
<Text style={styles.resumeEmptySubtitle}>
|
||||
We'll load the latest Claude and Codex sessions from your local history.
|
||||
</Text>
|
||||
<Pressable style={styles.refreshButtonAlt} onPress={refreshResumeList}>
|
||||
<Text style={styles.refreshButtonAltText}>Try Again</Text>
|
||||
</Pressable>
|
||||
</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>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
@@ -494,12 +780,13 @@ interface ModalHeaderProps {
|
||||
paddingLeft: number;
|
||||
paddingRight: number;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function ModalHeader({ paddingTop, paddingLeft, paddingRight, onClose }: ModalHeaderProps): ReactElement {
|
||||
function ModalHeader({ paddingTop, paddingLeft, paddingRight, onClose, title }: ModalHeaderProps): ReactElement {
|
||||
return (
|
||||
<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}>
|
||||
<X size={20} color={defaultTheme.colors.mutedForeground} />
|
||||
</Pressable>
|
||||
@@ -742,6 +1029,35 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "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: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -894,4 +1210,161 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
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],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Eye,
|
||||
SquareTerminal,
|
||||
Brain,
|
||||
Search,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { baseColors, theme } from "@/styles/theme";
|
||||
@@ -778,6 +779,7 @@ const toolKindIcons: Record<string, any> = {
|
||||
edit: Pencil,
|
||||
read: Eye,
|
||||
execute: SquareTerminal,
|
||||
search: Search,
|
||||
// Add more mappings as needed
|
||||
};
|
||||
|
||||
|
||||
@@ -212,10 +212,10 @@ export function ToolCallBottomSheet({
|
||||
if (payload.source === "agent") {
|
||||
const data = payload.data;
|
||||
return {
|
||||
toolName: `${data.server}/${data.tool}`,
|
||||
toolName: data.displayName ?? `${data.server}/${data.tool}`,
|
||||
args: data.raw,
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
result: data.result,
|
||||
error: data.error,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -210,9 +210,11 @@ interface SessionContextValue {
|
||||
|
||||
// Helpers
|
||||
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
refreshAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
||||
sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<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;
|
||||
respondToPermission: (agentId: string, requestId: string, response: AgentPermissionResponse) => void;
|
||||
}
|
||||
@@ -729,6 +731,30 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
ws.send(msg);
|
||||
}, [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[]) => {
|
||||
// Generate unique message ID for deduplication
|
||||
const messageId = generateMessageId();
|
||||
@@ -832,6 +858,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
ws.send(msg);
|
||||
}, [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 msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
@@ -941,9 +980,11 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
initializeAgent,
|
||||
refreshAgent,
|
||||
sendAgentMessage,
|
||||
sendAgentAudio,
|
||||
createAgent,
|
||||
resumeAgent,
|
||||
setAgentMode,
|
||||
respondToPermission,
|
||||
};
|
||||
|
||||
+206
-126
@@ -68,6 +68,11 @@ export interface AgentToolCallData {
|
||||
tool: string;
|
||||
status: string;
|
||||
raw?: unknown;
|
||||
callId?: string;
|
||||
displayName?: string;
|
||||
kind?: string;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export type ToolCallPayload =
|
||||
@@ -81,6 +86,10 @@ export interface ToolCallItem {
|
||||
payload: ToolCallPayload;
|
||||
}
|
||||
|
||||
type AgentToolCallItem = ToolCallItem & {
|
||||
payload: { source: "agent"; data: AgentToolCallData };
|
||||
};
|
||||
|
||||
type ActivityLogType = "system" | "info" | "success" | "error";
|
||||
|
||||
export interface ActivityLogItem {
|
||||
@@ -92,12 +101,54 @@ export interface ActivityLogItem {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type FileChangeEntry = { path: string; kind: string };
|
||||
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[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!chunk) {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -105,24 +156,29 @@ function appendAssistantMessage(state: StreamItem[], text: string, timestamp: Da
|
||||
if (last && last.kind === "assistant_message") {
|
||||
const updated: AssistantMessageItem = {
|
||||
...last,
|
||||
text: `${last.text}${trimmed}`,
|
||||
text: `${last.text}${chunk}`,
|
||||
timestamp,
|
||||
};
|
||||
return [...state.slice(0, -1), updated];
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idSeed = chunk.trim() || chunk;
|
||||
const item: AssistantMessageItem = {
|
||||
kind: "assistant_message",
|
||||
id: createTimelineId("assistant", trimmed, timestamp),
|
||||
text: trimmed,
|
||||
id: createTimelineId("assistant", idSeed, timestamp),
|
||||
text: chunk,
|
||||
timestamp,
|
||||
};
|
||||
return [...state, item];
|
||||
}
|
||||
|
||||
function appendThought(state: StreamItem[], text: string, timestamp: Date): StreamItem[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!chunk) {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -130,16 +186,21 @@ function appendThought(state: StreamItem[], text: string, timestamp: Date): Stre
|
||||
if (last && last.kind === "thought") {
|
||||
const updated: ThoughtItem = {
|
||||
...last,
|
||||
text: `${last.text}${trimmed}`,
|
||||
text: `${last.text}${chunk}`,
|
||||
timestamp,
|
||||
};
|
||||
return [...state.slice(0, -1), updated];
|
||||
}
|
||||
|
||||
if (!hasContent) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idSeed = chunk.trim() || chunk;
|
||||
const item: ThoughtItem = {
|
||||
kind: "thought",
|
||||
id: createTimelineId("thought", trimmed, timestamp),
|
||||
text: trimmed,
|
||||
id: createTimelineId("thought", idSeed, timestamp),
|
||||
text: chunk,
|
||||
timestamp,
|
||||
};
|
||||
return [...state, item];
|
||||
@@ -150,19 +211,56 @@ function appendAgentToolCall(
|
||||
data: AgentToolCallData,
|
||||
timestamp: Date
|
||||
): StreamItem[] {
|
||||
const status = data.status;
|
||||
const id = createTimelineId(
|
||||
"tool",
|
||||
`${data.provider}:${data.server}:${data.tool}:${status}`,
|
||||
timestamp
|
||||
);
|
||||
const normalizedStatus = normalizeToolCallStatus(data.status);
|
||||
const callId = data.callId ?? extractToolCallId(data.raw);
|
||||
|
||||
const normalizedStatus: "executing" | "completed" | "failed" =
|
||||
status === "completed"
|
||||
? "completed"
|
||||
: status === "failed"
|
||||
? "failed"
|
||||
: "executing";
|
||||
const payloadData: AgentToolCallData = {
|
||||
...data,
|
||||
status: normalizedStatus,
|
||||
callId: callId ?? data.callId,
|
||||
};
|
||||
|
||||
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 = {
|
||||
kind: "tool_call",
|
||||
@@ -170,21 +268,82 @@ function appendAgentToolCall(
|
||||
timestamp,
|
||||
payload: {
|
||||
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];
|
||||
}
|
||||
|
||||
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[] {
|
||||
const index = state.findIndex((existing) => existing.id === entry.id);
|
||||
if (index >= 0) {
|
||||
@@ -195,64 +354,6 @@ function appendActivityLog(state: StreamItem[], entry: ActivityLogItem): StreamI
|
||||
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 {
|
||||
if (!items.length) {
|
||||
return "Todo list";
|
||||
@@ -278,55 +379,34 @@ export function reduceStreamUpdate(
|
||||
case "timeline": {
|
||||
const item = event.item;
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
return appendUserMessage(state, item.text, timestamp, item.messageId);
|
||||
case "assistant_message":
|
||||
return appendAssistantMessage(state, item.text, timestamp);
|
||||
case "reasoning":
|
||||
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(
|
||||
state,
|
||||
{
|
||||
provider: event.provider,
|
||||
server: item.server,
|
||||
tool: item.tool,
|
||||
status: item.status,
|
||||
raw: item.raw,
|
||||
status: item.status ?? "executing",
|
||||
raw: rawPayload,
|
||||
callId: item.callId,
|
||||
displayName: item.displayName,
|
||||
kind: item.kind,
|
||||
result: item.output,
|
||||
error: item.error,
|
||||
},
|
||||
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": {
|
||||
const items = (item.items ?? []) as TodoEntry[];
|
||||
|
||||
@@ -46,33 +46,51 @@ export function curateAgentActivity(
|
||||
|
||||
for (const item of recentItems) {
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
flushBuffers(lines, buffers);
|
||||
lines.push(`[User] ${item.text.trim()}`);
|
||||
break;
|
||||
case "assistant_message":
|
||||
buffers.message = appendText(buffers.message, item.text);
|
||||
break;
|
||||
case "reasoning":
|
||||
buffers.thought = appendText(buffers.thought, item.text);
|
||||
break;
|
||||
case "command":
|
||||
case "tool_call": {
|
||||
flushBuffers(lines, buffers);
|
||||
lines.push(`[Command: ${item.command}] ${item.status}`);
|
||||
break;
|
||||
case "file_change":
|
||||
flushBuffers(lines, buffers);
|
||||
if (item.files.length > 0) {
|
||||
lines.push("[File Changes]");
|
||||
for (const file of item.files) {
|
||||
lines.push(`- (${file.kind}) ${file.path}`);
|
||||
const label =
|
||||
item.displayName ??
|
||||
(item.server && item.tool ? `${item.server}.${item.tool}` : item.tool ?? item.server ?? "Tool");
|
||||
const status = item.status ? ` ${item.status}` : "";
|
||||
if (item.kind === "execute" || item.server === "command") {
|
||||
lines.push(`[Command: ${label}]${status}`);
|
||||
} else if (item.kind === "edit" || item.server === "file_change") {
|
||||
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;
|
||||
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":
|
||||
flushBuffers(lines, buffers);
|
||||
lines.push("[Plan]");
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
export type AgentLifecycleStatus =
|
||||
@@ -53,6 +55,10 @@ export type SubscribeOptions = {
|
||||
replayState?: boolean;
|
||||
};
|
||||
|
||||
export type PersistedAgentQueryOptions = ListPersistedAgentsOptions & {
|
||||
provider?: AgentProvider;
|
||||
};
|
||||
|
||||
export type AgentManagerOptions = {
|
||||
clients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||
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 {
|
||||
const agent = this.agents.get(id);
|
||||
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> {
|
||||
const agent = this.requireAgent(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(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||
import os from "node:os";
|
||||
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 type { AgentSnapshot } from "./agent-manager.js";
|
||||
@@ -113,4 +113,33 @@ describe("AgentRegistry", () => {
|
||||
const record = await registry.get("agent-3");
|
||||
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 path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentSnapshot } from "./agent-manager.js";
|
||||
@@ -51,13 +54,7 @@ export class AgentRegistry {
|
||||
|
||||
constructor(filePath?: string) {
|
||||
this.filePath =
|
||||
filePath ??
|
||||
path.join(
|
||||
process.cwd(),
|
||||
"packages",
|
||||
"server",
|
||||
"agents.json"
|
||||
);
|
||||
filePath ?? path.join(resolveServerPackageRoot(), "agents.json");
|
||||
}
|
||||
|
||||
async load(): Promise<StoredAgentRecord[]> {
|
||||
@@ -66,22 +63,9 @@ export class AgentRegistry {
|
||||
}
|
||||
try {
|
||||
const content = await fs.readFile(this.filePath, "utf8");
|
||||
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);
|
||||
}
|
||||
}
|
||||
const parsed = await this.parseContent(content);
|
||||
this.loaded = true;
|
||||
return records;
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
this.loaded = true;
|
||||
@@ -194,7 +178,63 @@ export class AgentRegistry {
|
||||
private async flush(): Promise<void> {
|
||||
const payload = JSON.stringify(Array.from(this.cache.values()), null, 2);
|
||||
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));
|
||||
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 =
|
||||
| { type: "user_message"; text: string; messageId?: string; raw?: unknown }
|
||||
| { type: "assistant_message"; 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: "mcp_tool"; server: string; tool: string; status: string; raw?: unknown }
|
||||
| { type: "web_search"; query: string; raw?: unknown }
|
||||
| {
|
||||
type: "tool_call";
|
||||
server: string;
|
||||
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: "error"; message: string; raw?: unknown };
|
||||
|
||||
@@ -105,6 +115,19 @@ export type AgentRunResult = {
|
||||
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 = {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
@@ -143,4 +166,5 @@ export interface AgentClient {
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
createSession(config: 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 commandEvents = timeline.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "command" }> =>
|
||||
item.type === "command" && typeof item.command === "string" && !item.command.startsWith("permission:")
|
||||
const toolCalls = timeline.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "tool_call" }> =>
|
||||
item.type === "tool_call"
|
||||
);
|
||||
const fileChangeEvent = timeline.find(
|
||||
const commandEvents = toolCalls.filter(
|
||||
(item) =>
|
||||
item.type === "file_change" &&
|
||||
item.files.some((file) => file.path.includes("tool-test.txt"))
|
||||
(item.kind === "execute" || item.server === "command") &&
|
||||
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(toolEvent).toBeTruthy();
|
||||
expect(toolCalls.length).toBeGreaterThan(0);
|
||||
expect(sawPwdCommand).toBe(true);
|
||||
expect(fileChangeEvent).toBeTruthy();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import fs, { promises as fsPromises } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
@@ -35,6 +35,8 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -71,7 +73,43 @@ const DEFAULT_MODES: AgentMode[] = [
|
||||
|
||||
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 = {
|
||||
request: AgentPermissionRequest;
|
||||
@@ -81,6 +119,7 @@ type PendingPermission = {
|
||||
};
|
||||
|
||||
type ToolUseClassification = "generic" | "command" | "file_change";
|
||||
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
type ToolUseCacheEntry = {
|
||||
id: string;
|
||||
@@ -90,6 +129,7 @@ type ToolUseCacheEntry = {
|
||||
started: boolean;
|
||||
commandText?: string;
|
||||
files?: { path: string; kind: string }[];
|
||||
input?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000;
|
||||
@@ -123,6 +163,28 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
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 {
|
||||
if (config.provider !== "claude") {
|
||||
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
|
||||
@@ -269,10 +331,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (response.behavior === "allow") {
|
||||
if (pending.request.kind === "plan") {
|
||||
await this.setMode("acceptEdits");
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: "plan:approved",
|
||||
this.pushToolCall({
|
||||
server: "plan",
|
||||
tool: "plan_approval",
|
||||
status: "granted",
|
||||
callId: pending.request.id,
|
||||
displayName: "Plan approved",
|
||||
kind: "plan",
|
||||
raw: pending.request,
|
||||
});
|
||||
}
|
||||
@@ -282,10 +347,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions),
|
||||
};
|
||||
pending.resolve(result);
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: `permission:${pending.request.name}`,
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: pending.request.name,
|
||||
status: "granted",
|
||||
callId: pending.request.id,
|
||||
displayName: pending.request.title ?? pending.request.name,
|
||||
kind: "permission",
|
||||
input: pending.request.input,
|
||||
raw: { request: pending.request, response },
|
||||
});
|
||||
} else {
|
||||
@@ -295,10 +364,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
interrupt: response.interrupt,
|
||||
};
|
||||
pending.resolve(result);
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: `permission:${pending.request.name}`,
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: pending.request.name,
|
||||
status: "denied",
|
||||
callId: pending.request.id,
|
||||
displayName: pending.request.title ?? pending.request.name,
|
||||
kind: "permission",
|
||||
input: pending.request.input,
|
||||
raw: { request: pending.request, response },
|
||||
});
|
||||
}
|
||||
@@ -551,10 +624,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
raw: { toolName, input, options },
|
||||
};
|
||||
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: `permission:${toolName}`,
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: toolName,
|
||||
status: "requested",
|
||||
callId: requestId,
|
||||
displayName: request.title ?? toolName,
|
||||
kind: "permission",
|
||||
input,
|
||||
raw: { toolName, input },
|
||||
});
|
||||
|
||||
@@ -576,10 +653,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.pendingPermissions.delete(requestId);
|
||||
cleanup();
|
||||
const error = new Error("Permission request timed out");
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: `permission:${toolName}`,
|
||||
this.pushToolCall({
|
||||
server: "permission",
|
||||
tool: toolName,
|
||||
status: "denied",
|
||||
callId: requestId,
|
||||
displayName: request.title ?? toolName,
|
||||
kind: "permission",
|
||||
input,
|
||||
raw: { reason: "timeout", toolName, input },
|
||||
});
|
||||
this.pushEvent({
|
||||
@@ -634,6 +715,37 @@ class ClaudeAgentSession implements AgentSession {
|
||||
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) {
|
||||
if (this.eventQueue) {
|
||||
this.eventQueue.push(event);
|
||||
@@ -700,20 +812,39 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
private convertHistoryEntry(entry: any): AgentTimelineItem[] {
|
||||
const message = entry?.message;
|
||||
if (!message) {
|
||||
if (!message || !("content" in message)) {
|
||||
return [];
|
||||
}
|
||||
if (entry.type === "assistant" && message.content) {
|
||||
return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]);
|
||||
const content = message.content as string | ClaudeContentChunk[];
|
||||
|
||||
if (entry.type === "assistant" && content) {
|
||||
return this.mapBlocksToTimeline(content);
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
const hasToolBlock = message.content.some((block: ClaudeContentChunk) =>
|
||||
typeof block?.type === "string" && block.type.includes("tool")
|
||||
|
||||
if (entry.type === "user") {
|
||||
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) {
|
||||
return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]);
|
||||
return this.mapBlocksToTimeline(content);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -773,28 +904,19 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
entry.started = true;
|
||||
this.toolUseCache.set(entry.id, entry);
|
||||
this.enqueueTimeline({
|
||||
type: "command",
|
||||
command: `permission:${entry.name}`,
|
||||
status: "granted",
|
||||
raw: block,
|
||||
});
|
||||
if (entry.classification === "command") {
|
||||
const commandText = entry.commandText ?? entry.name;
|
||||
items.push({
|
||||
type: "command",
|
||||
command: commandText,
|
||||
status: "running",
|
||||
this.pushToolCall(
|
||||
{
|
||||
server: entry.server,
|
||||
tool: entry.name,
|
||||
status: "pending",
|
||||
callId: entry.id,
|
||||
displayName: this.buildToolDisplayName(entry),
|
||||
kind: this.getToolKind(entry.classification),
|
||||
input: entry.input ?? this.normalizeToolInput(block.input),
|
||||
raw: block,
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
type: "mcp_tool",
|
||||
server: entry.server,
|
||||
tool: entry.name,
|
||||
status: "pending",
|
||||
raw: block,
|
||||
});
|
||||
},
|
||||
items
|
||||
);
|
||||
}
|
||||
|
||||
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
||||
@@ -802,29 +924,25 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const server = entry?.server ?? block.server ?? "tool";
|
||||
const tool = entry?.name ?? block.tool_name ?? "tool";
|
||||
const status = block.is_error ? "failed" : "completed";
|
||||
items.push({
|
||||
type: "mcp_tool",
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
raw: block,
|
||||
});
|
||||
if (entry?.classification === "command") {
|
||||
const commandText = entry.commandText ?? entry.name;
|
||||
items.push({
|
||||
type: "command",
|
||||
command: commandText,
|
||||
const rawPayload =
|
||||
!block.is_error && entry?.classification === "file_change" && entry.files?.length
|
||||
? { block, files: entry.files }
|
||||
: block;
|
||||
this.pushToolCall(
|
||||
{
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
raw: block,
|
||||
});
|
||||
}
|
||||
if (!block.is_error && entry?.classification === "file_change" && entry.files?.length) {
|
||||
items.push({
|
||||
type: "file_change",
|
||||
files: entry.files,
|
||||
raw: { block, files: entry.files },
|
||||
});
|
||||
}
|
||||
callId: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
|
||||
displayName: this.buildToolDisplayName(entry),
|
||||
kind: this.getToolKind(entry?.classification),
|
||||
input: entry?.input,
|
||||
output: !block.is_error && entry?.files?.length ? { files: entry.files } : undefined,
|
||||
error: block.is_error ? block : undefined,
|
||||
raw: rawPayload,
|
||||
},
|
||||
items
|
||||
);
|
||||
if (typeof block.tool_use_id === "string") {
|
||||
this.toolUseCache.delete(block.tool_use_id);
|
||||
}
|
||||
@@ -872,6 +990,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
if (block.type === "tool_use") {
|
||||
const input = this.normalizeToolInput(block.input);
|
||||
existing.input = input;
|
||||
if (input) {
|
||||
if (this.isCommandTool(existing.name, input)) {
|
||||
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 (
|
||||
event.type === "timeline" &&
|
||||
event.provider === "codex" &&
|
||||
(event.item.type === "file_change" || event.item.type === "command" || event.item.type === "mcp_tool")
|
||||
event.item.type === "tool_call"
|
||||
) {
|
||||
sawActivity = true;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
AgentUsage,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
type CodexAgentConfig = AgentSessionConfig & { provider: "codex" };
|
||||
@@ -96,6 +98,33 @@ const MODE_PRESETS: Record<
|
||||
type CodexOptionsOverrides = Partial<ThreadOptions> & { skipGitRepoCheck?: boolean };
|
||||
|
||||
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 {
|
||||
if (!value) return undefined;
|
||||
@@ -166,6 +195,29 @@ export class CodexAgentClient implements AgentClient {
|
||||
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 {
|
||||
if (config.provider !== "codex") {
|
||||
throw new Error(`CodexAgentClient received config for provider '${config.provider}'`);
|
||||
@@ -323,12 +375,16 @@ class CodexAgentSession implements AgentSession {
|
||||
this.enqueueHistoryEvent({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "command",
|
||||
command: `permission:${request.name}`,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "permission",
|
||||
tool: request.name,
|
||||
status,
|
||||
callId: request.id,
|
||||
displayName: request.title ?? request.name,
|
||||
kind: "permission",
|
||||
input: request.input,
|
||||
raw: { request, response },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
this.enqueueHistoryEvent({
|
||||
@@ -579,12 +635,16 @@ class CodexAgentSession implements AgentSession {
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "command",
|
||||
command: `permission:${request.name}`,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "permission",
|
||||
tool: request.name,
|
||||
status: "requested",
|
||||
callId: request.id,
|
||||
displayName: request.title ?? request.name,
|
||||
kind: "permission",
|
||||
input: request.input,
|
||||
raw: request.raw,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ type: "permission_requested", provider: "codex", request },
|
||||
];
|
||||
@@ -698,23 +758,54 @@ class CodexAgentSession implements AgentSession {
|
||||
case "reasoning":
|
||||
return { type: "reasoning", text: item.text, raw: item };
|
||||
case "command_execution":
|
||||
return { type: "command", command: item.command, status: item.status, raw: item };
|
||||
case "file_change":
|
||||
return {
|
||||
type: "file_change",
|
||||
files: item.changes.map((change) => ({ path: change.path, kind: change.kind })),
|
||||
return createToolCallTimelineItem({
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
status: item.status,
|
||||
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,
|
||||
};
|
||||
});
|
||||
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":
|
||||
return {
|
||||
type: "mcp_tool",
|
||||
return createToolCallTimelineItem({
|
||||
server: item.server,
|
||||
tool: item.tool,
|
||||
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,
|
||||
};
|
||||
});
|
||||
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":
|
||||
return { type: "todo", items: item.items, raw: item };
|
||||
case "error":
|
||||
@@ -894,13 +985,16 @@ function handleRolloutFunctionCall(
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "mcp_tool",
|
||||
item: createToolCallTimelineItem({
|
||||
server: "codex",
|
||||
tool: name,
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -922,12 +1016,17 @@ function finalizeRolloutFunctionCall(
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "command",
|
||||
command: command.command,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "command",
|
||||
tool: "shell",
|
||||
status,
|
||||
callId: payload.call_id,
|
||||
displayName: buildCommandDisplayName(command.command),
|
||||
kind: "execute",
|
||||
input: { command: command.command },
|
||||
output: result,
|
||||
raw: { payload, result },
|
||||
},
|
||||
}),
|
||||
});
|
||||
commandCalls.delete(payload.call_id);
|
||||
}
|
||||
@@ -939,11 +1038,15 @@ function handleRolloutCustomToolCall(payload: any, events: AgentStreamEvent[]):
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "file_change",
|
||||
files,
|
||||
item: createToolCallTimelineItem({
|
||||
server: "file_change",
|
||||
tool: "apply_patch",
|
||||
status: "completed",
|
||||
displayName: buildFileChangeSummary(files),
|
||||
kind: "edit",
|
||||
output: { files },
|
||||
raw: payload,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -953,17 +1056,147 @@ function handleRolloutCustomToolCall(payload: any, events: AgentStreamEvent[]):
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "mcp_tool",
|
||||
item: createToolCallTimelineItem({
|
||||
server: "codex",
|
||||
tool: payload.name,
|
||||
status: payload.status ?? "completed",
|
||||
displayName: payload.name,
|
||||
kind: "tool",
|
||||
input: payload.input,
|
||||
output: payload.output,
|
||||
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 {
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
|
||||
@@ -112,6 +112,12 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
|
||||
|
||||
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("user_message"),
|
||||
text: z.string(),
|
||||
messageId: z.string().optional(),
|
||||
raw: z.unknown().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("assistant_message"),
|
||||
text: z.string(),
|
||||
@@ -123,31 +129,16 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem> =
|
||||
raw: z.unknown().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("command"),
|
||||
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"),
|
||||
type: z.literal("tool_call"),
|
||||
server: z.string(),
|
||||
tool: z.string(),
|
||||
status: z.string(),
|
||||
raw: z.unknown().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("web_search"),
|
||||
query: z.string(),
|
||||
status: z.string().optional(),
|
||||
callId: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
input: z.unknown().optional(),
|
||||
output: z.unknown().optional(),
|
||||
error: z.unknown().optional(),
|
||||
raw: z.unknown().optional(),
|
||||
}),
|
||||
z.object({
|
||||
@@ -334,6 +325,25 @@ export const CreateAgentRequestMessageSchema = z.object({
|
||||
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({
|
||||
type: z.literal("initialize_agent_request"),
|
||||
agentId: z.string(),
|
||||
@@ -400,11 +410,14 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SendAgentMessageSchema,
|
||||
SendAgentAudioSchema,
|
||||
CreateAgentRequestMessageSchema,
|
||||
ResumeAgentRequestMessageSchema,
|
||||
RefreshAgentRequestMessageSchema,
|
||||
InitializeAgentRequestMessageSchema,
|
||||
SetAgentModeMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
GitDiffRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
ListPersistedAgentsRequestMessageSchema,
|
||||
]);
|
||||
|
||||
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({
|
||||
type: z.literal("git_diff_response"),
|
||||
payload: z.object({
|
||||
@@ -618,6 +649,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
DeleteConversationResponseMessageSchema,
|
||||
AgentPermissionRequestMessageSchema,
|
||||
AgentPermissionResolvedMessageSchema,
|
||||
ListPersistedAgentsResponseSchema,
|
||||
GitDiffResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
]);
|
||||
@@ -645,6 +677,7 @@ export type ListConversationsResponseMessage = z.infer<typeof ListConversationsR
|
||||
export type DeleteConversationResponseMessage = z.infer<typeof DeleteConversationResponseMessageSchema>;
|
||||
export type AgentPermissionRequestMessage = z.infer<typeof AgentPermissionRequestMessageSchema>;
|
||||
export type AgentPermissionResolvedMessage = z.infer<typeof AgentPermissionResolvedMessageSchema>;
|
||||
export type ListPersistedAgentsResponseMessage = z.infer<typeof ListPersistedAgentsResponseSchema>;
|
||||
|
||||
// Type exports for payload types
|
||||
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 SendAgentAudio = z.infer<typeof SendAgentAudioSchema>;
|
||||
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 SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
|
||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||
|
||||
@@ -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.)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -516,6 +550,14 @@ export class Session {
|
||||
await this.handleCreateAgentRequest(msg);
|
||||
break;
|
||||
|
||||
case "resume_agent_request":
|
||||
await this.handleResumeAgentRequest(msg);
|
||||
break;
|
||||
|
||||
case "refresh_agent_request":
|
||||
await this.handleRefreshAgentRequest(msg);
|
||||
break;
|
||||
|
||||
case "initialize_agent_request":
|
||||
await this.handleInitializeAgentRequest(msg.agentId, msg.requestId);
|
||||
break;
|
||||
@@ -539,6 +581,10 @@ export class Session {
|
||||
case "file_explorer_request":
|
||||
await this.handleFileExplorerRequest(msg);
|
||||
break;
|
||||
|
||||
case "list_persisted_agents_request":
|
||||
await this.handleListPersistedAgentsRequest(msg);
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
@@ -657,7 +703,7 @@ export class Session {
|
||||
private async handleSendAgentMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
_messageId?: string,
|
||||
messageId?: string,
|
||||
images?: Array<{ data: string; mimeType: string }>
|
||||
): Promise<void> {
|
||||
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)` : ''}`
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
this.startAgentStream(agentId, transcriptText);
|
||||
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(
|
||||
config: AgentSessionConfig,
|
||||
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
|
||||
*/
|
||||
@@ -1154,10 +1379,6 @@ export class Session {
|
||||
|
||||
private emitAgentTimelineSnapshot(agent: AgentSnapshot): number {
|
||||
const timeline = this.agentManager.getTimeline(agent.id);
|
||||
if (timeline.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const events = timeline.map((item) => ({
|
||||
event: serializeAgentStreamEvent({
|
||||
type: "timeline",
|
||||
|
||||
Reference in New Issue
Block a user