From 888af294858b480b95a301ad1af5eaeb25eeb697 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 3 Mar 2026 17:35:48 +0700 Subject: [PATCH] feat: add worktree archiving with context menu and improved logging --- packages/app/src/components/agent-list.tsx | 4 +- .../app/src/components/sidebar-agent-list.tsx | 262 ++++++++++++++++-- .../app/src/components/ui/context-menu.tsx | 4 + .../app/src/hooks/use-aggregated-agents.ts | 3 + packages/app/src/hooks/use-all-agents-list.ts | 3 - packages/app/src/runtime/host-runtime.ts | 2 +- .../src/stores/checkout-git-actions-store.ts | 44 +++ .../server/src/server/agent/agent-manager.ts | 111 ++++++++ .../server/agent/providers/claude-agent.ts | 95 +++++++ packages/server/src/server/session.ts | 16 ++ scripts/dev.sh | 9 +- 11 files changed, 529 insertions(+), 24 deletions(-) diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 15b5d78a3..15d919a17 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -98,6 +98,7 @@ function AgentListRow({ const timeAgo = formatTimeAgo(agent.lastActivityAt); const agentKey = `${agent.serverId}:${agent.id}`; const isSelected = selectedAgentId === agentKey; + const archivedLabel = agent.archivedAt ? "Archived" : null; const checkoutQuery = useCheckoutStatusCacheOnly({ serverId: agent.serverId, cwd: agent.cwd, @@ -137,7 +138,8 @@ function AgentListRow({ {shortenPath(projectPath)} - {branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo} + {branchLabel ? ` · ${branchLabel}` : ""} + {archivedLabel ? ` · ${archivedLabel}` : ""} · {timeAgo} )} diff --git a/packages/app/src/components/sidebar-agent-list.tsx b/packages/app/src/components/sidebar-agent-list.tsx index e1e13f3fc..3579b788a 100644 --- a/packages/app/src/components/sidebar-agent-list.tsx +++ b/packages/app/src/components/sidebar-agent-list.tsx @@ -1,4 +1,4 @@ -import { View, Text, Pressable, Image, Platform } from 'react-native' +import { View, Text, Pressable, Image, Platform, Alert, ActivityIndicator, StatusBar } from 'react-native' import { useQueries } from '@tanstack/react-query' import { useCallback, @@ -11,8 +11,8 @@ import { } from 'react' import { router, usePathname, useSegments } from 'expo-router' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' -import { type GestureType } from 'react-native-gesture-handler' -import { ChevronDown, ChevronRight } from 'lucide-react-native' +import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler' +import { Archive, ChevronDown, ChevronRight } from 'lucide-react-native' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' import { projectIconQueryKey } from '@/hooks/use-project-icon-query' @@ -27,8 +27,17 @@ import { type SidebarWorkspaceEntry, } from '@/hooks/use-sidebar-agents-list' import { useSidebarOrderStore } from '@/stores/sidebar-order-store' +import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store' import { formatTimeAgo } from '@/utils/time' import type { SidebarStateBucket } from '@/utils/sidebar-agent-state' +import { confirmDialog } from '@/utils/confirm-dialog' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + useContextMenu, +} from '@/components/ui/context-menu' type SidebarTreeRow = | { @@ -76,7 +85,7 @@ interface WorkspaceRowProps { workspace: SidebarWorkspaceEntry selected: boolean onPress: () => void - onLongPress: () => void + drag: () => void } function deriveProjectDisplayName(input: { projectKey: string; projectName: string }): string { @@ -135,10 +144,25 @@ function resolveStatusDotColor(input: { theme: ReturnType[' : theme.colors.border } -function WorkspaceStatusDot({ bucket }: { bucket: SidebarWorkspaceEntry['statusBucket'] }) { +function WorkspaceStatusIndicator({ + bucket, + loading = false, +}: { + bucket: SidebarWorkspaceEntry['statusBucket'] + loading?: boolean +}) { const { theme } = useUnistyles() const color = resolveStatusDotColor({ theme, bucket }) - return + + return ( + + {loading ? ( + + ) : ( + + )} + + ) } function ProjectRow({ @@ -201,10 +225,71 @@ function ProjectRow({ ) } -function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRowProps) { - const didLongPressRef = useRef(false) +function WorkspaceRow({ workspace, selected, onPress, drag }: WorkspaceRowProps) { + const { theme } = useUnistyles() const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) + const { setAnchorRect, setOpen } = useContextMenu() + const didLongPressRef = useRef(false) + const didLongPressCleanupTimerRef = useRef | null>(null) + const longPressArmedRef = useRef(false) + const longPressCancelledRef = useRef(false) + const didStartDragRef = useRef(false) + const touchStartRef = useRef<{ x: number; y: number } | null>(null) + + const archiveStatus = useCheckoutGitActionsStore((state) => + state.getStatus({ + serverId: workspace.serverId, + cwd: workspace.cwd, + actionId: 'archive-worktree', + }) + ) + const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) + const isArchiving = archiveStatus === 'pending' + + const openContextMenuAtTouchStart = useCallback(() => { + const point = touchStartRef.current + if (!point) { + return + } + const statusBarHeight = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 + setAnchorRect({ + x: point.x, + y: point.y + statusBarHeight, + width: 0, + height: 0, + }) + setOpen(true) + }, [setAnchorRect, setOpen]) + + const handleArchiveWorktree = useCallback(() => { + if (!workspace.isPaseoOwnedWorktree) { + return + } + + void confirmDialog({ + title: 'Archive worktree?', + message: `Archive this worktree?\n\n${workspace.cwd}`, + confirmLabel: 'Archive', + cancelLabel: 'Cancel', + destructive: true, + }) + .then((confirmed) => { + if (!confirmed) { + return + } + return runArchiveWorktree({ + serverId: workspace.serverId, + cwd: workspace.cwd, + worktreePath: workspace.cwd, + }) + }) + .catch((err) => { + const message = err instanceof Error ? err.message : 'Failed to archive worktree' + Alert.alert('Error', message) + }) + }, [runArchiveWorktree, workspace.cwd, workspace.isPaseoOwnedWorktree, workspace.serverId]) + const handlePress = useCallback(() => { if (didLongPressRef.current) { didLongPressRef.current = false @@ -214,25 +299,115 @@ function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRo }, [onPress]) const handleLongPress = useCallback(() => { + if (longPressCancelledRef.current) { + return + } didLongPressRef.current = true - onLongPress() - }, [onLongPress]) + longPressArmedRef.current = true + }, []) - return ( - { + if (Platform.OS === 'web') { + return null + } + + const CANCEL_SLOP_PX = 10 + const DRAG_SLOP_PX = 8 + + return Gesture.Pan() + .manualActivation(true) + .runOnJS(true) + .onTouchesDown((event) => { + const touch = event.changedTouches[0] + if (!touch) { + return + } + touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY } + }) + .onTouchesMove((event, stateManager) => { + const touch = event.changedTouches[0] + if (!touch || event.numberOfTouches !== 1) { + stateManager.fail() + return + } + + const start = touchStartRef.current + if (!start) { + touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY } + return + } + + const dx = touch.absoluteX - start.x + const dy = touch.absoluteY - start.y + const distance = Math.sqrt(dx * dx + dy * dy) + + if (!longPressArmedRef.current) { + if (distance > CANCEL_SLOP_PX) { + longPressCancelledRef.current = true + stateManager.fail() + } + return + } + + if (didStartDragRef.current) { + return + } + + if (distance > DRAG_SLOP_PX) { + didStartDragRef.current = true + drag() + stateManager.fail() + } + }) + }, [drag]) + + const trigger = ( + [ styles.workspaceRow, selected && styles.workspaceRowSelected, hovered && styles.workspaceRowHovered, pressed && styles.workspaceRowPressed, ]} + onPressIn={(event) => { + if (didLongPressCleanupTimerRef.current) { + clearTimeout(didLongPressCleanupTimerRef.current) + didLongPressCleanupTimerRef.current = null + } + longPressCancelledRef.current = false + longPressArmedRef.current = false + didStartDragRef.current = false + touchStartRef.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY } + }} + onPressOut={() => { + if (Platform.OS === 'web') { + return + } + if (!longPressArmedRef.current || didStartDragRef.current) { + longPressCancelledRef.current = false + longPressArmedRef.current = false + didStartDragRef.current = false + touchStartRef.current = null + return + } + openContextMenuAtTouchStart() + didLongPressCleanupTimerRef.current = setTimeout(() => { + didLongPressRef.current = false + didLongPressCleanupTimerRef.current = null + }, 0) + longPressCancelledRef.current = false + longPressArmedRef.current = false + didStartDragRef.current = false + touchStartRef.current = null + }} onPress={handlePress} onLongPress={handleLongPress} delayLongPress={200} testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > - + {resolveWorkspaceBranchLabel(workspace)} @@ -242,7 +417,54 @@ function WorkspaceRow({ workspace, selected, onPress, onLongPress }: WorkspaceRo {createdAtLabel} ) : null} - + + ) + + return ( + <> + {moveMonitorGesture ? ( + {trigger} + ) : ( + trigger + )} + + + } + destructive + disabled={!workspace.isPaseoOwnedWorktree} + status={workspace.isPaseoOwnedWorktree ? archiveStatus : 'idle'} + pendingLabel="Archiving…" + successLabel="Archived" + onSelect={handleArchiveWorktree} + testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`} + > + Archive worktree + + + + ) +} + +function WorkspaceRowWithMenu({ + workspace, + selected, + onPress, + drag, +}: { + workspace: SidebarWorkspaceEntry + selected: boolean + onPress: () => void + drag: () => void +}) { + return ( + + + ) } @@ -452,7 +674,7 @@ export function SidebarAgentList({ activeWorkspaceSelection.workspaceId === item.workspace.cwd return ( - { @@ -462,7 +684,7 @@ export function SidebarAgentList({ onWorkspacePress?.() navigate(workspaceRoute as any) }} - onLongPress={drag} + drag={drag} /> ) }, @@ -671,6 +893,14 @@ const styles = StyleSheet.create((theme) => ({ height: 8, borderRadius: theme.borderRadius.full, flexShrink: 0, + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + workspaceStatusDotFill: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, }, workspaceBranchText: { color: theme.colors.foreground, diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index eea75d193..ce5326396 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -58,6 +58,10 @@ function useContextMenuContext(componentName: string): ContextMenuContextValue { return ctx; } +export function useContextMenu(): ContextMenuContextValue { + return useContextMenuContext("useContextMenu"); +} + function useControllableOpenState({ open, defaultOpen, diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index f7b4a170a..30324af3b 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -55,6 +55,9 @@ export function useAggregatedAgents(): AggregatedAgentsResult { } const serverLabel = serverLabelById.get(serverId) ?? serverId; for (const agent of agents.values()) { + if (agent.archivedAt) { + continue; + } const nextAgent: AggregatedAgent = { id: agent.id, serverId, diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index c9508a244..319b889cd 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -74,9 +74,6 @@ export function useAllAgentsList(options?: { serverId, serverLabel, }); - if (aggregated.archivedAt) { - continue; - } if (aggregated.labels.ui !== "true") { continue; } diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 2f3ac7eb6..252b012d0 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1113,7 +1113,7 @@ export class HostRuntimeStore { controller.markAgentDirectorySyncLoading(); try { const payload = await client.fetchAgents({ - filter: input.filter ?? { labels: { ui: "true" } }, + filter: input.filter ?? { labels: { ui: "true" }, includeArchived: true }, ...(input.subscribe ? { subscribe: input.subscribe } : {}), ...(input.page ? { page: input.page } : {}), }); diff --git a/packages/app/src/stores/checkout-git-actions-store.ts b/packages/app/src/stores/checkout-git-actions-store.ts index 7e9cd85ed..7db0c3084 100644 --- a/packages/app/src/stores/checkout-git-actions-store.ts +++ b/packages/app/src/stores/checkout-git-actions-store.ts @@ -86,6 +86,49 @@ function invalidateWorktreeList() { predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList", }); + void queryClient.invalidateQueries({ + predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList", + }); +} + +function removeWorktreeFromCachedLists(input: { + serverId: string; + worktreePath: string; +}): void { + const serverId = input.serverId.trim(); + const worktreePath = input.worktreePath.trim(); + if (!serverId || !worktreePath) { + return; + } + + const removeFromList = (current: unknown) => { + if (!Array.isArray(current)) { + return current; + } + const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath); + return filtered.length === current.length ? current : filtered; + }; + + queryClient.setQueriesData( + { + predicate: (query) => + Array.isArray(query.queryKey) && + query.queryKey[0] === "paseoWorktreeList" && + query.queryKey[1] === serverId, + }, + removeFromList + ); + + queryClient.setQueriesData( + { + predicate: (query) => + Array.isArray(query.queryKey) && + query.queryKey[0] === "sidebarPaseoWorktreeList" && + query.queryKey[1] === serverId, + }, + removeFromList + ); } const successTimers = new Map>(); @@ -267,6 +310,7 @@ export const useCheckoutGitActionsStore = create() if (payload.error) { throw new Error(payload.error.message); } + removeWorktreeFromCachedLists({ serverId, worktreePath }); invalidateWorktreeList(); }, }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 0a38a41c3..faa08dfe2 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -801,6 +801,15 @@ export class AgentManager { async closeAgent(agentId: string): Promise { const agent = this.requireAgent(agentId); + this.logger.trace( + { + agentId, + lifecycle: agent.lifecycle, + hasPendingRun: Boolean(agent.pendingRun), + pendingPermissions: agent.pendingPermissions.size, + }, + "closeAgent: start" + ); this.agents.delete(agentId); this.liveEventPumps.delete(agentId); this.liveEventBacklog.delete(agentId); @@ -816,6 +825,7 @@ export class AgentManager { }; await session.close(); this.emitState(closedAgent); + this.logger.trace({ agentId }, "closeAgent: completed"); } async setAgentMode(agentId: string, modeId: string): Promise { @@ -1005,7 +1015,24 @@ export class AgentManager { options?: AgentRunOptions ): AsyncGenerator { const existingAgent = this.requireAgent(agentId); + this.logger.trace( + { + agentId, + lifecycle: existingAgent.lifecycle, + hasPendingRun: Boolean(existingAgent.pendingRun), + promptType: typeof prompt === "string" ? "string" : "structured", + hasRunOptions: Boolean(options), + }, + "streamAgent: requested" + ); if (existingAgent.pendingRun) { + this.logger.trace( + { + agentId, + lifecycle: existingAgent.lifecycle, + }, + "streamAgent: rejected because pendingRun already exists" + ); throw new Error(`Agent ${agentId} already has an active run`); } @@ -1015,12 +1042,31 @@ export class AgentManager { let finalized = false; const finalize = (error?: string) => { + this.logger.trace( + { + agentId, + error, + alreadyFinalized: finalized, + lifecycle: agent.lifecycle, + hasPendingRun: Boolean(agent.pendingRun), + }, + "streamAgent.finalize: invoked" + ); if (finalized) { return; } finalized = true; if (agent.pendingRun !== streamForwarder) { + this.logger.trace( + { + agentId, + error, + lifecycle: agent.lifecycle, + hasPendingRun: Boolean(agent.pendingRun), + }, + "streamAgent.finalize: skipped because pendingRun no longer points to streamForwarder" + ); if (error) { agent.lastError = error; } @@ -1043,6 +1089,15 @@ export class AgentManager { mutableAgent.cwd ); } + this.logger.trace( + { + agentId, + lifecycle: mutableAgent.lifecycle, + hasPendingRun: Boolean(mutableAgent.pendingRun), + terminalError, + }, + "streamAgent.finalize: applying terminal state" + ); this.emitState(mutableAgent); this.flushLiveEventBacklog(mutableAgent); }; @@ -1054,6 +1109,22 @@ export class AgentManager { try { for await (const event of iterator) { self.handleStreamEvent(agent, event); + if ( + event.type === "turn_started" || + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + self.logger.trace( + { + agentId, + eventType: event.type, + lifecycle: agent.lifecycle, + hasPendingRun: Boolean(agent.pendingRun), + }, + "streamAgent: forwarded terminal/turn event" + ); + } yield event; } } catch (error) { @@ -1079,6 +1150,14 @@ export class AgentManager { // deterministically order idle->running transitions. this.touchUpdatedAt(agent); self.emitState(agent); + this.logger.trace( + { + agentId, + lifecycle: agent.lifecycle, + hasPendingRun: Boolean(agent.pendingRun), + }, + "streamAgent: started" + ); return streamForwarder; } @@ -1743,6 +1822,14 @@ export class AgentManager { } break; case "turn_completed": + this.logger.trace( + { + agentId: agent.id, + lifecycle: agent.lifecycle, + hasPendingRun: Boolean(agent.pendingRun), + }, + "handleStreamEvent: turn_completed" + ); agent.lastUsage = event.usage; agent.lastError = undefined; if (!agent.pendingRun && agent.lifecycle !== "idle") { @@ -1774,6 +1861,14 @@ export class AgentManager { this.emitState(agent); break; case "turn_canceled": + this.logger.trace( + { + agentId: agent.id, + lifecycle: agent.lifecycle, + hasPendingRun: Boolean(agent.pendingRun), + }, + "handleStreamEvent: turn_canceled" + ); if (!agent.pendingRun) { (agent as ActiveManagedAgent).lifecycle = "idle"; } @@ -1792,6 +1887,14 @@ export class AgentManager { this.emitState(agent); break; case "turn_started": + this.logger.trace( + { + agentId: agent.id, + lifecycle: agent.lifecycle, + hasPendingRun: Boolean(agent.pendingRun), + }, + "handleStreamEvent: turn_started" + ); if (!agent.pendingRun) { (agent as ActiveManagedAgent).lifecycle = "running"; this.emitState(agent); @@ -2143,6 +2246,14 @@ export class AgentManager { // Keep consuming provider events even during an active foreground run, // then replay them immediately once that run settles. if (latest.pendingRun) { + this.logger.trace( + { + agentId: latest.id, + eventType: event.type, + backlogSize: (this.liveEventBacklog.get(latest.id)?.length ?? 0) + 1, + }, + "Live event pump: queued event because pendingRun is active" + ); this.enqueueLiveEvent(latest.id, event); continue; } diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index d26d3ba1d..65745249e 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1906,6 +1906,16 @@ class ClaudeAgentSession implements AgentSession { } async close(): Promise { + this.logger.trace( + { + claudeSessionId: this.claudeSessionId, + turnState: this.turnState, + hasQuery: Boolean(this.query), + hasInput: Boolean(this.input), + hasActiveForegroundTurn: Boolean(this.activeForegroundTurn), + }, + "Claude session close: start" + ); this.closed = true; this.rejectAllPendingPermissions(new Error("Claude session closed")); this.cancelCurrentTurn?.(); @@ -1922,6 +1932,10 @@ class ClaudeAgentSession implements AgentSession { await this.awaitWithTimeout(this.query?.return?.(), "close query return"); this.query = null; this.input = null; + this.logger.trace( + { claudeSessionId: this.claudeSessionId, turnState: this.turnState }, + "Claude session close: completed" + ); } async listCommands(): Promise { @@ -2235,8 +2249,11 @@ class ClaudeAgentSession implements AgentSession { label: string ): Promise { if (!promise) { + this.logger.trace({ label }, "Claude query operation skipped (no promise)"); return; } + const startedAt = Date.now(); + this.logger.trace({ label }, "Claude query operation wait start"); try { await Promise.race([ promise, @@ -2244,6 +2261,10 @@ class ClaudeAgentSession implements AgentSession { setTimeout(() => reject(new Error("timeout")), 3_000); }), ]); + this.logger.trace( + { label, durationMs: Date.now() - startedAt }, + "Claude query operation settled" + ); } catch (error) { this.logger.warn({ err: error, label }, "Claude query operation did not settle cleanly"); } @@ -2464,6 +2485,23 @@ class ClaudeAgentSession implements AgentSession { } private emitRunEvent(run: RunRecord, event: AgentStreamEvent): void { + if ( + event.type === "turn_started" || + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + this.logger.trace( + { + runId: run.id, + owner: run.owner, + runState: run.state, + eventType: event.type, + routedTo: run.owner === "foreground" && run.queue ? "foreground_queue" : "live_queue", + }, + "Claude run event emitted" + ); + } if (run.owner === "foreground" && run.queue) { run.queue.push(event); if ( @@ -2494,6 +2532,16 @@ class ClaudeAgentSession implements AgentSession { this.activeForegroundTurn = null; this.preReplayMetadataSeen = false; } + this.logger.trace( + { + runId: run.id, + owner: run.owner, + eventType: event.type, + runState: run.state, + hasActiveForegroundTurn: Boolean(this.activeForegroundTurn), + }, + "Claude run terminal event handled" + ); this.transitionTurnStateFromActiveRuns(`run ${run.id} terminal`); } @@ -2789,6 +2837,13 @@ class ClaudeAgentSession implements AgentSession { } if (next.done) { + this.logger.trace( + { + claudeSessionId: this.claudeSessionId, + activeRunCount: this.runTracker.listActiveRuns().length, + }, + "Claude query pump next() returned done" + ); this.input?.end(); await this.awaitWithTimeout(q.return?.(), "query pump return on done"); if (this.query === q) { @@ -2834,6 +2889,18 @@ class ClaudeAgentSession implements AgentSession { run: route.run, message, }); + this.logger.trace( + { + claudeSessionId: this.claudeSessionId, + messageType: message.type, + routeReason: route.reason, + runId: route.run?.id ?? null, + runOwner: route.run?.owner ?? null, + suppressTerminalEvents, + metadataOnly, + }, + "Claude query pump routed SDK message" + ); if (route.run) { this.transitionTurnStateFromActiveRuns(`routed via ${route.reason}`); this.runTracker.bindIdentifiers(route.run, identifiers); @@ -2898,6 +2965,16 @@ class ClaudeAgentSession implements AgentSession { // Pre-replay success results are stale in practice (leftover from an // earlier query segment) and must not end the current foreground run. if (resultSubtype === "success") { + this.logger.trace( + { + runId: run.id, + runOwner: run.owner, + runState: run.state, + promptReplaySeen: run.promptReplaySeen, + resultSubtype, + }, + "Suppressing pre-replay foreground success result terminal event" + ); return true; } @@ -2917,6 +2994,7 @@ class ClaudeAgentSession implements AgentSession { message: SDKMessage, identifiers: EventIdentifiers ): void { + const previousState = run.state; if ( message.type === "user" && identifiers.messageId && @@ -2941,8 +3019,25 @@ class ClaudeAgentSession implements AgentSession { if (message.type === "result") { this.runTracker.transition(run, "finalizing"); + } else { return; } + + if (run.state !== previousState) { + this.logger.trace( + { + runId: run.id, + owner: run.owner, + messageType: message.type, + previousState, + nextState: run.state, + taskId: identifiers.taskId, + parentMessageId: identifiers.parentMessageId, + messageId: identifiers.messageId, + }, + "Updated Claude run lifecycle from SDK message" + ); + } } private shouldSuppressLocalReplayActivity(message: SDKMessage): boolean { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 813a9a79e..c18132166 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -736,10 +736,15 @@ export class Session { private async interruptAgentIfRunning(agentId: string): Promise { const snapshot = this.agentManager.getAgent(agentId) if (!snapshot) { + this.sessionLogger.trace({ agentId }, 'interruptAgentIfRunning: agent not found') throw new Error(`Agent ${agentId} not found`) } if (snapshot.lifecycle !== 'running' && !snapshot.pendingRun) { + this.sessionLogger.trace( + { agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) }, + 'interruptAgentIfRunning: skipping because agent is not running' + ) return } @@ -787,9 +792,18 @@ export class Session { prompt: AgentPromptInput, runOptions?: AgentRunOptions ): { ok: true } | { ok: false; error: string } { + this.sessionLogger.trace( + { + agentId, + promptType: typeof prompt === 'string' ? 'string' : 'structured', + hasRunOptions: Boolean(runOptions), + }, + 'startAgentStream: requested' + ) let iterator: AsyncGenerator try { iterator = this.agentManager.streamAgent(agentId, prompt, runOptions) + this.sessionLogger.trace({ agentId }, 'startAgentStream: streamAgent returned iterator') } catch (error) { this.handleAgentRunError(agentId, error, 'Failed to start agent run') const message = @@ -802,7 +816,9 @@ export class Session { for await (const _ of iterator) { // Events are forwarded via the session's AgentManager subscription. } + this.sessionLogger.trace({ agentId }, 'startAgentStream: iterator drained') } catch (error) { + this.sessionLogger.trace({ agentId, err: error }, 'startAgentStream: iterator threw') this.handleAgentRunError(agentId, error, 'Agent stream failed') } })() diff --git a/scripts/dev.sh b/scripts/dev.sh index 1b435a0f4..07137fa00 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -10,8 +10,11 @@ DAEMON_PORT=$(get-port 6767 6768 6769 6770 6771 6772 6773) METRO_PORT=$(get-port 8081 8082 8083 8084 8085 8086 8087) # Use a temporary PASEO_HOME to avoid conflicts between dev instances -# export PASEO_HOME=$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX") -# trap "rm -rf '$PASEO_HOME'" EXIT +if [ -z "${PASEO_HOME}" ]; then + export PASEO_HOME + PASEO_HOME="$(mktemp -d "${TMPDIR:-/tmp}/paseo-dev.XXXXXX")" + trap "rm -rf '$PASEO_HOME'" EXIT +fi # Build CORS origins for this Expo instance CORS_ORIGINS="http://localhost:${METRO_PORT},http://127.0.0.1:${METRO_PORT}" @@ -24,7 +27,7 @@ echo " Paseo Dev" echo "══════════════════════════════════════════════════════" echo " Daemon: http://localhost:${DAEMON_PORT}" echo " Metro: http://localhost:${METRO_PORT}" -# echo " Home: ${PASEO_HOME}" +echo " Home: ${PASEO_HOME}" echo "══════════════════════════════════════════════════════" # Export for child processes (overrides .env values)