From 96b211a2cf70742e48308ccf407f27eb1614a000 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 28 Jan 2026 13:25:48 +0700 Subject: [PATCH] Update files --- packages/app/src/components/agent-list.tsx | 3 +- .../app/src/components/explorer-sidebar.tsx | 2 +- packages/app/src/components/git-diff-pane.tsx | 69 ++--- .../app/src/components/grouped-agent-list.tsx | 4 +- .../src/hooks/checkout-status-revalidation.ts | 6 +- .../app/src/hooks/use-checkout-diff-query.ts | 20 +- .../src/hooks/use-checkout-pr-status-query.ts | 14 +- .../hooks/use-checkout-status-query.test.ts | 8 +- .../src/hooks/use-checkout-status-query.ts | 20 +- .../src/screens/agent/agent-ready-screen.tsx | 1 - .../src/client/daemon-client-v2.test.ts | 17 +- .../server/src/client/daemon-client-v2.ts | 42 ++- .../daemon-e2e/checkout-ship.e2e.test.ts | 36 +-- packages/server/src/server/session.ts | 252 +++++------------- packages/server/src/shared/messages.ts | 36 +-- packages/server/src/utils/checkout-git.ts | 22 ++ 16 files changed, 229 insertions(+), 323 deletions(-) diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 3e0aa33f4..a8738a19e 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -171,7 +171,7 @@ export function AgentList({ void queryClient.prefetchQuery({ queryKey, - queryFn: async () => await client.getCheckoutStatus(agent.id, { cwd: agent.cwd }), + queryFn: async () => await client.getCheckoutStatus(agent.cwd), staleTime: CHECKOUT_STATUS_STALE_TIME, }); } @@ -192,7 +192,6 @@ export function AgentList({ const checkoutQuery = useCheckoutStatusCacheOnly({ serverId: agent.serverId, - agentId: agent.id, cwd: agent.cwd, }); const checkout = checkoutQuery.data ?? null; diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 9b47a7594..0dfe2c044 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -251,7 +251,7 @@ function SidebarContent({ isMobile, }: SidebarContentProps) { const { theme } = useUnistyles(); - const { status } = useCheckoutStatusQuery({ serverId, agentId, cwd }); + const { status } = useCheckoutStatusQuery({ serverId, cwd }); const isGit = status?.isGit ?? false; // If not a git repo, only show files tab diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 90b6378de..328f9c3e9 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -432,7 +432,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const [actionError, setActionError] = useState(null); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } = - useCheckoutStatusQuery({ serverId, agentId, cwd }); + useCheckoutStatusQuery({ serverId, cwd }); const gitStatus = status && status.isGit ? status : null; const isGit = Boolean(gitStatus); const notGit = status !== null && !status.isGit && !status.error; @@ -456,7 +456,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { refresh: refreshDiff, } = useCheckoutDiffQuery({ serverId, - agentId, + cwd, mode: diffMode, baseRef, enabled: isGit, @@ -467,7 +467,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { refresh: refreshPrStatus, } = useCheckoutPrStatusQuery({ serverId, - agentId, + cwd, enabled: isGit, }); // Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches @@ -583,16 +583,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { }); }, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]); - const agentExists = useSessionStore((state) => - state.sessions[serverId]?.agents?.has(agentId) ?? false - ); - const commitMutation = useMutation({ mutationFn: async () => { if (!client) { throw new Error("Daemon client unavailable"); } - const payload = await client.checkoutCommit(agentId, { addAll: true }); + const payload = await client.checkoutCommit(cwd, { addAll: true }); if (payload.error) { throw new Error(payload.error.message); } @@ -614,7 +610,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { if (!client) { throw new Error("Daemon client unavailable"); } - const payload = await client.checkoutPrCreate(agentId, {}); + const payload = await client.checkoutPrCreate(cwd, {}); if (payload.error) { throw new Error(payload.error.message); } @@ -635,7 +631,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { if (!client) { throw new Error("Daemon client unavailable"); } - const payload = await client.checkoutMerge(agentId, { + const payload = await client.checkoutMerge(cwd, { baseRef, strategy: "merge", requireCleanTarget: true, @@ -661,7 +657,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { if (!client) { throw new Error("Daemon client unavailable"); } - const payload = await client.checkoutMergeFromBase(agentId, { + const payload = await client.checkoutMergeFromBase(cwd, { baseRef, requireCleanTarget: true, }); @@ -686,7 +682,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { if (!client) { throw new Error("Daemon client unavailable"); } - const payload = await client.checkoutPush(agentId); + const payload = await client.checkoutPush(cwd); if (payload.error) { throw new Error(payload.error.message); } @@ -753,14 +749,6 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const keyExtractor = useCallback((item: ParsedDiffFile) => item.path, []); - if (!agentExists) { - return ( - - Agent not found - - ); - } - const hasChanges = files.length > 0; const diffErrorMessage = diffPayloadError?.message ?? @@ -770,6 +758,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { gitStatus?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown"); const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; + const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; const baseRefLabel = useMemo(() => { if (!baseRef) return "base"; const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); @@ -855,13 +844,15 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { // ========================================================================== // Rules (in priority order): // 1. Uncommitted changes → "Commit" is primary - // 2. Has PR → "View PR" is primary - // 3. Ahead of base → "Merge branch" or "Create PR" based on shipDefault preference - // 4. Nothing to do → no primary CTA + // 2. Ahead of origin (unpushed commits) → "Push" is primary + // 3. Has PR → "View PR" is primary + // 4. Ahead of base → "Merge branch" or "Create PR" based on shipDefault preference + // 5. Nothing to do → no primary CTA // ========================================================================== type PrimaryCTA = | { type: "commit" } + | { type: "push" } | { type: "view-pr"; url: string } | { type: "ship"; action: "merge" | "create-pr" } | null; @@ -874,12 +865,17 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { return { type: "commit" }; } - // Rule 2: Has PR → View PR + // Rule 2: Ahead of origin → Push + if (aheadOfOrigin > 0 && !pushDisabled) { + return { type: "push" }; + } + + // Rule 3: Has PR → View PR if (hasPullRequest && prStatus?.url) { return { type: "view-pr", url: prStatus.url }; } - // Rule 3: Ahead of base → Ship (merge or create PR based on preference) + // Rule 4: Ahead of base → Ship (merge or create PR based on preference) if (aheadCount > 0) { const preferredAction = shipDefault === "merge" ? "merge" : "create-pr"; // If preferred action is disabled, fall back to the other @@ -892,15 +888,17 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { return { type: "ship", action: preferredAction }; } - // Rule 4: Nothing to do + // Rule 5: Nothing to do return null; - }, [isGit, hasUncommittedChanges, hasPullRequest, prStatus?.url, aheadCount, shipDefault, mergeDisabled, prDisabled]); + }, [isGit, hasUncommittedChanges, aheadOfOrigin, pushDisabled, hasPullRequest, prStatus?.url, aheadCount, shipDefault, mergeDisabled, prDisabled]); const primaryCTALabel = useMemo(() => { if (!primaryCTA) return ""; switch (primaryCTA.type) { case "commit": return "Commit"; + case "push": + return "Push"; case "view-pr": return "View PR"; case "ship": @@ -913,24 +911,28 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { switch (primaryCTA.type) { case "commit": return commitDisabled; + case "push": + return pushDisabled; case "view-pr": return false; // View PR is never disabled case "ship": return primaryCTA.action === "merge" ? mergeDisabled : prDisabled; } - }, [primaryCTA, actionsDisabled, commitDisabled, mergeDisabled, prDisabled]); + }, [primaryCTA, actionsDisabled, commitDisabled, pushDisabled, mergeDisabled, prDisabled]); const primaryCTAStatus: ActionStatus = useMemo(() => { if (!primaryCTA) return "idle"; switch (primaryCTA.type) { case "commit": return commitAction.status; + case "push": + return pushAction.status; case "view-pr": return "idle"; // View PR is instant, no status case "ship": return primaryCTA.action === "merge" ? mergeAction.status : prCreateAction.status; } - }, [primaryCTA, commitAction.status, mergeAction.status, prCreateAction.status]); + }, [primaryCTA, commitAction.status, pushAction.status, mergeAction.status, prCreateAction.status]); const primaryCTADisplayLabel = useMemo(() => { if (!primaryCTA) return ""; @@ -941,6 +943,10 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { if (status === "pending") return "Committing..."; if (status === "success") return "Committed"; return "Commit"; + case "push": + if (status === "pending") return "Pushing..."; + if (status === "success") return "Pushed"; + return "Push"; case "view-pr": return "View PR"; case "ship": @@ -962,6 +968,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { case "commit": commitAction.trigger(); break; + case "push": + pushAction.trigger(); + break; case "view-pr": openURLInNewTab(primaryCTA.url); break; @@ -973,7 +982,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { } break; } - }, [primaryCTA, primaryCTADisabled, primaryCTAStatus, commitAction, mergeAction, prCreateAction]); + }, [primaryCTA, primaryCTADisabled, primaryCTAStatus, commitAction, pushAction, mergeAction, prCreateAction]); return ( diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx index 243191ade..eb4e1a977 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -93,7 +93,6 @@ function SectionHeader({ // For project sections, try to get repo name from checkout status const checkoutQuery = useCheckoutStatusCacheOnly({ serverId: section.firstAgentServerId ?? "", - agentId: section.firstAgentId ?? "", cwd: section.workingDir ?? "", }); const checkout = checkoutQuery.data ?? null; @@ -299,7 +298,7 @@ export function GroupedAgentList({ void queryClient.prefetchQuery({ queryKey, - queryFn: async () => await client.getCheckoutStatus(agent.id, { cwd: agent.cwd }), + queryFn: async () => await client.getCheckoutStatus(agent.cwd), staleTime: CHECKOUT_STATUS_STALE_TIME, }); } @@ -409,7 +408,6 @@ export function GroupedAgentList({ const checkoutQuery = useCheckoutStatusCacheOnly({ serverId: agent.serverId, - agentId: agent.id, cwd: agent.cwd, }); const checkout = checkoutQuery.data ?? null; diff --git a/packages/app/src/hooks/checkout-status-revalidation.ts b/packages/app/src/hooks/checkout-status-revalidation.ts index 02f379c67..e2d2e1715 100644 --- a/packages/app/src/hooks/checkout-status-revalidation.ts +++ b/packages/app/src/hooks/checkout-status-revalidation.ts @@ -1,15 +1,15 @@ export type CheckoutStatusRevalidationParams = { serverId: string; - agentId: string; + cwd: string; isOpen: boolean; explorerTab: string; }; export function checkoutStatusRevalidationKey(params: CheckoutStatusRevalidationParams): string | null { - if (!params.agentId) return null; + if (!params.cwd) return null; if (!params.isOpen) return null; if (params.explorerTab !== "changes") return null; - return `${params.serverId}:${params.agentId}`; + return `${params.serverId}:${params.cwd}`; } export function nextCheckoutStatusRefetchDecision( diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index 704dc38fc..33c5476b5 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -9,16 +9,16 @@ const CHECKOUT_DIFF_STALE_TIME = 30_000; function checkoutDiffQueryKey( serverId: string, - agentId: string, + cwd: string, mode: "uncommitted" | "base", baseRef?: string ) { - return ["checkoutDiff", serverId, agentId, mode, baseRef ?? ""] as const; + return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const; } interface UseCheckoutDiffQueryOptions { serverId: string; - agentId: string; + cwd: string; mode: "uncommitted" | "base"; baseRef?: string; enabled?: boolean; @@ -31,7 +31,7 @@ export type HighlightToken = NonNullable[number]; export function useCheckoutDiffQuery({ serverId, - agentId, + cwd, mode, baseRef, enabled = true, @@ -51,27 +51,27 @@ export function useCheckoutDiffQuery({ const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const query = useQuery({ - queryKey: checkoutDiffQueryKey(serverId, agentId, mode, baseRef), + queryKey: checkoutDiffQueryKey(serverId, cwd, mode, baseRef), queryFn: async () => { if (!client) { throw new Error("Daemon client not available"); } - return await client.getCheckoutDiff(agentId, { mode, baseRef }); + return await client.getCheckoutDiff(cwd, { mode, baseRef }); }, - enabled: !!client && isConnected && !!agentId && enabled, + enabled: !!client && isConnected && !!cwd && enabled, staleTime: CHECKOUT_DIFF_STALE_TIME, refetchInterval: 10_000, }); // Revalidate when sidebar opens with "changes" tab active useEffect(() => { - if (!isOpen || explorerTab !== "changes" || !agentId) { + if (!isOpen || explorerTab !== "changes" || !cwd) { return; } queryClient.invalidateQueries({ - queryKey: checkoutDiffQueryKey(serverId, agentId, mode, baseRef), + queryKey: checkoutDiffQueryKey(serverId, cwd, mode, baseRef), }); - }, [isOpen, explorerTab, serverId, agentId, mode, baseRef, queryClient]); + }, [isOpen, explorerTab, serverId, cwd, mode, baseRef, queryClient]); const refresh = useCallback(() => { return query.refetch(); diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts index a2d27a5a5..254219a46 100644 --- a/packages/app/src/hooks/use-checkout-pr-status-query.ts +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -4,13 +4,13 @@ import type { CheckoutPrStatusResponse } from "@server/shared/messages"; const CHECKOUT_PR_STATUS_STALE_TIME = 20_000; -function checkoutPrStatusQueryKey(serverId: string, agentId: string) { - return ["checkoutPrStatus", serverId, agentId] as const; +function checkoutPrStatusQueryKey(serverId: string, cwd: string) { + return ["checkoutPrStatus", serverId, cwd] as const; } interface UseCheckoutPrStatusQueryOptions { serverId: string; - agentId: string; + cwd: string; enabled?: boolean; } @@ -18,7 +18,7 @@ export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; export function useCheckoutPrStatusQuery({ serverId, - agentId, + cwd, enabled = true, }: UseCheckoutPrStatusQueryOptions) { const client = useSessionStore( @@ -29,14 +29,14 @@ export function useCheckoutPrStatusQuery({ ); const query = useQuery({ - queryKey: checkoutPrStatusQueryKey(serverId, agentId), + queryKey: checkoutPrStatusQueryKey(serverId, cwd), queryFn: async () => { if (!client) { throw new Error("Daemon client not available"); } - return await client.checkoutPrStatus(agentId); + return await client.checkoutPrStatus(cwd); }, - enabled: !!client && isConnected && !!agentId && enabled, + enabled: !!client && isConnected && !!cwd && enabled, staleTime: CHECKOUT_PR_STATUS_STALE_TIME, refetchInterval: 15_000, }); diff --git a/packages/app/src/hooks/use-checkout-status-query.test.ts b/packages/app/src/hooks/use-checkout-status-query.test.ts index 50044202d..d55615c48 100644 --- a/packages/app/src/hooks/use-checkout-status-query.test.ts +++ b/packages/app/src/hooks/use-checkout-status-query.test.ts @@ -7,7 +7,7 @@ describe("useCheckoutStatusQuery", () => { expect( checkoutStatusRevalidationKey({ serverId: "daemon-1", - agentId: "agent-1", + cwd: "/path/to/project", isOpen: false, explorerTab: "changes", }) @@ -18,7 +18,7 @@ describe("useCheckoutStatusQuery", () => { expect( checkoutStatusRevalidationKey({ serverId: "daemon-1", - agentId: "agent-1", + cwd: "/path/to/project", isOpen: true, explorerTab: "files", }) @@ -29,11 +29,11 @@ describe("useCheckoutStatusQuery", () => { expect( checkoutStatusRevalidationKey({ serverId: "daemon-1", - agentId: "agent-1", + cwd: "/path/to/project", isOpen: true, explorerTab: "changes", }) - ).toBe("daemon-1:agent-1"); + ).toBe("daemon-1:/path/to/project"); }); }); diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts index 0cfc82185..f14351e58 100644 --- a/packages/app/src/hooks/use-checkout-status-query.ts +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -17,21 +17,19 @@ export function checkoutStatusQueryKey(serverId: string, cwd: string) { interface UseCheckoutStatusQueryOptions { serverId: string; - agentId: string; cwd: string; } export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; function fetchCheckoutStatus( - client: { getCheckoutStatus: (agentId: string, options?: { cwd?: string }) => Promise }, - agentId: string, + client: { getCheckoutStatus: (cwd: string) => Promise }, cwd: string ): Promise { - return client.getCheckoutStatus(agentId, { cwd }); + return client.getCheckoutStatus(cwd); } -export function useCheckoutStatusQuery({ serverId, agentId, cwd }: UseCheckoutStatusQueryOptions) { +export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null ); @@ -51,9 +49,9 @@ export function useCheckoutStatusQuery({ serverId, agentId, cwd }: UseCheckoutSt if (!client) { throw new Error("Daemon client not available"); } - return await fetchCheckoutStatus(client, agentId, cwd); + return await fetchCheckoutStatus(client, cwd); }, - enabled: !!client && isConnected && !!agentId && !!cwd, + enabled: !!client && isConnected && !!cwd, staleTime: CHECKOUT_STATUS_STALE_TIME, refetchInterval: 10_000, refetchIntervalInBackground: true, @@ -62,8 +60,8 @@ export function useCheckoutStatusQuery({ serverId, agentId, cwd }: UseCheckoutSt // Revalidate when sidebar is open with "changes" tab active. const revalidationKey = useMemo( - () => checkoutStatusRevalidationKey({ serverId, agentId, isOpen, explorerTab }), - [serverId, agentId, isOpen, explorerTab] + () => checkoutStatusRevalidationKey({ serverId, cwd, isOpen, explorerTab }), + [serverId, cwd, isOpen, explorerTab] ); const lastRevalidationKey = useRef(null); useEffect(() => { @@ -88,7 +86,7 @@ export function useCheckoutStatusQuery({ serverId, agentId, cwd }: UseCheckoutSt * initiating a fetch. Useful for list rows where a parent component prefetches * only the visible agents. */ -export function useCheckoutStatusCacheOnly({ serverId, agentId, cwd }: UseCheckoutStatusQueryOptions) { +export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) { const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null ); @@ -99,7 +97,7 @@ export function useCheckoutStatusCacheOnly({ serverId, agentId, cwd }: UseChecko if (!client) { throw new Error("Daemon client not available"); } - return await fetchCheckoutStatus(client, agentId, cwd); + return await fetchCheckoutStatus(client, cwd); }, enabled: false, staleTime: CHECKOUT_STATUS_STALE_TIME, diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index c5a7f260b..2ebdcc4e9 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -414,7 +414,6 @@ function AgentScreenContent({ // Checkout status for header subtitle const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, - agentId: resolvedAgentId ?? "", cwd: agent?.cwd ?? "", }); const checkout = checkoutStatusQuery.status; diff --git a/packages/server/src/client/daemon-client-v2.test.ts b/packages/server/src/client/daemon-client-v2.test.ts index 088a529ce..30ddbe8b5 100644 --- a/packages/server/src/client/daemon-client-v2.test.ts +++ b/packages/server/src/client/daemon-client-v2.test.ts @@ -75,14 +75,14 @@ describe("DaemonClientV2", () => { mock.triggerOpen(); await connectPromise; - const p1 = client.getCheckoutStatus("agent-1"); - const p2 = client.getCheckoutStatus("agent-1"); + const p1 = client.getCheckoutStatus("/tmp/project"); + const p2 = client.getCheckoutStatus("/tmp/project"); expect(mock.sent).toHaveLength(1); const request = JSON.parse(mock.sent[0]) as { type: "session"; - message: { type: "checkout_status_request"; agentId: string; requestId: string }; + message: { type: "checkout_status_request"; cwd: string; requestId: string }; }; const response = { @@ -90,8 +90,7 @@ describe("DaemonClientV2", () => { message: { type: "checkout_status_response", payload: { - agentId: "agent-1", - cwd: "/tmp", + cwd: "/tmp/project", error: null, requestId: request.message.requestId, isGit: false, @@ -109,16 +108,16 @@ describe("DaemonClientV2", () => { mock.triggerMessage(JSON.stringify(response)); const [r1, r2] = await Promise.all([p1, p2]); - expect(r1).toMatchObject({ agentId: "agent-1", requestId: request.message.requestId, isGit: false }); - expect(r2).toMatchObject({ agentId: "agent-1", requestId: request.message.requestId, isGit: false }); + expect(r1).toMatchObject({ cwd: "/tmp/project", requestId: request.message.requestId, isGit: false }); + expect(r2).toMatchObject({ cwd: "/tmp/project", requestId: request.message.requestId, isGit: false }); // After completion, a new call should issue a new request. - const p3 = client.getCheckoutStatus("agent-1"); + const p3 = client.getCheckoutStatus("/tmp/project"); expect(mock.sent).toHaveLength(2); const request2 = JSON.parse(mock.sent[1]) as { type: "session"; - message: { type: "checkout_status_request"; agentId: string; requestId: string }; + message: { type: "checkout_status_request"; cwd: string; requestId: string }; }; mock.triggerMessage( diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 8420c51fe..045cf0e97 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -1130,14 +1130,13 @@ export class DaemonClientV2 { // ============================================================================ async getCheckoutStatus( - agentId: string, - options?: { cwd?: string; requestId?: string } + cwd: string, + options?: { requestId?: string } ): Promise { const requestId = options?.requestId; - const cwd = options?.cwd; if (!requestId) { - const existing = this.checkoutStatusInFlight.get(agentId); + const existing = this.checkoutStatusInFlight.get(cwd); if (existing) { return existing; } @@ -1146,7 +1145,6 @@ export class DaemonClientV2 { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_status_request", - agentId, cwd, requestId: resolvedRequestId, }); @@ -1170,10 +1168,10 @@ export class DaemonClientV2 { })(); if (!requestId) { - this.checkoutStatusInFlight.set(agentId, responsePromise); + this.checkoutStatusInFlight.set(cwd, responsePromise); responsePromise.finally(() => { - if (this.checkoutStatusInFlight.get(agentId) === responsePromise) { - this.checkoutStatusInFlight.delete(agentId); + if (this.checkoutStatusInFlight.get(cwd) === responsePromise) { + this.checkoutStatusInFlight.delete(cwd); } }); } @@ -1182,14 +1180,14 @@ export class DaemonClientV2 { } async getCheckoutDiff( - agentId: string, + cwd: string, compare: { mode: "uncommitted" | "base"; baseRef?: string }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_diff_request", - agentId, + cwd, compare, requestId: resolvedRequestId, }); @@ -1211,14 +1209,14 @@ export class DaemonClientV2 { } async checkoutCommit( - agentId: string, + cwd: string, input: { message?: string; addAll?: boolean }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_commit_request", - agentId, + cwd, message: input.message, addAll: input.addAll, requestId: resolvedRequestId, @@ -1241,14 +1239,14 @@ export class DaemonClientV2 { } async checkoutMerge( - agentId: string, + cwd: string, input: { baseRef?: string; strategy?: "merge" | "squash"; requireCleanTarget?: boolean }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_merge_request", - agentId, + cwd, baseRef: input.baseRef, strategy: input.strategy, requireCleanTarget: input.requireCleanTarget, @@ -1272,14 +1270,14 @@ export class DaemonClientV2 { } async checkoutMergeFromBase( - agentId: string, + cwd: string, input: { baseRef?: string; requireCleanTarget?: boolean }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_merge_from_base_request", - agentId, + cwd, baseRef: input.baseRef, requireCleanTarget: input.requireCleanTarget, requestId: resolvedRequestId, @@ -1301,11 +1299,11 @@ export class DaemonClientV2 { return response; } - async checkoutPush(agentId: string, requestId?: string): Promise { + async checkoutPush(cwd: string, requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_push_request", - agentId, + cwd, requestId: resolvedRequestId, }); const response = this.waitFor( @@ -1326,14 +1324,14 @@ export class DaemonClientV2 { } async checkoutPrCreate( - agentId: string, + cwd: string, input: { title?: string; body?: string; baseRef?: string }, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_pr_create_request", - agentId, + cwd, title: input.title, body: input.body, baseRef: input.baseRef, @@ -1357,13 +1355,13 @@ export class DaemonClientV2 { } async checkoutPrStatus( - agentId: string, + cwd: string, requestId?: string ): Promise { const resolvedRequestId = this.createRequestId(requestId); const message = SessionInboundMessageSchema.parse({ type: "checkout_pr_status_request", - agentId, + cwd, requestId: resolvedRequestId, }); const response = this.waitFor( diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts index 355997193..ab8e25c51 100644 --- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -191,7 +191,7 @@ describe("daemon checkout ship loop", () => { }); agentId = agent.id; - const status = await ctx.client.getCheckoutStatus(agent.id); + const status = await ctx.client.getCheckoutStatus(worktree.worktreePath); expect(status.isGit).toBe(true); expect(status.isPaseoOwnedWorktree).toBe(true); expect(status.repoRoot).toContain(repoDir); @@ -207,13 +207,13 @@ describe("daemon checkout ship loop", () => { const renamePayload = getStructuredContent(renameResult); expect(renamePayload?.success).toBe(true); - const updatedStatus = await ctx.client.getCheckoutStatus(agent.id); + const updatedStatus = await ctx.client.getCheckoutStatus(worktree.worktreePath); expect(updatedStatus.currentBranch).toBe("ship-loop-ready"); const readmePath = path.join(worktree.worktreePath, "README.md"); writeFileSync(readmePath, "init\nship loop update\n"); - const diffUncommitted = await ctx.client.getCheckoutDiff(agent.id, { + const diffUncommitted = await ctx.client.getCheckoutDiff(worktree.worktreePath, { mode: "uncommitted", }); expect(diffUncommitted.error).toBeNull(); @@ -221,7 +221,7 @@ describe("daemon checkout ship loop", () => { const timelineBeforeCommit = ctx.daemon.daemon.agentManager.getTimeline(agent.id).length; - const commitResult = await ctx.client.checkoutCommit(agent.id, { + const commitResult = await ctx.client.checkoutCommit(worktree.worktreePath, { addAll: true, }); expect(commitResult.error).toBeNull(); @@ -230,12 +230,12 @@ describe("daemon checkout ship loop", () => { ctx.daemon.daemon.agentManager.getTimeline(agent.id).length; expect(timelineAfterCommit).toBe(timelineBeforeCommit); - const diffAfterCommit = await ctx.client.getCheckoutDiff(agent.id, { + const diffAfterCommit = await ctx.client.getCheckoutDiff(worktree.worktreePath, { mode: "uncommitted", }); expect(diffAfterCommit.files.length).toBe(0); - const baseDiff = await ctx.client.getCheckoutDiff(agent.id, { + const baseDiff = await ctx.client.getCheckoutDiff(worktree.worktreePath, { mode: "base", baseRef: "main", }); @@ -243,7 +243,7 @@ describe("daemon checkout ship loop", () => { const timelineBeforePr = ctx.daemon.daemon.agentManager.getTimeline(agent.id).length; - const prCreate = await ctx.client.checkoutPrCreate(agent.id, { + const prCreate = await ctx.client.checkoutPrCreate(worktree.worktreePath, { baseRef: "main", }); expect(prCreate.error).toBeNull(); @@ -252,12 +252,12 @@ describe("daemon checkout ship loop", () => { ctx.daemon.daemon.agentManager.getTimeline(agent.id).length; expect(timelineAfterPr).toBe(timelineBeforePr); - const prStatus = await ctx.client.checkoutPrStatus(agent.id); + const prStatus = await ctx.client.checkoutPrStatus(worktree.worktreePath); expect(prStatus.error).toBeNull(); expect(prStatus.status?.url).toContain(repoName); expect(prStatus.status?.state).toBeTruthy(); - const mergeResult = await ctx.client.checkoutMerge(agent.id, { + const mergeResult = await ctx.client.checkoutMerge(worktree.worktreePath, { baseRef: "main", strategy: "merge", requireCleanTarget: true, @@ -265,14 +265,14 @@ describe("daemon checkout ship loop", () => { expect(mergeResult.error).toBeNull(); expect(mergeResult.success).toBe(true); - const statusAfterMerge = await ctx.client.getCheckoutStatus(agent.id); + const statusAfterMerge = await ctx.client.getCheckoutStatus(worktree.worktreePath); expect(statusAfterMerge.isGit).toBe(true); if (statusAfterMerge.isGit) { expect(statusAfterMerge.baseRef).toBe("main"); expect(statusAfterMerge.aheadBehind?.ahead ?? 0).toBe(0); } - const baseDiffAfterMerge = await ctx.client.getCheckoutDiff(agent.id, { + const baseDiffAfterMerge = await ctx.client.getCheckoutDiff(worktree.worktreePath, { mode: "base", baseRef: "main", }); @@ -353,7 +353,7 @@ describe("daemon checkout ship loop", () => { }); agentId = agent.id; - const status = await ctx.client.getCheckoutStatus(agent.id); + const status = await ctx.client.getCheckoutStatus(worktree.worktreePath); expect(status.isGit).toBe(true); if (status.isGit) { expect(status.hasRemote).toBe(true); @@ -374,14 +374,14 @@ describe("daemon checkout ship loop", () => { // Add a commit on the agent branch. writeFileSync(path.join(worktree.worktreePath, "feature.txt"), "feature\n"); - const commitResult = await ctx.client.checkoutCommit(agent.id, { + const commitResult = await ctx.client.checkoutCommit(worktree.worktreePath, { message: "feature commit", addAll: true, }); expect(commitResult.error).toBeNull(); expect(commitResult.success).toBe(true); - const mergeFromBase = await ctx.client.checkoutMergeFromBase(agent.id, { + const mergeFromBase = await ctx.client.checkoutMergeFromBase(worktree.worktreePath, { baseRef: "main", requireCleanTarget: true, }); @@ -394,7 +394,7 @@ describe("daemon checkout ship loop", () => { stdio: "pipe", }); - const pushResult = await ctx.client.checkoutPush(agent.id); + const pushResult = await ctx.client.checkoutPush(worktree.worktreePath); expect(pushResult.error).toBeNull(); expect(pushResult.success).toBe(true); } finally { @@ -423,15 +423,15 @@ describe("daemon checkout ship loop", () => { }); agentId = agent.id; - const status = await ctx.client.getCheckoutStatus(agent.id); + const status = await ctx.client.getCheckoutStatus(worktree.worktreePath); expect(status.isGit).toBe(false); - const diff = await ctx.client.getCheckoutDiff(agent.id, { + const diff = await ctx.client.getCheckoutDiff(worktree.worktreePath, { mode: "uncommitted", }); expect(diff.error?.code).toBe("NOT_GIT_REPO"); - const commit = await ctx.client.checkoutCommit(agent.id, { + const commit = await ctx.client.checkoutCommit(worktree.worktreePath, { message: "Should fail", addAll: true, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 998abbe5b..cee3baf7d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4,10 +4,10 @@ import { exec } from "child_process"; import { promisify, inspect } from "util"; import { join, resolve, sep } from "path"; import invariant from "tiny-invariant"; +import { z } from "zod"; import { streamText, stepCountIs } from "ai"; import type { ToolSet } from "ai"; import type { ModelMessage } from "@ai-sdk/provider-utils"; -import { z } from "zod"; import { createOpenRouter, OpenRouterProviderOptions, @@ -121,6 +121,12 @@ let restartRequested = false; const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]; const RESTART_EXIT_DELAY_MS = 250; +/** + * Default model used for auto-generating commit messages and PR descriptions. + * Uses Claude Haiku for speed and cost efficiency. + */ +const AUTO_GEN_MODEL = "haiku"; + type ProcessingPhase = "idle" | "transcribing" | "llm"; type NormalizedGitOptions = { @@ -2357,18 +2363,8 @@ export class Session { return resolvedCandidate.startsWith(resolvedRoot + sep); } - private buildEphemeralAgentConfig(agent: ManagedAgent, title: string): AgentSessionConfig { - return { - ...agent.config, - cwd: agent.cwd, - title, - parentAgentId: agent.id, - internal: true, - }; - } - - private async generateCommitMessage(agent: ManagedAgent): Promise { - const diff = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome }); + private async generateCommitMessage(cwd: string): Promise { + const diff = await getCheckoutDiff(cwd, { mode: "uncommitted" }, { paseoHome: this.paseoHome }); const schema = z.object({ message: z .string() @@ -2385,7 +2381,13 @@ export class Session { try { const result = await generateStructuredAgentResponse({ manager: this.agentManager, - agentConfig: this.buildEphemeralAgentConfig(agent, "Commit generator"), + agentConfig: { + provider: "claude", + model: AUTO_GEN_MODEL, + cwd, + title: "Commit generator", + internal: true, + }, prompt, schema, schemaName: "CommitMessage", @@ -2400,12 +2402,12 @@ export class Session { } } - private async generatePullRequestText(agent: ManagedAgent, baseRef?: string): Promise<{ + private async generatePullRequestText(cwd: string, baseRef?: string): Promise<{ title: string; body: string; }> { const diff = await getCheckoutDiff( - agent.cwd, + cwd, { mode: "base", baseRef, @@ -2425,7 +2427,13 @@ export class Session { try { return await generateStructuredAgentResponse({ manager: this.agentManager, - agentConfig: this.buildEphemeralAgentConfig(agent, "PR generator"), + agentConfig: { + provider: "claude", + model: AUTO_GEN_MODEL, + cwd, + title: "PR generator", + internal: true, + }, prompt, schema, schemaName: "PullRequest", @@ -2853,31 +2861,7 @@ export class Session { private async handleCheckoutStatusRequest( msg: Extract ): Promise { - const { agentId, requestId } = msg; - const agent = this.agentManager.getAgent(agentId); - // Use cwd from agent if found, otherwise fall back to cwd from message - const cwd = agent?.cwd ?? msg.cwd; - if (!cwd) { - this.emit({ - type: "checkout_status_response", - payload: { - agentId, - cwd: "", - isGit: false, - repoRoot: null, - currentBranch: null, - isDirty: null, - baseRef: null, - aheadBehind: null, - hasRemote: false, - remoteUrl: null, - isPaseoOwnedWorktree: false, - error: { code: "UNKNOWN", message: `Agent not found and no cwd provided: ${agentId}` }, - requestId, - }, - }); - return; - } + const { cwd, requestId } = msg; try { const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome }); @@ -2885,7 +2869,6 @@ export class Session { this.emit({ type: "checkout_status_response", payload: { - agentId, cwd, isGit: false, repoRoot: null, @@ -2893,6 +2876,7 @@ export class Session { isDirty: null, baseRef: null, aheadBehind: null, + aheadOfOrigin: null, hasRemote: false, remoteUrl: null, isPaseoOwnedWorktree: false, @@ -2907,7 +2891,6 @@ export class Session { this.emit({ type: "checkout_status_response", payload: { - agentId, cwd, isGit: true, repoRoot: status.repoRoot ?? null, @@ -2915,6 +2898,7 @@ export class Session { isDirty: status.isDirty ?? null, baseRef: status.baseRef, aheadBehind: status.aheadBehind ?? null, + aheadOfOrigin: status.aheadOfOrigin ?? null, hasRemote: status.hasRemote, remoteUrl: status.remoteUrl, isPaseoOwnedWorktree: true, @@ -2928,7 +2912,6 @@ export class Session { this.emit({ type: "checkout_status_response", payload: { - agentId, cwd, isGit: true, repoRoot: status.repoRoot ?? null, @@ -2936,6 +2919,7 @@ export class Session { isDirty: status.isDirty ?? null, baseRef: status.baseRef ?? null, aheadBehind: status.aheadBehind ?? null, + aheadOfOrigin: status.aheadOfOrigin ?? null, hasRemote: status.hasRemote, remoteUrl: status.remoteUrl, isPaseoOwnedWorktree: false, @@ -2947,7 +2931,6 @@ export class Session { this.emit({ type: "checkout_status_response", payload: { - agentId, cwd, isGit: false, repoRoot: null, @@ -2955,6 +2938,7 @@ export class Session { isDirty: null, baseRef: null, aheadBehind: null, + aheadOfOrigin: null, hasRemote: false, remoteUrl: null, isPaseoOwnedWorktree: false, @@ -2968,24 +2952,11 @@ export class Session { private async handleCheckoutDiffRequest( msg: Extract ): Promise { - const { agentId, requestId, compare } = msg; - const agent = this.agentManager.getAgent(agentId); - if (!agent) { - this.emit({ - type: "checkout_diff_response", - payload: { - agentId, - files: [], - error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, - requestId, - }, - }); - return; - } + const { cwd, requestId, compare } = msg; try { const diffResult = await getCheckoutDiff( - agent.cwd, + cwd, { mode: compare.mode, baseRef: compare.baseRef, @@ -2996,7 +2967,7 @@ export class Session { this.emit({ type: "checkout_diff_response", payload: { - agentId, + cwd, files: diffResult.structured ?? [], error: null, requestId, @@ -3006,7 +2977,7 @@ export class Session { this.emit({ type: "checkout_diff_response", payload: { - agentId, + cwd, files: [], error: this.toCheckoutError(error), requestId, @@ -3018,31 +2989,18 @@ export class Session { private async handleCheckoutCommitRequest( msg: Extract ): Promise { - const { agentId, requestId } = msg; - const agent = this.agentManager.getAgent(agentId); - if (!agent) { - this.emit({ - type: "checkout_commit_response", - payload: { - agentId, - success: false, - error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, - requestId, - }, - }); - return; - } + const { cwd, requestId } = msg; try { let message = msg.message?.trim() ?? ""; if (!message) { - message = await this.generateCommitMessage(agent); + message = await this.generateCommitMessage(cwd); } if (!message) { throw new Error("Commit message is required"); } - await commitChanges(agent.cwd, { + await commitChanges(cwd, { message, addAll: msg.addAll ?? true, }); @@ -3050,7 +3008,7 @@ export class Session { this.emit({ type: "checkout_commit_response", payload: { - agentId, + cwd, success: true, error: null, requestId, @@ -3060,7 +3018,7 @@ export class Session { this.emit({ type: "checkout_commit_response", payload: { - agentId, + cwd, success: false, error: this.toCheckoutError(error), requestId, @@ -3072,29 +3030,14 @@ export class Session { private async handleCheckoutMergeRequest( msg: Extract ): Promise { - const { agentId, requestId } = msg; - const agent = this.agentManager.getAgent(agentId); - if (!agent) { - this.emit({ - type: "checkout_merge_response", - payload: { - agentId, - success: false, - error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, - requestId, - }, - }); - return; - } + const { cwd, requestId } = msg; try { - const status = await getCheckoutStatus(agent.cwd, { paseoHome: this.paseoHome }); + const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome }); if (!status.isGit) { - // `getCheckoutStatus` can return `isGit=false` in transient situations (e.g. cwd lookup - // running during other git operations). Double-check with git directly before failing. try { await execAsync("git rev-parse --is-inside-work-tree", { - cwd: agent.cwd, + cwd, env: READ_ONLY_GIT_ENV, }); } catch (error) { @@ -3104,13 +3047,13 @@ export class Session { : error instanceof Error ? error.message : String(error); - throw new Error(`Not a git repository: ${agent.cwd}\n${details}`.trim()); + throw new Error(`Not a git repository: ${cwd}\n${details}`.trim()); } } if (msg.requireCleanTarget) { const { stdout } = await execAsync("git status --porcelain", { - cwd: agent.cwd, + cwd, env: READ_ONLY_GIT_ENV, }); if (stdout.trim().length > 0) { @@ -3127,7 +3070,7 @@ export class Session { } await mergeToBase( - agent.cwd, + cwd, { baseRef, mode: msg.strategy === "squash" ? "squash" : "merge", @@ -3138,7 +3081,7 @@ export class Session { this.emit({ type: "checkout_merge_response", payload: { - agentId, + cwd, success: true, error: null, requestId, @@ -3148,7 +3091,7 @@ export class Session { this.emit({ type: "checkout_merge_response", payload: { - agentId, + cwd, success: false, error: this.toCheckoutError(error), requestId, @@ -3160,25 +3103,12 @@ export class Session { private async handleCheckoutMergeFromBaseRequest( msg: Extract ): Promise { - const { agentId, requestId } = msg; - const agent = this.agentManager.getAgent(agentId); - if (!agent) { - this.emit({ - type: "checkout_merge_from_base_response", - payload: { - agentId, - success: false, - error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, - requestId, - }, - }); - return; - } + const { cwd, requestId } = msg; try { if (msg.requireCleanTarget ?? true) { const { stdout } = await execAsync("git status --porcelain", { - cwd: agent.cwd, + cwd, env: READ_ONLY_GIT_ENV, }); if (stdout.trim().length > 0) { @@ -3187,7 +3117,7 @@ export class Session { } await mergeFromBase( - agent.cwd, + cwd, { baseRef: msg.baseRef, requireCleanTarget: msg.requireCleanTarget ?? true, @@ -3197,7 +3127,7 @@ export class Session { this.emit({ type: "checkout_merge_from_base_response", payload: { - agentId, + cwd, success: true, error: null, requestId, @@ -3207,7 +3137,7 @@ export class Session { this.emit({ type: "checkout_merge_from_base_response", payload: { - agentId, + cwd, success: false, error: this.toCheckoutError(error), requestId, @@ -3219,27 +3149,14 @@ export class Session { private async handleCheckoutPushRequest( msg: Extract ): Promise { - const { agentId, requestId } = msg; - const agent = this.agentManager.getAgent(agentId); - if (!agent) { - this.emit({ - type: "checkout_push_response", - payload: { - agentId, - success: false, - error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, - requestId, - }, - }); - return; - } + const { cwd, requestId } = msg; try { - await pushCurrentBranch(agent.cwd); + await pushCurrentBranch(cwd); this.emit({ type: "checkout_push_response", payload: { - agentId, + cwd, success: true, error: null, requestId, @@ -3249,7 +3166,7 @@ export class Session { this.emit({ type: "checkout_push_response", payload: { - agentId, + cwd, success: false, error: this.toCheckoutError(error), requestId, @@ -3261,39 +3178,19 @@ export class Session { private async handleCheckoutPrCreateRequest( msg: Extract ): Promise { - const { agentId, requestId } = msg; - const agent = this.agentManager.getAgent(agentId); - if (!agent) { - this.emit({ - type: "checkout_pr_create_response", - payload: { - agentId, - url: null, - number: null, - error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, - requestId, - }, - }); - return; - } + const { cwd, requestId } = msg; try { let title = msg.title?.trim() ?? ""; let body = msg.body?.trim() ?? ""; + if (!title || !body) { - const generated = await this.generatePullRequestText(agent, msg.baseRef); - if (!title) { - title = generated.title; - } - if (!body) { - body = generated.body; - } - } - if (!title) { - throw new Error("Pull request title is required"); + const generated = await this.generatePullRequestText(cwd, msg.baseRef); + if (!title) title = generated.title; + if (!body) body = generated.body; } - const result = await createPullRequest(agent.cwd, { + const result = await createPullRequest(cwd, { title, body, base: msg.baseRef, @@ -3302,7 +3199,7 @@ export class Session { this.emit({ type: "checkout_pr_create_response", payload: { - agentId, + cwd, url: result.url ?? null, number: result.number ?? null, error: null, @@ -3313,7 +3210,7 @@ export class Session { this.emit({ type: "checkout_pr_create_response", payload: { - agentId, + cwd, url: null, number: null, error: this.toCheckoutError(error), @@ -3326,27 +3223,14 @@ export class Session { private async handleCheckoutPrStatusRequest( msg: Extract ): Promise { - const { agentId, requestId } = msg; - const agent = this.agentManager.getAgent(agentId); - if (!agent) { - this.emit({ - type: "checkout_pr_status_response", - payload: { - agentId, - status: null, - error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, - requestId, - }, - }); - return; - } + const { cwd, requestId } = msg; try { - const status = await getPullRequestStatus(agent.cwd); + const status = await getPullRequestStatus(cwd); this.emit({ type: "checkout_pr_status_response", payload: { - agentId, + cwd, status, error: null, requestId, @@ -3356,7 +3240,7 @@ export class Session { this.emit({ type: "checkout_pr_status_response", payload: { - agentId, + cwd, status: null, error: this.toCheckoutError(error), requestId, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 77835fcac..64b065327 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -476,22 +476,20 @@ const CheckoutDiffCompareSchema = z.object({ export const CheckoutStatusRequestSchema = z.object({ type: z.literal("checkout_status_request"), - agentId: z.string(), - /** Optional cwd to use if the agent is not live in memory (e.g. from persisted agents.json) */ - cwd: z.string().optional(), + cwd: z.string(), requestId: z.string(), }); export const CheckoutDiffRequestSchema = z.object({ type: z.literal("checkout_diff_request"), - agentId: z.string(), + cwd: z.string(), compare: CheckoutDiffCompareSchema, requestId: z.string(), }); export const CheckoutCommitRequestSchema = z.object({ type: z.literal("checkout_commit_request"), - agentId: z.string(), + cwd: z.string(), message: z.string().optional(), addAll: z.boolean().optional(), requestId: z.string(), @@ -499,7 +497,7 @@ export const CheckoutCommitRequestSchema = z.object({ export const CheckoutMergeRequestSchema = z.object({ type: z.literal("checkout_merge_request"), - agentId: z.string(), + cwd: z.string(), baseRef: z.string().optional(), strategy: z.enum(["merge", "squash"]).optional(), requireCleanTarget: z.boolean().optional(), @@ -508,7 +506,7 @@ export const CheckoutMergeRequestSchema = z.object({ export const CheckoutMergeFromBaseRequestSchema = z.object({ type: z.literal("checkout_merge_from_base_request"), - agentId: z.string(), + cwd: z.string(), baseRef: z.string().optional(), requireCleanTarget: z.boolean().optional(), requestId: z.string(), @@ -516,13 +514,13 @@ export const CheckoutMergeFromBaseRequestSchema = z.object({ export const CheckoutPushRequestSchema = z.object({ type: z.literal("checkout_push_request"), - agentId: z.string(), + cwd: z.string(), requestId: z.string(), }); export const CheckoutPrCreateRequestSchema = z.object({ type: z.literal("checkout_pr_create_request"), - agentId: z.string(), + cwd: z.string(), title: z.string().optional(), body: z.string().optional(), baseRef: z.string().optional(), @@ -531,7 +529,7 @@ export const CheckoutPrCreateRequestSchema = z.object({ export const CheckoutPrStatusRequestSchema = z.object({ type: z.literal("checkout_pr_status_request"), - agentId: z.string(), + cwd: z.string(), requestId: z.string(), }); @@ -1052,7 +1050,6 @@ const AheadBehindSchema = z.object({ }); const CheckoutStatusCommonSchema = z.object({ - agentId: z.string(), cwd: z.string(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), @@ -1066,6 +1063,7 @@ const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({ isDirty: z.null(), baseRef: z.null(), aheadBehind: z.null(), + aheadOfOrigin: z.null(), hasRemote: z.boolean(), remoteUrl: z.null(), }); @@ -1078,6 +1076,7 @@ const CheckoutStatusGitNonPaseoSchema = CheckoutStatusCommonSchema.extend({ isDirty: z.boolean(), baseRef: z.string().nullable(), aheadBehind: AheadBehindSchema.nullable(), + aheadOfOrigin: z.number().nullable(), hasRemote: z.boolean(), remoteUrl: z.string().nullable(), }); @@ -1090,6 +1089,7 @@ const CheckoutStatusGitPaseoSchema = CheckoutStatusCommonSchema.extend({ isDirty: z.boolean(), baseRef: z.string(), aheadBehind: AheadBehindSchema.nullable(), + aheadOfOrigin: z.number().nullable(), hasRemote: z.boolean(), remoteUrl: z.string().nullable(), }); @@ -1106,7 +1106,7 @@ export const CheckoutStatusResponseSchema = z.object({ export const CheckoutDiffResponseSchema = z.object({ type: z.literal("checkout_diff_response"), payload: z.object({ - agentId: z.string(), + cwd: z.string(), files: z.array(ParsedDiffFileSchema), error: CheckoutErrorSchema.nullable(), requestId: z.string(), @@ -1116,7 +1116,7 @@ export const CheckoutDiffResponseSchema = z.object({ export const CheckoutCommitResponseSchema = z.object({ type: z.literal("checkout_commit_response"), payload: z.object({ - agentId: z.string(), + cwd: z.string(), success: z.boolean(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), @@ -1126,7 +1126,7 @@ export const CheckoutCommitResponseSchema = z.object({ export const CheckoutMergeResponseSchema = z.object({ type: z.literal("checkout_merge_response"), payload: z.object({ - agentId: z.string(), + cwd: z.string(), success: z.boolean(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), @@ -1136,7 +1136,7 @@ export const CheckoutMergeResponseSchema = z.object({ export const CheckoutMergeFromBaseResponseSchema = z.object({ type: z.literal("checkout_merge_from_base_response"), payload: z.object({ - agentId: z.string(), + cwd: z.string(), success: z.boolean(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), @@ -1146,7 +1146,7 @@ export const CheckoutMergeFromBaseResponseSchema = z.object({ export const CheckoutPushResponseSchema = z.object({ type: z.literal("checkout_push_response"), payload: z.object({ - agentId: z.string(), + cwd: z.string(), success: z.boolean(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), @@ -1156,7 +1156,7 @@ export const CheckoutPushResponseSchema = z.object({ export const CheckoutPrCreateResponseSchema = z.object({ type: z.literal("checkout_pr_create_response"), payload: z.object({ - agentId: z.string(), + cwd: z.string(), url: z.string().nullable(), number: z.number().nullable(), error: CheckoutErrorSchema.nullable(), @@ -1175,7 +1175,7 @@ const CheckoutPrStatusSchema = z.object({ export const CheckoutPrStatusResponseSchema = z.object({ type: z.literal("checkout_pr_status_response"), payload: z.object({ - agentId: z.string(), + cwd: z.string(), status: CheckoutPrStatusSchema.nullable(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index f84fd45c3..57cc39f03 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -70,6 +70,7 @@ export type CheckoutStatusGitNonPaseo = { isDirty: boolean; baseRef: string | null; aheadBehind: AheadBehind | null; + aheadOfOrigin: number | null; hasRemote: boolean; remoteUrl: string | null; isPaseoOwnedWorktree: false; @@ -82,6 +83,7 @@ export type CheckoutStatusGitPaseo = { isDirty: boolean; baseRef: string; aheadBehind: AheadBehind | null; + aheadOfOrigin: number | null; hasRemote: boolean; remoteUrl: string | null; isPaseoOwnedWorktree: true; @@ -393,6 +395,22 @@ async function getAheadBehind(cwd: string, baseRef: string, currentBranch: strin return { ahead, behind }; } +async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise { + if (!currentBranch) { + return null; + } + try { + const { stdout } = await execAsync( + `git rev-list --count origin/${currentBranch}..${currentBranch}`, + { cwd, env: READ_ONLY_GIT_ENV } + ); + const count = Number.parseInt(stdout.trim(), 10); + return Number.isNaN(count) ? null : count; + } catch { + return null; + } +} + async function getUntrackedDiff(cwd: string): Promise { let untrackedDiff = ""; try { @@ -443,6 +461,8 @@ export async function getCheckoutStatus( const baseRef = configured.baseRef ?? (await resolveBaseRef(repoInfo.path)); const aheadBehind = baseRef && currentBranch ? await getAheadBehind(cwd, baseRef, currentBranch) : null; + const aheadOfOrigin = + hasRemote && currentBranch ? await getAheadOfOrigin(cwd, currentBranch) : null; if (configured.isPaseoOwnedWorktree) { return { @@ -452,6 +472,7 @@ export async function getCheckoutStatus( isDirty, baseRef: configured.baseRef, aheadBehind, + aheadOfOrigin, hasRemote, remoteUrl, isPaseoOwnedWorktree: true, @@ -465,6 +486,7 @@ export async function getCheckoutStatus( isDirty, baseRef, aheadBehind, + aheadOfOrigin, hasRemote, remoteUrl, isPaseoOwnedWorktree: false,