diff --git a/apps/array/src/main/services/agent/schemas.ts b/apps/array/src/main/services/agent/schemas.ts index 58cdadbe..b5d2aa4a 100644 --- a/apps/array/src/main/services/agent/schemas.ts +++ b/apps/array/src/main/services/agent/schemas.ts @@ -13,6 +13,10 @@ export type Credentials = z.infer; export const agentFrameworkSchema = z.enum(["claude", "codex"]); export type AgentFramework = z.infer; +// Execution mode schema +export const executionModeSchema = z.enum(["plan"]); +export type ExecutionMode = z.infer; + // Session config schema export const sessionConfigSchema = z.object({ taskId: z.string(), @@ -23,6 +27,7 @@ export const sessionConfigSchema = z.object({ sdkSessionId: z.string().optional(), model: z.string().optional(), framework: agentFrameworkSchema.optional(), + executionMode: executionModeSchema.optional(), }); export type SessionConfig = z.infer; @@ -120,6 +125,7 @@ export const subscribeSessionInput = z.object({ // Agent events export const AgentServiceEvent = { SessionEvent: "session-event", + PermissionRequest: "permission-request", } as const; export interface AgentSessionEventPayload { @@ -127,6 +133,39 @@ export interface AgentSessionEventPayload { payload: unknown; } +export interface PermissionOption { + kind: "allow_once" | "allow_always" | "reject_once" | "reject_always"; + name: string; + optionId: string; + description?: string; +} + +export interface PermissionRequestPayload { + sessionId: string; + toolCallId: string; + title: string; + options: PermissionOption[]; + rawInput: unknown; +} + export interface AgentServiceEvents { [AgentServiceEvent.SessionEvent]: AgentSessionEventPayload; + [AgentServiceEvent.PermissionRequest]: PermissionRequestPayload; } + +// Permission response input for tRPC +export const respondToPermissionInput = z.object({ + sessionId: z.string(), + toolCallId: z.string(), + optionId: z.string(), +}); + +export type RespondToPermissionInput = z.infer; + +// Permission cancellation input for tRPC +export const cancelPermissionInput = z.object({ + sessionId: z.string(), + toolCallId: z.string(), +}); + +export type CancelPermissionInput = z.infer; diff --git a/apps/array/src/main/services/agent/service.ts b/apps/array/src/main/services/agent/service.ts index 095df23f..3ee2e84e 100644 --- a/apps/array/src/main/services/agent/service.ts +++ b/apps/array/src/main/services/agent/service.ts @@ -130,6 +130,7 @@ interface SessionConfig { sdkSessionId?: string; model?: string; framework?: "claude" | "codex"; + executionMode?: "plan"; } interface ManagedSession { @@ -152,16 +153,84 @@ function getClaudeCliPath(): string { : join(appPath, ".vite/build/claude-cli/cli.js"); } +interface PendingPermission { + resolve: (response: RequestPermissionResponse) => void; + reject: (error: Error) => void; + sessionId: string; + toolCallId: string; +} + @injectable() export class AgentService extends TypedEventEmitter { private sessions = new Map(); private currentToken: string | null = null; + private pendingPermissions = new Map(); public updateToken(newToken: string): void { this.currentToken = newToken; log.info("Session token updated"); } + /** + * Respond to a pending permission request from the UI. + * This resolves the promise that the agent is waiting on. + */ + public respondToPermission( + sessionId: string, + toolCallId: string, + optionId: string, + ): void { + const key = `${sessionId}:${toolCallId}`; + const pending = this.pendingPermissions.get(key); + + if (!pending) { + log.warn("No pending permission found", { sessionId, toolCallId }); + return; + } + + log.info("Permission response received", { + sessionId, + toolCallId, + optionId, + }); + + pending.resolve({ + outcome: { + outcome: "selected", + optionId, + }, + }); + + this.pendingPermissions.delete(key); + } + + /** + * Cancel a pending permission request. + * This resolves the promise with a "cancelled" outcome per ACP spec. + */ + public cancelPermission(sessionId: string, toolCallId: string): void { + const key = `${sessionId}:${toolCallId}`; + const pending = this.pendingPermissions.get(key); + + if (!pending) { + log.warn("No pending permission found to cancel", { + sessionId, + toolCallId, + }); + return; + } + + log.info("Permission cancelled", { sessionId, toolCallId }); + + pending.resolve({ + outcome: { + outcome: "cancelled", + }, + }); + + this.pendingPermissions.delete(key); + } + private getToken(fallback: string): string { return this.currentToken || fallback; } @@ -232,6 +301,7 @@ export class AgentService extends TypedEventEmitter { sdkSessionId, model, framework, + executionMode, } = config; if (!isRetry) { @@ -289,7 +359,11 @@ export class AgentService extends TypedEventEmitter { await connection.newSession({ cwd: repoPath, mcpServers, - _meta: { sessionId: taskRunId, model }, + _meta: { + sessionId: taskRunId, + model, + ...(executionMode && { initialModeId: executionMode }), + }, }); } @@ -515,6 +589,9 @@ export class AgentService extends TypedEventEmitter { _channel: string, clientStreams: { readable: ReadableStream; writable: WritableStream }, ): ClientSideConnection { + // Capture service reference for use in client callbacks + const service = this; + const emitToRenderer = (payload: unknown) => { // Emit event via TypedEventEmitter for tRPC subscription this.emit(AgentServiceEvent.SessionEvent, { @@ -546,6 +623,63 @@ export class AgentService extends TypedEventEmitter { async requestPermission( params: RequestPermissionRequest, ): Promise { + const toolName = + (params.toolCall?.rawInput as { toolName?: string } | undefined) + ?.toolName || ""; + const toolCallId = params.toolCall?.toolCallId || ""; + + log.info("requestPermission called", { + sessionId: taskRunId, + toolCallId, + toolName, + title: params.toolCall?.title, + optionCount: params.options.length, + }); + + // If we have a toolCallId, always prompt the user for permission. + // The claude.ts adapter only calls requestPermission when user input is needed. + // (It handles auto-approve internally for acceptEdits/bypassPermissions modes) + if (toolCallId) { + log.info("Permission request requires user input", { + sessionId: taskRunId, + toolCallId, + toolName, + title: params.toolCall?.title, + }); + + return new Promise((resolve, reject) => { + const key = `${taskRunId}:${toolCallId}`; + service.pendingPermissions.set(key, { + resolve, + reject, + sessionId: taskRunId, + toolCallId, + }); + + log.info("Emitting permission request to renderer", { + sessionId: taskRunId, + toolCallId, + }); + service.emit(AgentServiceEvent.PermissionRequest, { + sessionId: taskRunId, + toolCallId, + title: params.toolCall?.title || "Permission Required", + options: params.options.map((o) => ({ + kind: o.kind, + name: o.name, + optionId: o.optionId, + description: (o as { description?: string }).description, + })), + rawInput: params.toolCall?.rawInput, + }); + }); + } + + // Fallback: no toolCallId means we can't track the response, auto-approve + log.warn("No toolCallId in permission request, auto-approving", { + sessionId: taskRunId, + toolName, + }); const allowOption = params.options.find( (o) => o.kind === "allow_once" || o.kind === "allow_always", ); @@ -611,6 +745,8 @@ export class AgentService extends TypedEventEmitter { sdkSessionId: "sdkSessionId" in params ? params.sdkSessionId : undefined, model: "model" in params ? params.model : undefined, framework: "framework" in params ? params.framework : "claude", + executionMode: + "executionMode" in params ? params.executionMode : undefined, }; } diff --git a/apps/array/src/main/services/workspace/service.ts b/apps/array/src/main/services/workspace/service.ts index dc472dbd..10c75bb0 100644 --- a/apps/array/src/main/services/workspace/service.ts +++ b/apps/array/src/main/services/workspace/service.ts @@ -89,6 +89,7 @@ export interface WorkspaceServiceEvents { @injectable() export class WorkspaceService extends TypedEventEmitter { private scriptRunner: ScriptRunner; + private creatingWorkspaces = new Map>(); constructor() { super(); @@ -100,6 +101,28 @@ export class WorkspaceService extends TypedEventEmitter } async createWorkspace(options: CreateWorkspaceInput): Promise { + // Prevent concurrent workspace creation for the same task + const existingPromise = this.creatingWorkspaces.get(options.taskId); + if (existingPromise) { + log.warn( + `Workspace creation already in progress for task ${options.taskId}, waiting for existing operation`, + ); + return existingPromise; + } + + const promise = this.doCreateWorkspace(options); + this.creatingWorkspaces.set(options.taskId, promise); + + try { + return await promise; + } finally { + this.creatingWorkspaces.delete(options.taskId); + } + } + + private async doCreateWorkspace( + options: CreateWorkspaceInput, + ): Promise { const { taskId, mainRepoPath, folderId, folderPath, mode, branch } = options; log.info( diff --git a/apps/array/src/main/trpc/routers/agent.ts b/apps/array/src/main/trpc/routers/agent.ts index e750d81e..cb9c9249 100644 --- a/apps/array/src/main/trpc/routers/agent.ts +++ b/apps/array/src/main/trpc/routers/agent.ts @@ -3,11 +3,13 @@ import { container } from "../../di/container.js"; import { MAIN_TOKENS } from "../../di/tokens.js"; import { AgentServiceEvent, + cancelPermissionInput, cancelPromptInput, cancelSessionInput, promptInput, promptOutput, reconnectSessionInput, + respondToPermissionInput, sessionResponseSchema, setModelInput, startSessionInput, @@ -72,4 +74,39 @@ export const agentRouter = router({ } } }), + + // Permission request subscription - yields when tools need user input + onPermissionRequest: publicProcedure + .input(subscribeSessionInput) + .subscription(async function* (opts) { + const service = getService(); + const targetSessionId = opts.input.sessionId; + const iterable = service.toIterable(AgentServiceEvent.PermissionRequest, { + signal: opts.signal, + }); + + for await (const event of iterable) { + if (event.sessionId === targetSessionId) { + yield event; + } + } + }), + + // Respond to a permission request from the UI + respondToPermission: publicProcedure + .input(respondToPermissionInput) + .mutation(({ input }) => + getService().respondToPermission( + input.sessionId, + input.toolCallId, + input.optionId, + ), + ), + + // Cancel a permission request (e.g., user pressed Escape) + cancelPermission: publicProcedure + .input(cancelPermissionInput) + .mutation(({ input }) => + getService().cancelPermission(input.sessionId, input.toolCallId), + ), }); diff --git a/apps/array/src/renderer/features/sessions/components/ConversationView.tsx b/apps/array/src/renderer/features/sessions/components/ConversationView.tsx index ffd05c7e..959b066d 100644 --- a/apps/array/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/array/src/renderer/features/sessions/components/ConversationView.tsx @@ -52,6 +52,7 @@ interface ConversationViewProps { isPromptPending: boolean; repoPath?: string | null; isCloud?: boolean; + taskId?: string; } const SCROLL_THRESHOLD = 100; @@ -62,6 +63,7 @@ export function ConversationView({ isPromptPending, repoPath, isCloud = false, + taskId, }: ConversationViewProps) { const scrollRef = useRef(null); const items = useMemo(() => buildConversationItems(events), [events]); @@ -121,6 +123,7 @@ export function ConversationView({ turn={item} repoPath={repoPath} isCloud={isCloud} + taskId={taskId} /> ) : ( @@ -151,12 +154,14 @@ interface TurnViewProps { turn: Turn; repoPath?: string | null; isCloud?: boolean; + taskId?: string; } const TurnView = memo(function TurnView({ turn, repoPath, isCloud = false, + taskId, }: TurnViewProps) { const wasCancelled = turn.stopReason === "cancelled"; const gitAction = parseGitActionMessage(turn.userContent); @@ -175,6 +180,7 @@ const TurnView = memo(function TurnView({ key={`${item.sessionUpdate}-${i}`} item={item} toolCalls={turn.toolCalls} + taskId={taskId} turnCancelled={wasCancelled} /> ))} diff --git a/apps/array/src/renderer/features/sessions/components/InlinePermissionSelector.tsx b/apps/array/src/renderer/features/sessions/components/InlinePermissionSelector.tsx new file mode 100644 index 00000000..eaeb87e6 --- /dev/null +++ b/apps/array/src/renderer/features/sessions/components/InlinePermissionSelector.tsx @@ -0,0 +1,258 @@ +import { Box, Flex, Text } from "@radix-ui/themes"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; + +export interface PermissionOption { + optionId: string; + name: string; + description?: string; + kind: string; +} + +interface InlinePermissionSelectorProps { + title: string; + options: PermissionOption[]; + onSelect: (optionId: string, customInput?: string) => void; + onCancel?: () => void; + disabled?: boolean; +} + +export function InlinePermissionSelector({ + title, + options, + onSelect, + onCancel, + disabled = false, +}: InlinePermissionSelectorProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const [isCustomInputMode, setIsCustomInputMode] = useState(false); + const [customInput, setCustomInput] = useState(""); + const inputRef = useRef(null); + const containerRef = useRef(null); + + // Auto-focus the container when component mounts to capture keyboard events + useEffect(() => { + if (!disabled && containerRef.current) { + containerRef.current.focus(); + } + }, [disabled]); + + // Filter to only show: allow_always (Accept All), allow_once (Accept), and custom input + // The custom input uses reject_once under the hood to send feedback + const allOptions = useMemo(() => { + const filteredOptions = options.filter( + (o) => o.kind === "allow_always" || o.kind === "allow_once", + ); + return [ + ...filteredOptions, + { optionId: "_custom", name: "Other", description: "", kind: "custom" }, + ]; + }, [options]); + + const numOptions = allOptions.length; + + // Focus custom input when entering that mode + useEffect(() => { + if (isCustomInputMode && inputRef.current) { + inputRef.current.focus(); + } + }, [isCustomInputMode]); + + const moveUp = useCallback(() => { + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : numOptions - 1)); + }, [numOptions]); + + const moveDown = useCallback(() => { + setSelectedIndex((prev) => (prev < numOptions - 1 ? prev + 1 : 0)); + }, [numOptions]); + + const selectCurrent = useCallback(() => { + const selected = allOptions[selectedIndex]; + if (selected.optionId === "_custom") { + setIsCustomInputMode(true); + } else { + onSelect(selected.optionId); + } + }, [allOptions, selectedIndex, onSelect]); + + const handleCancel = useCallback(() => { + onCancel?.(); + }, [onCancel]); + + // Keyboard navigation using useHotkeys + const isEnabled = !disabled && !isCustomInputMode; + + useHotkeys( + "up", + moveUp, + { + enabled: isEnabled, + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [moveUp, isEnabled], + ); + useHotkeys( + "down", + moveDown, + { + enabled: isEnabled, + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [moveDown, isEnabled], + ); + useHotkeys( + "left", + moveUp, + { + enabled: isEnabled, + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [moveUp, isEnabled], + ); + useHotkeys( + "right", + moveDown, + { + enabled: isEnabled, + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [moveDown, isEnabled], + ); + useHotkeys( + "tab", + moveDown, + { + enabled: isEnabled, + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [moveDown, isEnabled], + ); + useHotkeys( + "enter", + selectCurrent, + { + enabled: isEnabled, + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [selectCurrent, isEnabled], + ); + useHotkeys( + "escape", + handleCancel, + { + enabled: isEnabled, + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [handleCancel, isEnabled], + ); + + const handleCustomInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + setIsCustomInputMode(false); + setCustomInput(""); + } else if (e.key === "Enter" && customInput.trim()) { + e.preventDefault(); + const keepPlanningOption = options.find( + (o) => o.kind === "reject_once", + ); + if (keepPlanningOption) { + onSelect(keepPlanningOption.optionId, customInput.trim()); + } + } + }, + [customInput, options, onSelect], + ); + + const handleOptionClick = (index: number) => { + if (disabled) return; + const opt = allOptions[index]; + if (opt.optionId === "_custom") { + setSelectedIndex(index); + setIsCustomInputMode(true); + } else { + onSelect(opt.optionId); + } + }; + + return ( + + {/* Question/Title */} + + {title} + + + {/* Options - single line each */} + + {allOptions.map((option, index) => { + const isSelected = selectedIndex === index; + const isCustom = option.optionId === "_custom"; + + if (isCustom && isCustomInputMode) { + return ( + + + setCustomInput(e.target.value)} + onKeyDown={handleCustomInputKeyDown} + placeholder="Type your feedback and press Enter..." + className="flex-1 border-none bg-transparent text-gray-12 text-xs outline-none placeholder:text-gray-9" + disabled={disabled} + /> + + ); + } + + return ( + handleOptionClick(index)} + > + + › + + {option.name} + + ); + })} + + + {/* Keyboard hints */} + + Enter to select · ↑↓ to navigate · Esc to cancel + + + ); +} diff --git a/apps/array/src/renderer/features/sessions/components/ModeIndicator.tsx b/apps/array/src/renderer/features/sessions/components/ModeIndicator.tsx new file mode 100644 index 00000000..3374d34e --- /dev/null +++ b/apps/array/src/renderer/features/sessions/components/ModeIndicator.tsx @@ -0,0 +1,63 @@ +import { Notepad, Pencil, ShieldCheck } from "@phosphor-icons/react"; +import { Badge, Flex, Text, Tooltip } from "@radix-ui/themes"; +import type { ExecutionMode } from "../stores/sessionStore"; +import { useCurrentModeForTask } from "../stores/sessionStore"; + +interface ModeIndicatorProps { + taskId?: string; +} + +const modeConfig: Record< + ExecutionMode, + { + label: string; + icon: React.ReactNode; + color: "amber" | "gray" | "green"; + tooltip: string; + } +> = { + plan: { + label: "Plan Mode", + icon: , + color: "amber", + tooltip: "Agent will plan first and ask for approval before making changes", + }, + default: { + label: "Default", + icon: , + color: "gray", + tooltip: "Agent will ask for approval on each edit", + }, + acceptEdits: { + label: "Auto-accept", + icon: , + color: "green", + tooltip: "Edits are automatically approved", + }, +}; + +export function ModeIndicator({ taskId }: ModeIndicatorProps) { + const currentMode = useCurrentModeForTask(taskId); + + if (!currentMode) { + return null; + } + + const config = modeConfig[currentMode]; + + return ( + + + + {config.icon} + {config.label} + + + + ); +} diff --git a/apps/array/src/renderer/features/sessions/components/SessionView.tsx b/apps/array/src/renderer/features/sessions/components/SessionView.tsx index 950bdd6b..fd3e57fc 100644 --- a/apps/array/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/array/src/renderer/features/sessions/components/SessionView.tsx @@ -1,5 +1,9 @@ import { MessageEditor } from "@features/message-editor/components/MessageEditor"; import { useDraftStore } from "@features/message-editor/stores/draftStore"; +import { + usePendingPermissionsForTask, + useSessionActions, +} from "@features/sessions/stores/sessionStore"; import type { Plan } from "@features/sessions/types"; import { Box, ContextMenu, Flex } from "@radix-ui/themes"; import { @@ -14,6 +18,8 @@ import { useShowRawLogs, } from "../stores/sessionViewStore"; import { ConversationView } from "./ConversationView"; +import { InlinePermissionSelector } from "./InlinePermissionSelector"; +import { ModeIndicator } from "./ModeIndicator"; import { PlanStatusBar } from "./PlanStatusBar"; import { RawLogsView } from "./raw-logs/RawLogsView"; @@ -42,6 +48,8 @@ export function SessionView({ }: SessionViewProps) { const showRawLogs = useShowRawLogs(); const { setShowRawLogs } = useSessionViewActions(); + const pendingPermissions = usePendingPermissionsForTask(taskId); + const { respondToPermission, cancelPermission } = useSessionActions(); const sessionId = taskId ?? "default"; const setContext = useDraftStore((s) => s.actions.setContext); @@ -103,10 +111,56 @@ export function SessionView({ const [isBashMode, setIsBashMode] = useState(false); + const firstPendingPermission = useMemo(() => { + const entries = Array.from(pendingPermissions.entries()); + if (entries.length === 0) return null; + const [toolCallId, permission] = entries[0]; + return { ...permission, toolCallId }; + }, [pendingPermissions]); + + const handlePermissionSelect = useCallback( + async (optionId: string, customInput?: string) => { + if (!firstPendingPermission || !taskId) return; + + // If custom input provided, send it as a prompt after selecting "keep planning" + if (customInput) { + await respondToPermission( + taskId, + firstPendingPermission.toolCallId, + optionId, + ); + // Send the custom input as a follow-up prompt + onSendPrompt(customInput); + } else { + await respondToPermission( + taskId, + firstPendingPermission.toolCallId, + optionId, + ); + } + }, + [firstPendingPermission, taskId, respondToPermission, onSendPrompt], + ); + + const handlePermissionCancel = useCallback(async () => { + if (!firstPendingPermission || !taskId) return; + await cancelPermission(taskId, firstPendingPermission.toolCallId); + }, [firstPendingPermission, taskId, cancelPermission]); + return ( + {taskId && ( + + + + )} {showRawLogs ? ( ) : ( @@ -115,27 +169,38 @@ export function SessionView({ isPromptPending={isPromptPending} repoPath={repoPath} isCloud={isCloud} + taskId={taskId} /> )} - - - + ) : ( + + + + )} diff --git a/apps/array/src/renderer/features/sessions/components/session-update/InlineQuestionView.tsx b/apps/array/src/renderer/features/sessions/components/session-update/InlineQuestionView.tsx new file mode 100644 index 00000000..d01f5a8c --- /dev/null +++ b/apps/array/src/renderer/features/sessions/components/session-update/InlineQuestionView.tsx @@ -0,0 +1,128 @@ +import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; +import { + usePendingPermissionsForTask, + useSessionActions, +} from "@features/sessions/stores/sessionStore"; +import type { ToolCall } from "@features/sessions/types"; +import { ChatCircle, CheckCircle } from "@phosphor-icons/react"; +import { Box, Button, Flex, Text } from "@radix-ui/themes"; +import { useState } from "react"; + +interface InlineQuestionViewProps { + toolCall: ToolCall; + taskId: string; + turnCancelled?: boolean; +} + +interface QuestionInput { + questions?: Array<{ + question: string; + header?: string; + options: Array<{ + label: string; + description?: string; + }>; + multiSelect?: boolean; + }>; + answers?: Record; +} + +export function InlineQuestionView({ + toolCall, + taskId, + turnCancelled, +}: InlineQuestionViewProps) { + const { toolCallId, rawInput, status } = toolCall; + const input = rawInput as QuestionInput | undefined; + const pendingPermissions = usePendingPermissionsForTask(taskId); + const { respondToPermission } = useSessionActions(); + const [isResponding, setIsResponding] = useState(false); + + const pendingPermission = pendingPermissions.get(toolCallId); + const isComplete = status === "completed"; + const isPending = !!pendingPermission && !isComplete; + const wasCancelled = + (status === "pending" || status === "in_progress") && turnCancelled; + + const firstQuestion = input?.questions?.[0]; + const questionText = firstQuestion?.question ?? "Question"; + const selectedAnswer = input?.answers?.[questionText]; + + const handleOptionClick = async (optionId: string) => { + if (!pendingPermission || isResponding) return; + setIsResponding(true); + try { + await respondToPermission(taskId, toolCallId, optionId); + } finally { + setIsResponding(false); + } + }; + + return ( + + {/* Header */} + + {isPending && !isResponding ? ( + + ) : isComplete ? ( + + ) : ( + + )} + + {firstQuestion?.header ?? "Question"} + + + + {/* Question text */} + + + {questionText} + + + + {/* Options or Answer */} + {isPending && pendingPermission.options.length > 0 ? ( + + {pendingPermission.options.map((option) => ( + + ))} + + ) : selectedAnswer ? ( + + + + Answer: + + + {selectedAnswer} + + + + ) : wasCancelled ? ( + + + (Cancelled) + + + ) : null} + + ); +} diff --git a/apps/array/src/renderer/features/sessions/components/session-update/PlanApprovalView.tsx b/apps/array/src/renderer/features/sessions/components/session-update/PlanApprovalView.tsx new file mode 100644 index 00000000..a4c33dc1 --- /dev/null +++ b/apps/array/src/renderer/features/sessions/components/session-update/PlanApprovalView.tsx @@ -0,0 +1,99 @@ +import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; +import { usePendingPermissionsForTask } from "@features/sessions/stores/sessionStore"; +import type { ToolCall } from "@features/sessions/types"; +import { CheckCircle, ClockCounterClockwise } from "@phosphor-icons/react"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import { useMemo } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +interface PlanApprovalViewProps { + toolCall: ToolCall; + taskId: string; + turnCancelled?: boolean; +} + +export function PlanApprovalView({ + toolCall, + taskId, + turnCancelled, +}: PlanApprovalViewProps) { + const { toolCallId, status, content } = toolCall; + const pendingPermissions = usePendingPermissionsForTask(taskId); + + const pendingPermission = pendingPermissions.get(toolCallId); + const isComplete = status === "completed"; + const isPending = !!pendingPermission && !isComplete; + const wasCancelled = + (status === "pending" || status === "in_progress") && turnCancelled; + + // Extract plan text from content or rawInput + const planText = useMemo(() => { + // Try rawInput first (where Claude SDK puts the plan) + const rawPlan = (toolCall.rawInput as { plan?: string } | undefined)?.plan; + if (rawPlan) return rawPlan; + + // Fallback: check content array + if (!content || content.length === 0) return null; + const textContent = content.find((c) => c.type === "content"); + if (textContent && "content" in textContent) { + const inner = textContent.content as + | { type?: string; text?: string } + | undefined; + if (inner?.type === "text" && inner.text) { + return inner.text; + } + } + return null; + }, [content, toolCall.rawInput]); + + return ( + + {/* Plan content in highlighted box */} + {planText && ( + + + + Implementation Plan + + + + + {planText} + + + + )} + + {/* Status indicator */} + + {isPending ? ( + <> + + + Waiting for approval — use the selector below to continue + + + ) : isComplete ? ( + <> + + + Plan approved — proceeding with implementation + + + ) : wasCancelled ? ( + + (Cancelled) + + ) : ( + <> + + + Preparing plan... + + + )} + + + ); +} diff --git a/apps/array/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx b/apps/array/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx index 36a65887..2fbeb141 100644 --- a/apps/array/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/apps/array/src/renderer/features/sessions/components/session-update/SessionUpdateView.tsx @@ -18,12 +18,14 @@ export type RenderItem = interface SessionUpdateViewProps { item: RenderItem; toolCalls?: Map; + taskId?: string; turnCancelled?: boolean; } export function SessionUpdateView({ item, toolCalls, + taskId, turnCancelled, }: SessionUpdateViewProps) { switch (item.sessionUpdate) { @@ -41,6 +43,7 @@ export function SessionUpdateView({ return ( ); diff --git a/apps/array/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx b/apps/array/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx index 6d72b047..2ba299bf 100644 --- a/apps/array/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx +++ b/apps/array/src/renderer/features/sessions/components/session-update/ToolCallBlock.tsx @@ -1,17 +1,46 @@ import type { ToolCall } from "@features/sessions/types"; import { ExecuteToolView } from "./ExecuteToolView"; +import { InlineQuestionView } from "./InlineQuestionView"; +import { PlanApprovalView } from "./PlanApprovalView"; import { ToolCallView } from "./ToolCallView"; interface ToolCallBlockProps { toolCall: ToolCall; + taskId?: string; turnCancelled?: boolean; } -export function ToolCallBlock({ toolCall, turnCancelled }: ToolCallBlockProps) { +export function ToolCallBlock({ + toolCall, + taskId, + turnCancelled, +}: ToolCallBlockProps) { + // Route to specialized views for interactive tools + if (toolCall.kind === "ask" && taskId) { + return ( + + ); + } + + if (toolCall.kind === "switch_mode" && taskId) { + return ( + + ); + } + if (toolCall.kind === "execute") { return ( ); } + return ; } diff --git a/apps/array/src/renderer/features/sessions/components/session-update/ToolCallView.tsx b/apps/array/src/renderer/features/sessions/components/session-update/ToolCallView.tsx index 4f69b0a0..e7f3ce37 100644 --- a/apps/array/src/renderer/features/sessions/components/session-update/ToolCallView.tsx +++ b/apps/array/src/renderer/features/sessions/components/session-update/ToolCallView.tsx @@ -4,6 +4,7 @@ import { ArrowsClockwise, ArrowsLeftRight, Brain, + ChatCircle, FileText, Globe, type Icon, @@ -25,6 +26,7 @@ const kindIcons: Record = { think: Brain, fetch: Globe, switch_mode: ArrowsClockwise, + ask: ChatCircle, other: Wrench, }; diff --git a/apps/array/src/renderer/features/sessions/stores/sessionStore.ts b/apps/array/src/renderer/features/sessions/stores/sessionStore.ts index 05c0a97d..51c00801 100644 --- a/apps/array/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/array/src/renderer/features/sessions/stores/sessionStore.ts @@ -23,12 +23,21 @@ import { immer } from "zustand/middleware/immer"; import { getCloudUrlFromRegion } from "@/constants/oauth"; import { trpcVanilla } from "@/renderer/trpc"; import { ANALYTICS_EVENTS } from "@/types/analytics"; +import { + findPendingPermissions, + type PermissionRequest, +} from "../utils/parseSessionLogs"; const log = logger.scope("session-store"); const CLOUD_POLLING_INTERVAL_MS = 500; // --- Types --- +// Re-export for external consumers +export type { PermissionRequest }; + +export type ExecutionMode = "plan" | "default" | "acceptEdits"; + export interface AgentSession { taskRunId: string; taskId: string; @@ -42,6 +51,10 @@ export interface AgentSession { processedLineCount?: number; model?: string; framework?: "claude" | "codex"; + // Current execution mode (plan = read-only, default = manual approve, acceptEdits = auto-approve edits) + currentMode: ExecutionMode; + // Permission requests waiting for user response + pendingPermissions: Map; } interface SessionState { @@ -53,6 +66,7 @@ interface SessionActions { task: Task; repoPath: string; initialPrompt?: ContentBlock[]; + executionMode?: "plan"; }) => Promise; disconnectFromTask: (taskId: string) => Promise; sendPrompt: ( @@ -67,6 +81,12 @@ interface SessionActions { cwd: string, result: { stdout: string; stderr: string; exitCode: number }, ) => Promise; + respondToPermission: ( + taskId: string, + toolCallId: string, + optionId: string, + ) => Promise; + cancelPermission: (taskId: string, toolCallId: string) => Promise; } interface AuthCredentials { @@ -81,7 +101,13 @@ type SessionStore = SessionState & { actions: SessionActions }; const connectAttempts = new Set(); const cloudPollers = new Map(); // Track active tRPC subscriptions for cleanup -const subscriptions = new Map void }>(); +const subscriptions = new Map< + string, + { + event: { unsubscribe: () => void }; + permission?: { unsubscribe: () => void }; + } +>(); /** * Subscribe to agent session events via tRPC subscription. @@ -90,7 +116,7 @@ const subscriptions = new Map void }>(); function subscribeToChannel(taskRunId: string) { if (subscriptions.has(taskRunId)) return; - const subscription = trpcVanilla.agent.onSessionEvent.subscribe( + const eventSubscription = trpcVanilla.agent.onSessionEvent.subscribe( { sessionId: taskRunId }, { onData: (payload: unknown) => { @@ -98,6 +124,28 @@ function subscribeToChannel(taskRunId: string) { const session = state.sessions[taskRunId]; if (session) { session.events.push(payload as AcpMessage); + + // Handle mode updates from ExitPlanMode approval + const msg = (payload as AcpMessage).message; + if ( + "method" in msg && + msg.method === "session/update" && + "params" in msg + ) { + const params = msg.params as { + update?: { sessionUpdate?: string; currentModeId?: string }; + }; + if ( + params?.update?.sessionUpdate === "current_mode_update" && + params.update.currentModeId + ) { + const newMode = params.update.currentModeId as ExecutionMode; + if (newMode === "default" || newMode === "acceptEdits") { + session.currentMode = newMode; + log.info("Session mode updated", { taskRunId, newMode }); + } + } + } } }); }, @@ -107,12 +155,99 @@ function subscribeToChannel(taskRunId: string) { }, ); - subscriptions.set(taskRunId, subscription); + // Subscribe to permission requests (for AskUserQuestion, ExitPlanMode, etc.) + const permissionSubscription = + trpcVanilla.agent.onPermissionRequest.subscribe( + { sessionId: taskRunId }, + { + onData: async (payload) => { + log.info("Permission request received in renderer", { + taskRunId, + toolCallId: payload.toolCallId, + title: payload.title, + optionCount: payload.options?.length, + }); + + // Get current state and update outside of Immer (Maps don't work well with Immer proxies) + const state = useStore.getState(); + const session = state.sessions[taskRunId]; + + if (session) { + const newPermissions = new Map(session.pendingPermissions); + newPermissions.set(payload.toolCallId, { + toolCallId: payload.toolCallId, + title: payload.title, + options: payload.options, + rawInput: payload.rawInput, + receivedAt: Date.now(), + }); + + log.info("Updating pendingPermissions in store", { + taskRunId, + toolCallId: payload.toolCallId, + newMapSize: newPermissions.size, + }); + + // Update using setState with a new sessions object to trigger re-render + useStore.setState((draft) => { + if (draft.sessions[taskRunId]) { + draft.sessions[taskRunId].pendingPermissions = newPermissions; + } + }); + + // Persist permission request to logs for recovery on reconnect + const auth = useAuthStore.getState(); + if (auth.client && session.taskId) { + const storedEntry: StoredLogEntry = { + type: "notification", + timestamp: new Date().toISOString(), + notification: { + method: "_array/permission_request", + params: { + toolCallId: payload.toolCallId, + title: payload.title, + options: payload.options, + rawInput: payload.rawInput, + }, + }, + }; + try { + await auth.client.appendTaskRunLog(session.taskId, taskRunId, [ + storedEntry, + ]); + log.info("Permission request persisted to logs", { + taskRunId, + toolCallId: payload.toolCallId, + }); + } catch (error) { + log.warn("Failed to persist permission request to logs", { + error, + }); + } + } + } else { + log.warn("Session not found for permission request", { + taskRunId, + availableSessions: Object.keys(state.sessions), + }); + } + }, + onError: (err) => { + log.error("Permission subscription error", { taskRunId, error: err }); + }, + }, + ); + + subscriptions.set(taskRunId, { + event: eventSubscription, + permission: permissionSubscription, + }); } function unsubscribeFromChannel(taskRunId: string) { const subscription = subscriptions.get(taskRunId); - subscription?.unsubscribe(); + subscription?.event.unsubscribe(); + subscription?.permission?.unsubscribe(); subscriptions.delete(taskRunId); } @@ -273,6 +408,7 @@ function createBaseSession( taskId: string, isCloud: boolean, framework?: "claude" | "codex", + executionMode?: "plan", ): AgentSession { return { taskRunId, @@ -284,6 +420,8 @@ function createBaseSession( isPromptPending: false, isCloud, framework, + currentMode: executionMode ?? "default", + pendingPermissions: new Map(), }; } @@ -440,9 +578,20 @@ const useStore = create()( const { rawEntries, sdkSessionId } = await fetchSessionLogs(logUrl); const events = convertStoredEntriesToEvents(rawEntries); + // Restore pending permissions from logs + const pendingPermissions = findPendingPermissions(rawEntries); + if (pendingPermissions.size > 0) { + log.info("Restoring pending permissions from logs", { + taskRunId, + count: pendingPermissions.size, + toolCallIds: Array.from(pendingPermissions.keys()), + }); + } + const session = createBaseSession(taskRunId, taskId, false); session.events = events; session.logUrl = logUrl; + session.pendingPermissions = pendingPermissions; addSession(session); subscribeToChannel(taskRunId); @@ -471,6 +620,7 @@ const useStore = create()( repoPath: string, auth: AuthCredentials, initialPrompt?: ContentBlock[], + executionMode?: "plan", ) => { if (!auth.client) { log.error("API client not available"); @@ -493,6 +643,7 @@ const useStore = create()( projectId: auth.projectId, model: defaultModel, framework: defaultFramework, + executionMode, }); const session = createBaseSession( @@ -500,6 +651,7 @@ const useStore = create()( taskId, false, defaultFramework, + executionMode, ); session.channel = result.channel; session.status = "connected"; @@ -569,7 +721,12 @@ const useStore = create()( sessions: {}, actions: { - connectToTask: async ({ task, repoPath, initialPrompt }) => { + connectToTask: async ({ + task, + repoPath, + initialPrompt, + executionMode, + }) => { const { id: taskId, latest_run: latestRun, @@ -611,6 +768,7 @@ const useStore = create()( repoPath, auth, initialPrompt, + executionMode, ); } } catch (error) { @@ -745,6 +903,145 @@ const useStore = create()( await appendAndPersist(taskId, session, event, storedEntry); }, + + respondToPermission: async (taskId, toolCallId, optionId) => { + const session = getSessionByTaskId(taskId); + if (!session) { + log.error("No session found for permission response", { taskId }); + return; + } + + try { + await trpcVanilla.agent.respondToPermission.mutate({ + sessionId: session.taskRunId, + toolCallId, + optionId, + }); + + // Create new Map outside of Immer (Maps don't work well with Immer proxies) + const currentState = get(); + const sess = currentState.sessions[session.taskRunId]; + if (sess) { + const newPermissions = new Map(sess.pendingPermissions); + newPermissions.delete(toolCallId); + set((draft) => { + if (draft.sessions[session.taskRunId]) { + draft.sessions[session.taskRunId].pendingPermissions = + newPermissions; + } + }); + } + + log.info("Permission response sent", { + taskId, + toolCallId, + optionId, + }); + + // Persist permission response to logs for recovery tracking + const auth = useAuthStore.getState(); + if (auth.client) { + const storedEntry: StoredLogEntry = { + type: "notification", + timestamp: new Date().toISOString(), + notification: { + method: "_array/permission_response", + params: { + toolCallId, + optionId, + }, + }, + }; + try { + await auth.client.appendTaskRunLog(taskId, session.taskRunId, [ + storedEntry, + ]); + log.info("Permission response persisted to logs", { + taskId, + toolCallId, + }); + } catch (persistError) { + log.warn("Failed to persist permission response to logs", { + error: persistError, + }); + } + } + } catch (error) { + log.error("Failed to respond to permission", { + taskId, + toolCallId, + optionId, + error, + }); + } + }, + + cancelPermission: async (taskId, toolCallId) => { + const session = getSessionByTaskId(taskId); + if (!session) { + log.error("No session found for permission cancellation", { + taskId, + }); + return; + } + + try { + await trpcVanilla.agent.cancelPermission.mutate({ + sessionId: session.taskRunId, + toolCallId, + }); + + const currentState = get(); + const sess = currentState.sessions[session.taskRunId]; + if (sess) { + const newPermissions = new Map(sess.pendingPermissions); + newPermissions.delete(toolCallId); + set((draft) => { + if (draft.sessions[session.taskRunId]) { + draft.sessions[session.taskRunId].pendingPermissions = + newPermissions; + } + }); + } + + log.info("Permission cancelled", { taskId, toolCallId }); + + // Persist permission cancellation to logs for recovery tracking + const auth = useAuthStore.getState(); + if (auth.client) { + const storedEntry: StoredLogEntry = { + type: "notification", + timestamp: new Date().toISOString(), + notification: { + method: "_array/permission_response", + params: { + toolCallId, + optionId: "_cancelled", + }, + }, + }; + try { + await auth.client.appendTaskRunLog(taskId, session.taskRunId, [ + storedEntry, + ]); + log.info("Permission cancellation persisted to logs", { + taskId, + toolCallId, + }); + } catch (persistError) { + log.warn("Failed to persist permission cancellation to logs", { + error: persistError, + }); + } + } + } catch (error) { + log.error("Failed to cancel permission", { + taskId, + toolCallId, + error, + }); + } + }, }, }; }), @@ -847,6 +1144,51 @@ export function getUserPromptsForTask(taskId: string | undefined): string[] { return extractUserPromptsFromEvents(session.events); } +/** + * Hook to get pending permissions for a task. + * Returns a Map of toolCallId -> PermissionRequest. + */ +export const usePendingPermissionsForTask = ( + taskId: string | undefined, +): Map => { + return useStore((s) => { + if (!taskId) return new Map(); + const session = Object.values(s.sessions).find( + (sess) => sess.taskId === taskId, + ); + return session?.pendingPermissions ?? new Map(); + }); +}; + +/** + * Get pending permissions for a task (non-hook version). + */ +export function getPendingPermissionsForTask( + taskId: string | undefined, +): Map { + if (!taskId) return new Map(); + const sessions = useStore.getState().sessions; + const session = Object.values(sessions).find( + (sess) => sess.taskId === taskId, + ); + return session?.pendingPermissions ?? new Map(); +} + +/** + * Hook to get the current execution mode for a task. + */ +export const useCurrentModeForTask = ( + taskId: string | undefined, +): ExecutionMode | undefined => { + return useStore((s) => { + if (!taskId) return undefined; + const session = Object.values(s.sessions).find( + (sess) => sess.taskId === taskId, + ); + return session?.currentMode; + }); +}; + // Token refresh subscription let lastKnownToken: string | null = null; useAuthStore.subscribe( diff --git a/apps/array/src/renderer/features/sessions/types.ts b/apps/array/src/renderer/features/sessions/types.ts index 2e84366f..4a6098cb 100644 --- a/apps/array/src/renderer/features/sessions/types.ts +++ b/apps/array/src/renderer/features/sessions/types.ts @@ -1,12 +1,15 @@ import type { + ToolKind as SdkToolKind, SessionNotification, ToolCallContent, ToolCallLocation, ToolCallStatus, - ToolKind, } from "@agentclientprotocol/sdk"; -export type { ToolKind, ToolCallContent, ToolCallStatus, ToolCallLocation }; +// Extend SDK ToolKind with custom kinds +export type ToolKind = SdkToolKind | "ask"; + +export type { ToolCallContent, ToolCallStatus, ToolCallLocation }; export interface ToolCall { _meta?: { [key: string]: unknown } | null; diff --git a/apps/array/src/renderer/features/sessions/utils/parseSessionLogs.ts b/apps/array/src/renderer/features/sessions/utils/parseSessionLogs.ts index b7b64484..4324ff04 100644 --- a/apps/array/src/renderer/features/sessions/utils/parseSessionLogs.ts +++ b/apps/array/src/renderer/features/sessions/utils/parseSessionLogs.ts @@ -100,3 +100,60 @@ export async function fetchSessionLogs( return { notifications: [], rawEntries: [] }; } } + +export interface PermissionRequest { + toolCallId: string; + title: string; + options: Array<{ + kind: string; + name: string; + optionId: string; + description?: string; + }>; + rawInput: unknown; + receivedAt: number; +} + +/** + * Scan log entries to find pending permission requests. + * Returns permission requests that don't have a matching response. + */ +export function findPendingPermissions( + entries: StoredLogEntry[], +): Map { + const requests = new Map(); + const responses = new Set(); + + for (const entry of entries) { + const method = entry.notification?.method; + const params = entry.notification?.params as + | Record + | undefined; + + if (method === "_array/permission_request" && params?.toolCallId) { + requests.set(params.toolCallId as string, entry); + } + if (method === "_array/permission_response" && params?.toolCallId) { + responses.add(params.toolCallId as string); + } + } + + // Return requests without matching response + const pending = new Map(); + for (const [toolCallId, entry] of requests) { + if (!responses.has(toolCallId)) { + const params = entry.notification?.params as Record; + pending.set(toolCallId, { + toolCallId, + title: (params.title as string) || "Permission Required", + options: (params.options as PermissionRequest["options"]) || [], + rawInput: params.rawInput, + receivedAt: entry.timestamp + ? new Date(entry.timestamp).getTime() + : Date.now(), + }); + } + } + + return pending; +} diff --git a/apps/array/src/renderer/features/task-detail/components/TaskInput.css b/apps/array/src/renderer/features/task-detail/components/TaskInput.css index 9b0c455e..1b84b4fb 100644 --- a/apps/array/src/renderer/features/task-detail/components/TaskInput.css +++ b/apps/array/src/renderer/features/task-detail/components/TaskInput.css @@ -16,3 +16,20 @@ .worktree-toggle-button[data-active="true"]:hover { background-color: var(--blue-a5); } + +.plan-mode-toggle-button { + color: var(--gray-11); +} + +.plan-mode-toggle-button:hover { + background-color: var(--gray-a4); +} + +.plan-mode-toggle-button[data-active="true"] { + background-color: var(--amber-a4); + color: var(--amber-11); +} + +.plan-mode-toggle-button[data-active="true"]:hover { + background-color: var(--amber-a5); +} diff --git a/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx index 56cfb5b6..963a2699 100644 --- a/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/array/src/renderer/features/task-detail/components/TaskInput.tsx @@ -37,6 +37,7 @@ export function TaskInput() { useState(lastUsedLocalWorkspaceMode); const [selectedBranch, setSelectedBranch] = useState(null); const [editorIsEmpty, setEditorIsEmpty] = useState(true); + const [isPlanMode, setIsPlanMode] = useState(false); const { githubIntegration } = useRepositoryIntegration(); @@ -53,7 +54,6 @@ export function TaskInput() { setSelectedDirectory(newPath); }; - // Compute the effective workspace mode for task creation const effectiveWorkspaceMode: WorkspaceMode = localWorkspaceMode; const { isCreatingTask, canSubmit, handleSubmit } = useTaskCreation({ @@ -63,6 +63,7 @@ export function TaskInput() { workspaceMode: effectiveWorkspaceMode, branch: selectedBranch, editorIsEmpty, + executionMode: isPlanMode ? "plan" : undefined, }); return ( @@ -143,6 +144,8 @@ export function TaskInput() { onSubmit={handleSubmit} hasDirectory={!!selectedDirectory} onEmptyChange={setEditorIsEmpty} + isPlanMode={isPlanMode} + onPlanModeChange={setIsPlanMode} /> diff --git a/apps/array/src/renderer/features/task-detail/components/TaskInputEditor.tsx b/apps/array/src/renderer/features/task-detail/components/TaskInputEditor.tsx index fd95261c..ff8c691d 100644 --- a/apps/array/src/renderer/features/task-detail/components/TaskInputEditor.tsx +++ b/apps/array/src/renderer/features/task-detail/components/TaskInputEditor.tsx @@ -3,7 +3,7 @@ import { EditorToolbar } from "@features/message-editor/components/EditorToolbar import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; import { useTiptapEditor } from "@features/message-editor/tiptap/useTiptapEditor"; import { FrameworkSelector } from "@features/sessions/components/FrameworkSelector"; -import { ArrowUp, GitBranchIcon } from "@phosphor-icons/react"; +import { ArrowUp, GitBranchIcon, Notepad } from "@phosphor-icons/react"; import { Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes"; import { EditorContent } from "@tiptap/react"; import { forwardRef, useImperativeHandle } from "react"; @@ -23,6 +23,8 @@ interface TaskInputEditorProps { onSubmit: () => void; hasDirectory: boolean; onEmptyChange?: (isEmpty: boolean) => void; + isPlanMode?: boolean; + onPlanModeChange?: (isPlanMode: boolean) => void; } export const TaskInputEditor = forwardRef< @@ -41,6 +43,8 @@ export const TaskInputEditor = forwardRef< onSubmit, hasDirectory, onEmptyChange, + isPlanMode = false, + onPlanModeChange, }, ref, ) => { @@ -184,6 +188,28 @@ export const TaskInputEditor = forwardRef< + {!isCloudMode && onPlanModeChange && ( + + { + e.stopPropagation(); + onPlanModeChange(!isPlanMode); + }} + className="plan-mode-toggle-button" + data-active={isPlanMode} + > + + + + )} {!isCloudMode && ( (RENDERER_TOKENS.TaskService); @@ -185,6 +190,7 @@ export function useTaskCreation({ workspaceMode, branch, autoRunTasks, + executionMode, invalidateTasks, navigateToTask, ]); diff --git a/apps/array/src/renderer/sagas/task/task-creation.ts b/apps/array/src/renderer/sagas/task/task-creation.ts index e3390a6f..9873524e 100644 --- a/apps/array/src/renderer/sagas/task/task-creation.ts +++ b/apps/array/src/renderer/sagas/task/task-creation.ts @@ -31,6 +31,8 @@ export interface TaskCreationInput { branch?: string | null; githubIntegrationId?: number; autoRun?: boolean; + // Execution mode: "plan" starts in plan mode (read-only), undefined starts in default mode + executionMode?: "plan"; } export interface TaskCreationOutput { @@ -200,6 +202,7 @@ export class TaskCreationSaga extends Saga< task, repoPath: agentCwd ?? "", initialPrompt, + executionMode: input.executionMode, }); } return { taskId: task.id }; diff --git a/apps/array/src/renderer/styles/globals.css b/apps/array/src/renderer/styles/globals.css index 20758971..18645649 100644 --- a/apps/array/src/renderer/styles/globals.css +++ b/apps/array/src/renderer/styles/globals.css @@ -115,6 +115,88 @@ body { -moz-osx-font-smoothing: grayscale; } +.plan-markdown { + font-size: 12px; + line-height: 1.6; +} + +.plan-markdown h1 { + font-size: 16px; + font-weight: 600; + margin: 0 0 10px; +} + +.plan-markdown h2 { + font-size: 14px; + font-weight: 600; + margin: 14px 0 8px; +} + +.plan-markdown h3 { + font-size: 13px; + font-weight: 600; + margin: 12px 0 6px; +} + +.plan-markdown p { + margin: 0 0 8px; +} + +.plan-markdown ul, +.plan-markdown ol { + margin: 0 0 10px 16px; + padding: 0; +} + +.plan-markdown li { + margin: 2px 0; +} + +.plan-markdown code { + font-family: + "JetBrains Mono", "Monaco", "Menlo", "Ubuntu Mono", "Courier New", monospace; + font-size: 11px; + background: rgba(0, 0, 0, 0.2); + padding: 0 4px; + border-radius: 4px; +} + +.plan-markdown pre { + background: rgba(0, 0, 0, 0.25); + padding: 8px 10px; + border-radius: 6px; + overflow-x: auto; + margin: 8px 0 12px; +} + +.plan-markdown pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: 11px; +} + +.plan-markdown table { + border-collapse: collapse; + margin: 8px 0 12px; + width: 100%; + font-size: 11px; +} + +.plan-markdown th, +.plan-markdown td { + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 4px 6px; + text-align: left; + vertical-align: top; +} + +.plan-markdown blockquote { + margin: 8px 0 12px; + padding-left: 10px; + border-left: 2px solid rgba(255, 255, 255, 0.3); +} + ::-webkit-scrollbar { width: 8px; height: 8px; diff --git a/apps/array/src/shared/types.ts b/apps/array/src/shared/types.ts index 7a16abe5..f2380aab 100644 --- a/apps/array/src/shared/types.ts +++ b/apps/array/src/shared/types.ts @@ -144,29 +144,6 @@ export interface MentionItem { urlId?: string; } -// Plan Mode types -export type ExecutionMode = "plan"; - -export type PlanModePhase = - | "idle" - | "research" - | "questions" - | "planning" - | "review"; - -export interface ClarifyingQuestion { - id: string; - question: string; - options: string[]; // ["a) option1", "b) option2", "c) something else"] - requiresInput: boolean; // true if option c or custom input needed -} - -export interface QuestionAnswer { - questionId: string; - selectedOption: string; - customInput?: string; -} - export interface TaskArtifact { name: string; path: string; diff --git a/packages/agent/index.ts b/packages/agent/index.ts index f5f39d5d..626d0e05 100644 --- a/packages/agent/index.ts +++ b/packages/agent/index.ts @@ -1,12 +1,9 @@ export type { - ArtifactNotificationPayload, BranchCreatedPayload, ConsoleNotificationPayload, ErrorNotificationPayload, - PhaseNotificationPayload, PostHogNotificationPayload, PostHogNotificationType, - PrCreatedPayload, RunStartedPayload, SdkSessionPayload, TaskCompletePayload, @@ -33,6 +30,7 @@ export type { TodoItem, TodoList } from "./src/todo-manager.js"; export { TodoManager } from "./src/todo-manager.js"; export { ToolRegistry } from "./src/tools/registry.js"; export type { + AskUserQuestionTool, BashOutputTool, BashTool, EditTool, @@ -58,7 +56,6 @@ export type { LogLevel as LogLevelType, McpServerConfig, OnLogCallback, - ResearchEvaluation, SessionNotification, StoredEntry, StoredNotification, diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts index 834719b7..3bbbeceb 100644 --- a/packages/agent/src/acp-extensions.ts +++ b/packages/agent/src/acp-extensions.ts @@ -13,16 +13,8 @@ * Used with AgentSideConnection.extNotification() or Client.extNotification() */ export const POSTHOG_NOTIFICATIONS = { - /** Artifact produced during task execution (research, plan, etc.) */ - ARTIFACT: "_posthog/artifact", - /** Phase has started (research, plan, build, etc.) */ - PHASE_START: "_posthog/phase_start", - /** Phase has completed */ - PHASE_COMPLETE: "_posthog/phase_complete", /** Git branch was created */ BRANCH_CREATED: "_posthog/branch_created", - /** Pull request was created */ - PR_CREATED: "_posthog/pr_created", /** Task run has started */ RUN_STARTED: "_posthog/run_started", /** Task has completed */ @@ -33,43 +25,15 @@ export const POSTHOG_NOTIFICATIONS = { CONSOLE: "_posthog/console", /** SDK session ID notification (for resumption) */ SDK_SESSION: "_posthog/sdk_session", - /** Sandbox execution output (stdout/stderr from Modal or Docker) */ - SANDBOX_OUTPUT: "_posthog/sandbox_output", } as const; export type PostHogNotificationType = (typeof POSTHOG_NOTIFICATIONS)[keyof typeof POSTHOG_NOTIFICATIONS]; -export interface ArtifactNotificationPayload { - sessionId: string; - kind: - | "research_evaluation" - | "research_questions" - | "plan" - | "pr_body" - | string; - content: unknown; -} - -export interface PhaseNotificationPayload { - sessionId: string; - phase: "research" | "plan" | "build" | "finalize" | string; - [key: string]: unknown; -} - export interface BranchCreatedPayload { - sessionId: string; branch: string; } -/** - * Payload for PR created notification - */ -export interface PrCreatedPayload { - sessionId: string; - prUrl: string; -} - export interface RunStartedPayload { sessionId: string; runId: string; @@ -107,24 +71,10 @@ export interface SdkSessionPayload { sdkSessionId: string; } -/** - * Sandbox execution output - */ -export interface SandboxOutputPayload { - sessionId: string; - stdout: string; - stderr: string; - exitCode: number; -} - export type PostHogNotificationPayload = - | ArtifactNotificationPayload - | PhaseNotificationPayload | BranchCreatedPayload - | PrCreatedPayload | RunStartedPayload | TaskCompletePayload | ErrorNotificationPayload | ConsoleNotificationPayload - | SdkSessionPayload - | SandboxOutputPayload; + | SdkSessionPayload; diff --git a/packages/agent/src/adapters/claude/claude.ts b/packages/agent/src/adapters/claude/claude.ts index 7739432b..6fa87375 100644 --- a/packages/agent/src/adapters/claude/claude.ts +++ b/packages/agent/src/adapters/claude/claude.ts @@ -74,10 +74,23 @@ import { Pushable, unreachable } from "./utils.js"; * tool definitions include input_examples which causes API errors. * See: https://github.com/anthropics/claude-code/issues/11678 */ +function getClaudeConfigDir(): string { + return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); +} + +function getClaudePlansDir(): string { + return path.join(getClaudeConfigDir(), "plans"); +} + +function isClaudePlanFilePath(filePath: string | undefined): boolean { + if (!filePath) return false; + const resolved = path.resolve(filePath); + const plansDir = path.resolve(getClaudePlansDir()); + return resolved === plansDir || resolved.startsWith(plansDir + path.sep); +} + function clearStatsigCache(): void { - const configDir = - process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); - const statsigPath = path.join(configDir, "statsig"); + const statsigPath = path.join(getClaudeConfigDir(), "statsig"); try { if (fs.existsSync(statsigPath)) { @@ -95,6 +108,8 @@ type Session = { permissionMode: PermissionMode; notificationHistory: SessionNotification[]; sdkSessionId?: string; + lastPlanFilePath?: string; + lastPlanContent?: string; }; type BackgroundTerminal = @@ -149,7 +164,7 @@ type ToolUseCache = { type: "tool_use" | "server_tool_use" | "mcp_tool_use"; id: string; name: string; - input: any; + input: unknown; }; }; @@ -194,6 +209,44 @@ export class ClaudeAcpAgent implements Agent { return session; } + private getLatestAssistantText( + notifications: SessionNotification[], + ): string | null { + const chunks: string[] = []; + let started = false; + + for (let i = notifications.length - 1; i >= 0; i -= 1) { + const update = notifications[i]?.update; + if (!update) continue; + + if (update.sessionUpdate === "agent_message_chunk") { + started = true; + const content = update.content as { + type?: string; + text?: string; + } | null; + if (content?.type === "text" && content.text) { + chunks.push(content.text); + } + continue; + } + + if (started) { + break; + } + } + + if (chunks.length === 0) return null; + return chunks.reverse().join(""); + } + + private isPlanReady(plan: string | undefined): boolean { + if (!plan) return false; + const trimmed = plan.trim(); + if (trimmed.length < 40) return false; + return /(^|\n)#{1,6}\s+\S/.test(trimmed); + } + appendNotification( sessionId: string, notification: SessionNotification, @@ -206,7 +259,7 @@ export class ClaudeAcpAgent implements Agent { this.clientCapabilities = request.clientCapabilities; // Default authMethod - const authMethod: any = { + const authMethod: { description: string; name: string; id: string } = { description: "Run `claude /login` in the terminal", name: "Log in with Claude Code", id: "claude-login", @@ -316,7 +369,12 @@ export class ClaudeAcpAgent implements Agent { } } - const permissionMode = "default"; + // Use initialModeId from _meta if provided (e.g., "plan" for plan mode), otherwise default + const initialModeId = ( + params._meta as { initialModeId?: string } | undefined + )?.initialModeId; + const ourPermissionMode = (initialModeId ?? "default") as PermissionMode; + const sdkPermissionMode: PermissionMode = ourPermissionMode; // Extract options from _meta if provided const userProvidedOptions = (params._meta as NewSessionMeta | undefined) @@ -334,7 +392,8 @@ export class ClaudeAcpAgent implements Agent { // If we want bypassPermissions to be an option, we have to allow it here. // But it doesn't work in root mode, so we only activate it if it will work. allowDangerouslySkipPermissions: !IS_ROOT, - permissionMode, + // Use the requested permission mode (including plan mode) + permissionMode: sdkPermissionMode, canUseTool: this.canUseTool(sessionId), // Use "node" to resolve via PATH where a symlink to Electron exists. // This avoids launching the Electron binary directly from the app bundle, @@ -404,6 +463,11 @@ export class ClaudeAcpAgent implements Agent { ); } + // ExitPlanMode should only be available during plan mode + if (ourPermissionMode !== "plan") { + disallowedTools.push("ExitPlanMode"); + } + if (allowedTools.length > 0) { options.allowedTools = allowedTools; } @@ -425,7 +489,7 @@ export class ClaudeAcpAgent implements Agent { options, }); - this.createSession(sessionId, q, input, permissionMode); + this.createSession(sessionId, q, input, ourPermissionMode); // Register for S3 persistence if config provided const persistence = params._meta?.persistence as @@ -495,7 +559,7 @@ export class ClaudeAcpAgent implements Agent { sessionId, models, modes: { - currentModeId: permissionMode, + currentModeId: ourPermissionMode, availableModes, }, }; @@ -512,7 +576,8 @@ export class ClaudeAcpAgent implements Agent { this.sessions[params.sessionId].cancelled = false; - const { query, input } = this.sessions[params.sessionId]; + const session = this.sessions[params.sessionId]; + const { query, input } = session; // Capture and store user message for replay for (const chunk of params.prompt) { @@ -527,7 +592,7 @@ export class ClaudeAcpAgent implements Agent { this.appendNotification(params.sessionId, userNotification); } - input.push(promptToClaude(params)); + input.push(promptToClaude({ ...params, prompt: params.prompt })); while (true) { const { value: message, done } = await query.next(); if (done || !message) { @@ -538,7 +603,7 @@ export class ClaudeAcpAgent implements Agent { } this.logger.debug("SDK message received", { type: message.type, - subtype: (message as any).subtype, + subtype: (message as { subtype?: string }).subtype, }); switch (message.type) { @@ -783,7 +848,89 @@ export class ClaudeAcpAgent implements Agent { }; } + // Helper to emit a tool denial notification so the UI shows the reason + const emitToolDenial = async (message: string) => { + this.logger.info(`[canUseTool] Tool denied: ${toolName}`, { message }); + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: toolUseID, + status: "failed", + content: [ + { + type: "content", + content: { + type: "text", + text: message, + }, + }, + ], + }, + }); + }; + if (toolName === "ExitPlanMode") { + // If we're already not in plan mode, just allow the tool without prompting + // This handles the case where mode was already changed by a previous ExitPlanMode call + // (Claude may call ExitPlanMode again after writing the plan file) + if (session.permissionMode !== "plan") { + return { + behavior: "allow", + updatedInput: toolInput, + }; + } + + let updatedInput = toolInput; + const planFromFile = + session.lastPlanContent || + (session.lastPlanFilePath + ? this.fileContentCache[session.lastPlanFilePath] + : undefined); + const hasPlan = + typeof (toolInput as { plan?: unknown } | undefined)?.plan === + "string"; + if (!hasPlan) { + const fallbackPlan = planFromFile + ? planFromFile + : this.getLatestAssistantText(session.notificationHistory); + if (fallbackPlan) { + updatedInput = { + ...(toolInput as Record), + plan: fallbackPlan, + }; + } + } + + const planText = + typeof (updatedInput as { plan?: unknown } | undefined)?.plan === + "string" + ? String((updatedInput as { plan?: unknown }).plan) + : undefined; + if (!planText) { + const message = `Plan not ready. Provide the full markdown plan in ExitPlanMode or write it to ${getClaudePlansDir()} before requesting approval.`; + await emitToolDenial(message); + return { + behavior: "deny", + message, + interrupt: false, + }; + } + if (!this.isPlanReady(planText)) { + const message = + "Plan not ready. Provide the full markdown plan in ExitPlanMode before requesting approval."; + await emitToolDenial(message); + return { + behavior: "deny", + message, + interrupt: false, + }; + } + + // ExitPlanMode is a signal to show the permission dialog + // The plan content should already be in the agent's text response + // Note: The SDK's ExitPlanMode tool includes a plan parameter, so ensure it is present + const response = await this.client.requestPermission({ options: [ { @@ -805,9 +952,9 @@ export class ClaudeAcpAgent implements Agent { sessionId, toolCall: { toolCallId: toolUseID, - rawInput: toolInput, + rawInput: { ...updatedInput, toolName }, title: toolInfoFromToolUse( - { name: toolName, input: toolInput }, + { name: toolName, input: updatedInput }, this.fileContentCache, this.logger, ).title, @@ -830,7 +977,7 @@ export class ClaudeAcpAgent implements Agent { return { behavior: "allow", - updatedInput: toolInput, + updatedInput, updatedPermissions: suggestions ?? [ { type: "setMode", @@ -839,15 +986,136 @@ export class ClaudeAcpAgent implements Agent { }, ], }; + } else { + // User chose "No, keep planning" - stay in plan mode and let agent continue + const message = + "User wants to continue planning. Please refine your plan based on any feedback provided, or ask clarifying questions if needed."; + await emitToolDenial(message); + return { + behavior: "deny", + message, + interrupt: false, + }; + } + } + + // AskUserQuestion always prompts user - never auto-approve + if (toolName === "AskUserQuestion") { + interface AskUserQuestionInput { + questions?: Array<{ + question: string; + options: Array<{ label: string; description?: string }>; + multiSelect?: boolean; + }>; + } + const questions = (toolInput as AskUserQuestionInput)?.questions || []; + const firstQuestion = questions[0]; + + if (!firstQuestion) { + return { + behavior: "deny", + message: "No question provided", + interrupt: true, + }; + } + + // Convert question options to permission options + const options = firstQuestion.options.map( + (opt: { label: string; description?: string }, idx: number) => ({ + kind: "allow_once" as const, + name: opt.label, + optionId: `option_${idx}`, + description: opt.description, + }), + ); + + // Add "Other" option if multiSelect is not enabled + if (!firstQuestion.multiSelect) { + options.push({ + kind: "allow_once" as const, + name: "Other", + optionId: "other", + description: "Provide a custom response", + }); + } + + const response = await this.client.requestPermission({ + options, + sessionId, + toolCall: { + toolCallId: toolUseID, + rawInput: { ...toolInput, toolName }, + title: firstQuestion.question, + }, + }); + + if (response.outcome?.outcome === "selected") { + const selectedOptionId = response.outcome.optionId; + const selectedIdx = parseInt( + selectedOptionId.replace("option_", ""), + 10, + ); + const selectedOption = firstQuestion.options[selectedIdx]; + + // Return the answer in updatedInput so it flows back to Claude + return { + behavior: "allow", + updatedInput: { + ...toolInput, + answers: { + [firstQuestion.question]: + selectedOption?.label || selectedOptionId, + }, + }, + }; } else { return { behavior: "deny", - message: "User rejected request to exit plan mode.", + message: "User did not answer the question", interrupt: true, }; } } + // In plan mode, deny write/edit tools except for Claude's plan files + // This includes both MCP-wrapped tools and built-in SDK tools + const WRITE_TOOL_NAMES = [ + ...EDIT_TOOL_NAMES, + "Edit", + "Write", + "Bash", + "NotebookEdit", + ]; + if ( + session.permissionMode === "plan" && + WRITE_TOOL_NAMES.includes(toolName) + ) { + // Allow writes to Claude Code's plan files + const filePath = (toolInput as { file_path?: string })?.file_path; + const isPlanFile = isClaudePlanFilePath(filePath); + + if (isPlanFile) { + session.lastPlanFilePath = filePath; + const content = (toolInput as { content?: string })?.content; + if (typeof content === "string") { + session.lastPlanContent = content; + } + return { + behavior: "allow", + updatedInput: toolInput, + }; + } + + const message = + "Cannot use write tools in plan mode. Use ExitPlanMode to request permission to make changes."; + await emitToolDenial(message); + return { + behavior: "deny", + message, + interrupt: false, + }; + } + if ( session.permissionMode === "bypassPermissions" || (session.permissionMode === "acceptEdits" && @@ -913,9 +1181,11 @@ export class ClaudeAcpAgent implements Agent { updatedInput: toolInput, }; } else { + const message = "User refused permission to run tool"; + await emitToolDenial(message); return { behavior: "deny", - message: "User refused permission to run tool", + message, interrupt: true, }; } @@ -1153,8 +1423,8 @@ function formatUriAsLink(uri: string): string { } export function promptToClaude(prompt: PromptRequest): SDKUserMessage { - const content: any[] = []; - const context: any[] = []; + const content: ContentBlockParam[] = []; + const context: ContentBlockParam[] = []; for (const chunk of prompt.prompt) { switch (chunk.type) { @@ -1199,7 +1469,11 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage { source: { type: "base64", data: chunk.data, - media_type: chunk.mimeType, + media_type: chunk.mimeType as + | "image/jpeg" + | "image/png" + | "image/gif" + | "image/webp", }, }); } else if (chunk.uri?.startsWith("http")) { diff --git a/packages/agent/src/adapters/claude/tools.ts b/packages/agent/src/adapters/claude/tools.ts index 03d7a416..cba169ff 100644 --- a/packages/agent/src/adapters/claude/tools.ts +++ b/packages/agent/src/adapters/claude/tools.ts @@ -38,24 +38,30 @@ interface ToolUpdate { locations?: ToolCallLocation[]; } +interface ToolUse { + name: string; + input?: unknown; +} + export function toolInfoFromToolUse( - toolUse: any, + toolUse: ToolUse, cachedFileContent: { [key: string]: string }, logger: Logger = new Logger({ debug: false, prefix: "[ClaudeTools]" }), ): ToolInfo { const name = toolUse.name; - const input = toolUse.input; + // Cast input to allow property access - each case handles its expected properties + const input = toolUse.input as Record | undefined; switch (name) { case "Task": return { - title: input?.description ? input.description : "Task", + title: input?.description ? String(input.description) : "Task", kind: "think", content: input?.prompt ? [ { type: "content", - content: { type: "text", text: input.prompt }, + content: { type: "text", text: String(input.prompt) }, }, ] : [], @@ -64,42 +70,46 @@ export function toolInfoFromToolUse( case "NotebookRead": return { title: input?.notebook_path - ? `Read Notebook ${input.notebook_path}` + ? `Read Notebook ${String(input.notebook_path)}` : "Read Notebook", kind: "read", content: [], - locations: input?.notebook_path ? [{ path: input.notebook_path }] : [], + locations: input?.notebook_path + ? [{ path: String(input.notebook_path) }] + : [], }; case "NotebookEdit": return { title: input?.notebook_path - ? `Edit Notebook ${input.notebook_path}` + ? `Edit Notebook ${String(input.notebook_path)}` : "Edit Notebook", kind: "edit", content: input?.new_source ? [ { type: "content", - content: { type: "text", text: input.new_source }, + content: { type: "text", text: String(input.new_source) }, }, ] : [], - locations: input?.notebook_path ? [{ path: input.notebook_path }] : [], + locations: input?.notebook_path + ? [{ path: String(input.notebook_path) }] + : [], }; case "Bash": case toolNames.bash: return { title: input?.command - ? `\`${input.command.replaceAll("`", "\\`")}\`` + ? `\`${String(input.command).replaceAll("`", "\\`")}\`` : "Terminal", kind: "execute", content: input?.description ? [ { type: "content", - content: { type: "text", text: input.description }, + content: { type: "text", text: String(input.description) }, }, ] : [], @@ -123,24 +133,21 @@ export function toolInfoFromToolUse( case toolNames.read: { let limit = ""; - if (input.limit) { - limit = - " (" + - ((input.offset ?? 0) + 1) + - " - " + - ((input.offset ?? 0) + input.limit) + - ")"; - } else if (input.offset) { - limit = ` (from line ${input.offset + 1})`; + const inputLimit = input?.limit as number | undefined; + const inputOffset = (input?.offset as number | undefined) ?? 0; + if (inputLimit) { + limit = ` (${inputOffset + 1} - ${inputOffset + inputLimit})`; + } else if (inputOffset) { + limit = ` (from line ${inputOffset + 1})`; } return { - title: `Read ${input.file_path ?? "File"}${limit}`, + title: `Read ${input?.file_path ? String(input.file_path) : "File"}${limit}`, kind: "read", - locations: input.file_path + locations: input?.file_path ? [ { - path: input.file_path, - line: input.offset ?? 0, + path: String(input.file_path), + line: inputOffset, }, ] : [], @@ -153,11 +160,11 @@ export function toolInfoFromToolUse( title: "Read File", kind: "read", content: [], - locations: input.file_path + locations: input?.file_path ? [ { - path: input.file_path, - line: input.offset ?? 0, + path: String(input.file_path), + line: (input?.offset as number | undefined) ?? 0, }, ] : [], @@ -165,7 +172,7 @@ export function toolInfoFromToolUse( case "LS": return { - title: `List the ${input?.path ? `\`${input.path}\`` : "current"} directory's contents`, + title: `List the ${input?.path ? `\`${String(input.path)}\`` : "current"} directory's contents`, kind: "search", content: [], locations: [], @@ -173,9 +180,9 @@ export function toolInfoFromToolUse( case toolNames.edit: case "Edit": { - const path = input?.file_path ?? input?.file_path; - let oldText = input.old_string ?? null; - let newText = input.new_string ?? ""; + const path = input?.file_path ? String(input.file_path) : undefined; + let oldText = input?.old_string ? String(input.old_string) : null; + let newText = input?.new_string ? String(input.new_string) : ""; let affectedLines: number[] = []; if (path && oldText) { @@ -218,86 +225,92 @@ export function toolInfoFromToolUse( } case toolNames.write: { - let content: ToolCallContent[] = []; - if (input?.file_path) { - content = [ + let contentResult: ToolCallContent[] = []; + const filePath = input?.file_path ? String(input.file_path) : undefined; + const contentStr = input?.content ? String(input.content) : undefined; + if (filePath) { + contentResult = [ { type: "diff", - path: input.file_path, + path: filePath, oldText: null, - newText: input.content, + newText: contentStr ?? "", }, ]; - } else if (input?.content) { - content = [ + } else if (contentStr) { + contentResult = [ { type: "content", - content: { type: "text", text: input.content }, + content: { type: "text", text: contentStr }, }, ]; } return { - title: input?.file_path ? `Write ${input.file_path}` : "Write", + title: filePath ? `Write ${filePath}` : "Write", kind: "edit", - content, - locations: input?.file_path ? [{ path: input.file_path }] : [], + content: contentResult, + locations: filePath ? [{ path: filePath }] : [], }; } - case "Write": + case "Write": { + const filePath = input?.file_path ? String(input.file_path) : undefined; + const contentStr = input?.content ? String(input.content) : ""; return { - title: input?.file_path ? `Write ${input.file_path}` : "Write", + title: filePath ? `Write ${filePath}` : "Write", kind: "edit", - content: input?.file_path + content: filePath ? [ { type: "diff", - path: input.file_path, + path: filePath, oldText: null, - newText: input.content, + newText: contentStr, }, ] : [], - locations: input?.file_path ? [{ path: input.file_path }] : [], + locations: filePath ? [{ path: filePath }] : [], }; + } case "Glob": { let label = "Find"; - if (input.path) { - label += ` \`${input.path}\``; + const pathStr = input?.path ? String(input.path) : undefined; + if (pathStr) { + label += ` \`${pathStr}\``; } - if (input.pattern) { - label += ` \`${input.pattern}\``; + if (input?.pattern) { + label += ` \`${String(input.pattern)}\``; } return { title: label, kind: "search", content: [], - locations: input.path ? [{ path: input.path }] : [], + locations: pathStr ? [{ path: pathStr }] : [], }; } case "Grep": { let label = "grep"; - if (input["-i"]) { + if (input?.["-i"]) { label += " -i"; } - if (input["-n"]) { + if (input?.["-n"]) { label += " -n"; } - if (input["-A"] !== undefined) { + if (input?.["-A"] !== undefined) { label += ` -A ${input["-A"]}`; } - if (input["-B"] !== undefined) { + if (input?.["-B"] !== undefined) { label += ` -B ${input["-B"]}`; } - if (input["-C"] !== undefined) { + if (input?.["-C"] !== undefined) { label += ` -C ${input["-C"]}`; } - if (input.output_mode) { + if (input?.output_mode) { switch (input.output_mode) { case "FilesWithMatches": label += " -l"; @@ -310,26 +323,26 @@ export function toolInfoFromToolUse( } } - if (input.head_limit !== undefined) { + if (input?.head_limit !== undefined) { label += ` | head -${input.head_limit}`; } - if (input.glob) { - label += ` --include="${input.glob}"`; + if (input?.glob) { + label += ` --include="${String(input.glob)}"`; } - if (input.type) { - label += ` --type=${input.type}`; + if (input?.type) { + label += ` --type=${String(input.type)}`; } - if (input.multiline) { + if (input?.multiline) { label += " -P"; } - label += ` "${input.pattern}"`; + label += ` "${input?.pattern ? String(input.pattern) : ""}"`; - if (input.path) { - label += ` ${input.path}`; + if (input?.path) { + label += ` ${String(input.path)}`; } return { @@ -341,27 +354,29 @@ export function toolInfoFromToolUse( case "WebFetch": return { - title: input?.url ? `Fetch ${input.url}` : "Fetch", + title: input?.url ? `Fetch ${String(input.url)}` : "Fetch", kind: "fetch", content: input?.prompt ? [ { type: "content", - content: { type: "text", text: input.prompt }, + content: { type: "text", text: String(input.prompt) }, }, ] : [], }; case "WebSearch": { - let label = `"${input.query}"`; + let label = `"${input?.query ? String(input.query) : ""}"`; + const allowedDomains = input?.allowed_domains as string[] | undefined; + const blockedDomains = input?.blocked_domains as string[] | undefined; - if (input.allowed_domains && input.allowed_domains.length > 0) { - label += ` (allowed: ${input.allowed_domains.join(", ")})`; + if (allowedDomains && allowedDomains.length > 0) { + label += ` (allowed: ${allowedDomains.join(", ")})`; } - if (input.blocked_domains && input.blocked_domains.length > 0) { - label += ` (blocked: ${input.blocked_domains.join(", ")})`; + if (blockedDomains && blockedDomains.length > 0) { + label += ` (blocked: ${blockedDomains.join(", ")})`; } return { @@ -374,7 +389,7 @@ export function toolInfoFromToolUse( case "TodoWrite": return { title: Array.isArray(input?.todos) - ? `Update TODOs: ${input.todos.map((todo: any) => todo.content).join(", ")}` + ? `Update TODOs: ${input.todos.map((todo: { content?: string }) => todo.content).join(", ")}` : "Update TODOs", kind: "think", content: [], @@ -385,9 +400,35 @@ export function toolInfoFromToolUse( title: "Ready to code?", kind: "switch_mode", content: input?.plan - ? [{ type: "content", content: { type: "text", text: input.plan } }] + ? [ + { + type: "content", + content: { type: "text", text: String(input.plan) }, + }, + ] + : [], + }; + + case "AskUserQuestion": { + const questions = input?.questions as + | Array<{ question?: string }> + | undefined; + return { + title: questions?.[0]?.question || "Question", + kind: "ask" as ToolKind, + content: questions + ? [ + { + type: "content", + content: { + type: "text", + text: JSON.stringify(questions, null, 2), + }, + }, + ] : [], }; + } case "Other": { let output: string; @@ -431,25 +472,32 @@ export function toolUpdateFromToolResult( | BetaTextEditorCodeExecutionToolResultBlockParam | BetaRequestMCPToolResultBlockParam | BetaToolSearchToolResultBlockParam, - toolUse: any | undefined, + toolUse: ToolUse | undefined, ): ToolUpdate { switch (toolUse?.name) { case "Read": case toolNames.read: if (Array.isArray(toolResult.content) && toolResult.content.length > 0) { return { - content: toolResult.content.map((content: any) => ({ - type: "content", - content: - content.type === "text" - ? { - type: "text", - text: markdownEscape( - content.text.replace(SYSTEM_REMINDER, ""), - ), - } - : content, - })), + content: toolResult.content.map((item) => { + const itemObj = item as { type?: string; text?: string }; + if (itemObj.type === "text") { + return { + type: "content" as const, + content: { + type: "text" as const, + text: markdownEscape( + (itemObj.text ?? "").replace(SYSTEM_REMINDER, ""), + ), + }, + }; + } + // For non-text content, return as-is with proper typing + return { + type: "content" as const, + content: item as { type: "text"; text: string }, + }; + }), }; } else if ( typeof toolResult.content === "string" && @@ -492,6 +540,29 @@ export function toolUpdateFromToolResult( case "ExitPlanMode": { return { title: "Exited Plan Mode" }; } + case "AskUserQuestion": { + // The answer is returned in the tool result + const content = toolResult.content; + if (Array.isArray(content) && content.length > 0) { + const firstItem = content[0]; + if ( + typeof firstItem === "object" && + firstItem !== null && + "text" in firstItem + ) { + return { + title: "Answer received", + content: [ + { + type: "content", + content: { type: "text", text: String(firstItem.text) }, + }, + ], + }; + } + } + return { title: "Question answered" }; + } default: { return toAcpContentUpdate( toolResult.content, @@ -502,21 +573,27 @@ export function toolUpdateFromToolResult( } function toAcpContentUpdate( - content: any, + content: unknown, isError: boolean = false, ): { content?: ToolCallContent[] } { if (Array.isArray(content) && content.length > 0) { return { - content: content.map((content: any) => ({ - type: "content", - content: - isError && content.type === "text" - ? { - ...content, - text: `\`\`\`\n${content.text}\n\`\`\``, - } - : content, - })), + content: content.map((item) => { + const itemObj = item as { type?: string; text?: string }; + if (isError && itemObj.type === "text") { + return { + type: "content" as const, + content: { + type: "text" as const, + text: `\`\`\`\n${itemObj.text ?? ""}\n\`\`\``, + }, + }; + } + return { + type: "content" as const, + content: item as { type: "text"; text: string }, + }; + }), }; } else if (typeof content === "string" && content.length > 0) { return { diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index a10cb3a1..31b1acbf 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -13,10 +13,8 @@ import { import { PostHogFileManager } from "./file-manager.js"; import { GitManager } from "./git-manager.js"; import { PostHogAPIClient } from "./posthog-api.js"; -import { PromptBuilder } from "./prompt-builder.js"; import { SessionStore } from "./session-store.js"; import { TaskManager } from "./task-manager.js"; -import { TemplateManager } from "./template-manager.js"; import type { AgentConfig, CanUseTool, @@ -25,8 +23,14 @@ import type { TaskExecutionOptions, } from "./types.js"; import { Logger } from "./utils/logger.js"; -import { TASK_WORKFLOW } from "./workflow/config.js"; -import type { SendNotification, WorkflowRuntime } from "./workflow/types.js"; + +/** + * Type for sending ACP notifications + */ +type SendNotification = ( + method: string, + params: Record, +) => Promise; export class Agent { private workingDirectory: string; @@ -34,10 +38,8 @@ export class Agent { private posthogAPI?: PostHogAPIClient; private fileManager: PostHogFileManager; private gitManager: GitManager; - private templateManager: TemplateManager; private logger: Logger; private acpConnection?: InProcessAcpConnection; - private promptBuilder: PromptBuilder; private mcpServers?: Record; private canUseTool?: CanUseTool; private currentRunId?: string; @@ -89,7 +91,6 @@ export class Agent { repositoryPath: this.workingDirectory, logger: this.logger.child("GitManager"), }); - this.templateManager = new TemplateManager(); if ( config.posthogApiUrl && @@ -108,13 +109,6 @@ export class Agent { this.logger.child("SessionStore"), ); } - - this.promptBuilder = new PromptBuilder({ - getTaskFiles: (taskId: string) => this.getTaskFiles(taskId), - generatePlanTemplate: (vars) => this.templateManager.generatePlan(vars), - posthogClient: this.posthogAPI, - logger: this.logger.child("PromptBuilder"), - }); } /** @@ -146,107 +140,18 @@ export class Agent { } } - private getOrCreateConnection(): InProcessAcpConnection { - if (!this.acpConnection) { - this.acpConnection = createAcpConnection({ - sessionStore: this.sessionStore, - }); - } - return this.acpConnection; - } - - // Adaptive task execution orchestrated via workflow steps + /** + * @deprecated Use runTaskV2() for local execution or runTaskCloud() for cloud execution. + * This method used the old workflow system which has been removed. + */ async runTask( - taskId: string, - taskRunId: string, - options: import("./types.js").TaskExecutionOptions = {}, + _taskId: string, + _taskRunId: string, + _options: import("./types.js").TaskExecutionOptions = {}, ): Promise { - // await this._configureLlmGateway(); - - const task = await this.fetchTask(taskId); - const cwd = options.repositoryPath || this.workingDirectory; - const isCloudMode = options.isCloudMode ?? false; - const taskSlug = (task as any).slug || task.id; - - // Use taskRunId as sessionId - they are the same identifier - this.currentRunId = taskRunId; - - this.logger.info("Starting adaptive task execution", { - taskId: task.id, - taskSlug, - taskRunId, - isCloudMode, - }); - - const connection = this.getOrCreateConnection(); - - // Create sendNotification using ACP connection's extNotification - const sendNotification: SendNotification = async (method, params) => { - this.logger.debug(`Notification: ${method}`, params); - await connection.agentConnection.extNotification?.(method, params); - }; - - await sendNotification(POSTHOG_NOTIFICATIONS.RUN_STARTED, { - sessionId: taskRunId, - runId: taskRunId, - }); - - await this.prepareTaskBranch(taskSlug, isCloudMode, sendNotification); - - let taskError: Error | undefined; - try { - const workflowContext: WorkflowRuntime = { - task, - taskSlug, - runId: taskRunId, - cwd, - isCloudMode, - options, - logger: this.logger, - fileManager: this.fileManager, - gitManager: this.gitManager, - promptBuilder: this.promptBuilder, - connection: connection.agentConnection, - sessionId: taskRunId, - sendNotification, - mcpServers: this.mcpServers, - posthogAPI: this.posthogAPI, - stepResults: {}, - }; - - for (const step of TASK_WORKFLOW) { - const result = await step.run({ step, context: workflowContext }); - if (result.halt) { - return; - } - } - - const shouldCreatePR = options.createPR ?? isCloudMode; - if (shouldCreatePR) { - await this.ensurePullRequest( - task, - workflowContext.stepResults, - sendNotification, - ); - } - - this.logger.info("Task execution complete", { taskId: task.id }); - await sendNotification(POSTHOG_NOTIFICATIONS.TASK_COMPLETE, { - sessionId: taskRunId, - taskId: task.id, - }); - } catch (error) { - taskError = error instanceof Error ? error : new Error(String(error)); - this.logger.error("Task execution failed", { - taskId: task.id, - error: taskError.message, - }); - await sendNotification(POSTHOG_NOTIFICATIONS.ERROR, { - sessionId: taskRunId, - message: taskError.message, - }); - throw taskError; - } + throw new Error( + "runTask() is deprecated. Use runTaskV2() for local execution or runTaskCloud() for cloud execution.", + ); } /** @@ -755,61 +660,6 @@ This PR implements the changes described in the task.`; throw error; } } - - private async ensurePullRequest( - task: Task, - stepResults: Record, - sendNotification: SendNotification, - ): Promise { - const latestRun = task.latest_run; - const existingPr = - latestRun?.output && typeof latestRun.output === "object" - ? (latestRun.output as any).pr_url - : null; - - if (existingPr) { - this.logger.info("PR already exists, skipping creation", { - taskId: task.id, - prUrl: existingPr, - }); - return; - } - - const buildResult = stepResults.build; - if (!buildResult?.commitCreated) { - this.logger.warn( - "Build step did not produce a commit; skipping PR creation", - { taskId: task.id }, - ); - return; - } - - const branchName = await this.gitManager.getCurrentBranch(); - const finalizeResult = stepResults.finalize; - const prBody = finalizeResult?.prBody; - - const prUrl = await this.createPullRequest( - task.id, - branchName, - task.title, - task.description ?? "", - prBody, - ); - - await sendNotification(POSTHOG_NOTIFICATIONS.PR_CREATED, { prUrl }); - - try { - await this.attachPullRequestToTask(task.id, prUrl, branchName); - this.logger.info("PR attached to task successfully", { - taskId: task.id, - prUrl, - }); - } catch (error) { - this.logger.warn("Could not attach PR to task", { - error: error instanceof Error ? error.message : String(error), - }); - } - } } export type { diff --git a/packages/agent/src/file-manager.ts b/packages/agent/src/file-manager.ts index 7060bd1c..59f93860 100644 --- a/packages/agent/src/file-manager.ts +++ b/packages/agent/src/file-manager.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import { extname, join } from "node:path"; import z from "zod"; -import type { ResearchEvaluation, SupportingFile } from "./types.js"; +import type { SupportingFile } from "./types.js"; import { Logger } from "./utils/logger.js"; export interface TaskFile { @@ -162,39 +162,6 @@ export class PostHogFileManager { return await this.readTaskFile(taskId, "requirements.md"); } - async writeResearch(taskId: string, data: ResearchEvaluation): Promise { - this.logger.debug("Writing research", { - taskId, - score: data.actionabilityScore, - hasQuestions: !!data.questions, - questionCount: data.questions?.length ?? 0, - answered: data.answered ?? false, - }); - - await this.writeTaskFile(taskId, { - name: "research.json", - content: JSON.stringify(data, null, 2), - type: "artifact", - }); - - this.logger.info("Research file written", { - taskId, - score: data.actionabilityScore, - hasQuestions: !!data.questions, - answered: data.answered ?? false, - }); - } - - async readResearch(taskId: string): Promise { - try { - const content = await this.readTaskFile(taskId, "research.json"); - return content ? (JSON.parse(content) as ResearchEvaluation) : null; - } catch (error) { - this.logger.debug("Failed to parse research.json", { error }); - return null; - } - } - async writeTodos(taskId: string, data: unknown): Promise { const todos = z.object({ metadata: z.object({ diff --git a/packages/agent/src/tools/registry.ts b/packages/agent/src/tools/registry.ts index 7fd1b6e1..1031a761 100644 --- a/packages/agent/src/tools/registry.ts +++ b/packages/agent/src/tools/registry.ts @@ -84,6 +84,11 @@ const TOOL_DEFINITIONS: Record = { category: "assistant", description: "Exit plan mode and present plan to user", }, + AskUserQuestion: { + name: "AskUserQuestion", + category: "assistant", + description: "Ask the user a clarifying question with options", + }, SlashCommand: { name: "SlashCommand", category: "assistant", diff --git a/packages/agent/src/tools/types.ts b/packages/agent/src/tools/types.ts index 23972bf0..fea0e6e1 100644 --- a/packages/agent/src/tools/types.ts +++ b/packages/agent/src/tools/types.ts @@ -100,6 +100,11 @@ export interface ExitPlanModeTool extends Tool { category: "assistant"; } +export interface AskUserQuestionTool extends Tool { + name: "AskUserQuestion"; + category: "assistant"; +} + export interface SlashCommandTool extends Tool { name: "SlashCommand"; category: "assistant"; @@ -124,4 +129,5 @@ export type KnownTool = | TaskTool | TodoWriteTool | ExitPlanModeTool + | AskUserQuestionTool | SlashCommandTool; diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index e676a53d..e6d10894 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -250,29 +250,6 @@ export interface UrlMention { label?: string; } -// Research evaluation types -export interface ResearchQuestion { - id: string; - question: string; - options: string[]; -} - -export interface ResearchAnswer { - questionId: string; - selectedOption: string; - customInput?: string; -} - -export interface ResearchEvaluation { - actionabilityScore: number; // 0-1 confidence score - context: string; // brief summary for planning - keyFiles: string[]; // files needing modification - blockers?: string[]; // what's preventing full confidence - questions?: ResearchQuestion[]; // only if score < 0.7 - answered?: boolean; // whether questions have been answered - answers?: ResearchAnswer[]; // user's answers to questions -} - // Worktree types for parallel task development export interface WorktreeInfo { worktreePath: string; diff --git a/packages/agent/src/workflow/config.ts b/packages/agent/src/workflow/config.ts deleted file mode 100644 index e7140fba..00000000 --- a/packages/agent/src/workflow/config.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { buildStep } from "./steps/build.js"; -import { finalizeStep } from "./steps/finalize.js"; -import { planStep } from "./steps/plan.js"; -import { researchStep } from "./steps/research.js"; -import type { WorkflowDefinition } from "./types.js"; - -const MODELS = { - SONNET: "claude-sonnet-4-5", - HAIKU: "claude-haiku-4-5", -}; - -export const TASK_WORKFLOW: WorkflowDefinition = [ - { - id: "research", - name: "Research", - agent: "research", - model: MODELS.HAIKU, - permissionMode: "plan", - commit: true, - push: true, - run: researchStep, - }, - { - id: "plan", - name: "Plan", - agent: "planning", - model: MODELS.SONNET, - permissionMode: "plan", - commit: true, - push: true, - run: planStep, - }, - { - id: "build", - name: "Build", - agent: "execution", - model: MODELS.SONNET, - permissionMode: "acceptEdits", - commit: true, - push: true, - run: buildStep, - }, - { - id: "finalize", - name: "Finalize", - agent: "system", // not used - model: MODELS.HAIKU, // not used - permissionMode: "plan", // not used - commit: true, - push: true, - run: finalizeStep, - }, -]; diff --git a/packages/agent/src/workflow/steps/build.ts b/packages/agent/src/workflow/steps/build.ts deleted file mode 100644 index f6bc1c18..00000000 --- a/packages/agent/src/workflow/steps/build.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { query } from "@anthropic-ai/claude-agent-sdk"; -import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions.js"; -import { EXECUTION_SYSTEM_PROMPT } from "../../agents/execution.js"; -import { TodoManager } from "../../todo-manager.js"; -import { PermissionMode } from "../../types.js"; -import type { WorkflowStepRunner } from "../types.js"; - -export const buildStep: WorkflowStepRunner = async ({ step, context }) => { - const { - task, - cwd, - options, - logger, - promptBuilder, - sessionId, - mcpServers, - gitManager, - sendNotification, - } = context; - - const stepLogger = logger.child("BuildStep"); - - const latestRun = task.latest_run; - const prExists = - latestRun?.output && typeof latestRun.output === "object" - ? (latestRun.output as Record).pr_url - : null; - - if (prExists) { - stepLogger.info("PR already exists, skipping build phase", { - taskId: task.id, - }); - return { status: "skipped" }; - } - - stepLogger.info("Starting build phase", { taskId: task.id }); - await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_START, { - sessionId, - phase: "build", - }); - - const executionPrompt = await promptBuilder.buildExecutionPrompt(task, cwd); - const fullPrompt = `${EXECUTION_SYSTEM_PROMPT}\n\n${executionPrompt}`; - - const configuredPermissionMode = - options.permissionMode ?? - (typeof step.permissionMode === "string" - ? (step.permissionMode as PermissionMode) - : step.permissionMode) ?? - PermissionMode.ACCEPT_EDITS; - - const baseOptions: Record = { - model: step.model, - cwd, - permissionMode: configuredPermissionMode, - settingSources: ["local"], - mcpServers, - // Allow all tools for build phase - full read/write access needed for implementation - allowedTools: [ - "Task", - "Bash", - "BashOutput", - "KillBash", - "Edit", - "Read", - "Write", - "Glob", - "Grep", - "NotebookEdit", - "WebFetch", - "WebSearch", - "ListMcpResources", - "ReadMcpResource", - "TodoWrite", - ], - }; - - // Add fine-grained permission hook if provided - if (options.canUseTool) { - baseOptions.canUseTool = options.canUseTool; - } - - const response = query({ - prompt: fullPrompt, - options: { ...baseOptions, ...(options.queryOverrides || {}) }, - }); - - // Track commits made during Claude Code execution - const commitTracker = await gitManager.trackCommitsDuring(); - - // Track todos from TodoWrite tool calls - const todoManager = new TodoManager(context.fileManager, stepLogger); - - try { - for await (const message of response) { - const todoList = await todoManager.checkAndPersistFromMessage( - message, - task.id, - ); - if (todoList) { - await sendNotification(POSTHOG_NOTIFICATIONS.ARTIFACT, { - sessionId, - kind: "todos", - content: todoList, - }); - } - } - } catch (error) { - stepLogger.error("Error during build step query", error); - throw error; - } - - // Finalize: commit any remaining changes and optionally push - const { commitCreated, pushedBranch } = await commitTracker.finalize({ - commitMessage: `Implementation for ${task.title}`, - push: step.push, - }); - - context.stepResults[step.id] = { commitCreated }; - - if (!commitCreated) { - stepLogger.warn("No changes to commit in build phase", { taskId: task.id }); - } else { - stepLogger.info("Build commits finalized", { - taskId: task.id, - pushedBranch, - }); - } - - await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { - sessionId, - phase: "build", - }); - return { status: "completed" }; -}; diff --git a/packages/agent/src/workflow/steps/finalize.ts b/packages/agent/src/workflow/steps/finalize.ts deleted file mode 100644 index 7787dd91..00000000 --- a/packages/agent/src/workflow/steps/finalize.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { LocalArtifact } from "../../file-manager.js"; -import type { Task, TaskRunArtifact } from "../../types.js"; -import type { WorkflowStepRunner } from "../types.js"; -import { finalizeStepGitActions } from "../utils.js"; - -const MAX_SNIPPET_LENGTH = 1200; - -export const finalizeStep: WorkflowStepRunner = async ({ step, context }) => { - const { task, logger, fileManager, gitManager, posthogAPI, runId } = context; - - const stepLogger = logger.child("FinalizeStep"); - const artifacts = await fileManager.collectTaskArtifacts(task.id); - let uploadedArtifacts: TaskRunArtifact[] | undefined; - - if (artifacts.length && posthogAPI && runId) { - try { - const payload = artifacts.map((artifact) => ({ - name: artifact.name, - type: artifact.type, - content: artifact.content, - content_type: artifact.contentType, - })); - uploadedArtifacts = await posthogAPI.uploadTaskArtifacts( - task.id, - runId, - payload, - ); - stepLogger.info("Uploaded task artifacts to PostHog", { - taskId: task.id, - uploadedCount: uploadedArtifacts.length, - }); - } catch (error) { - stepLogger.warn("Failed to upload task artifacts", { - taskId: task.id, - error: error instanceof Error ? error.message : String(error), - }); - } - } else { - stepLogger.debug("Skipping artifact upload", { - hasArtifacts: artifacts.length > 0, - hasPostHogApi: Boolean(posthogAPI), - runId, - }); - } - - const prBody = buildPullRequestBody(task, artifacts, uploadedArtifacts); - await fileManager.cleanupTaskDirectory(task.id); - await gitManager.addAllPostHogFiles(); - - // Commit the deletion of artifacts - await finalizeStepGitActions(context, step, { - commitMessage: `Cleanup task artifacts for ${task.title}`, - allowEmptyCommit: true, - }); - - context.stepResults[step.id] = { - prBody, - uploadedArtifacts, - artifactCount: artifacts.length, - }; - - return { status: "completed" }; -}; - -function buildPullRequestBody( - task: Task, - artifacts: LocalArtifact[], - uploaded?: TaskRunArtifact[], -): string { - const lines: string[] = []; - const taskSlug = (task as unknown as Record).slug || task.id; - - lines.push("## Task context"); - lines.push(`- **Task**: ${taskSlug}`); - lines.push(`- **Title**: ${task.title}`); - lines.push(`- **Origin**: ${task.origin_product}`); - - if (task.description) { - lines.push(""); - lines.push(`> ${task.description.trim().split("\n").join("\n> ")}`); - } - - const usedFiles = new Set(); - - const contextArtifact = artifacts.find( - (artifact) => artifact.name === "context.md", - ); - if (contextArtifact) { - lines.push(""); - lines.push("### Task prompt"); - lines.push(contextArtifact.content); - usedFiles.add(contextArtifact.name); - } - - const researchArtifact = artifacts.find( - (artifact) => artifact.name === "research.json", - ); - if (researchArtifact) { - usedFiles.add(researchArtifact.name); - const researchSection = formatResearchSection(researchArtifact.content); - if (researchSection) { - lines.push(""); - lines.push(researchSection); - } - } - - const planArtifact = artifacts.find( - (artifact) => artifact.name === "plan.md", - ); - if (planArtifact) { - lines.push(""); - lines.push("### Implementation plan"); - lines.push(planArtifact.content); - usedFiles.add(planArtifact.name); - } - - const todoArtifact = artifacts.find( - (artifact) => artifact.name === "todos.json", - ); - if (todoArtifact) { - const summary = summarizeTodos(todoArtifact.content); - if (summary) { - lines.push(""); - lines.push("### Todo list"); - lines.push(summary); - } - usedFiles.add(todoArtifact.name); - } - - const remainingArtifacts = artifacts.filter( - (artifact) => !usedFiles.has(artifact.name), - ); - if (remainingArtifacts.length) { - lines.push(""); - lines.push("### Additional artifacts"); - for (const artifact of remainingArtifacts) { - lines.push(`#### ${artifact.name}`); - lines.push(renderCodeFence(artifact.content)); - } - } - - const artifactList = - uploaded ?? - artifacts.map((artifact) => ({ - name: artifact.name, - type: artifact.type, - })); - - if (artifactList.length) { - lines.push(""); - lines.push("### Uploaded artifacts"); - for (const artifact of artifactList) { - const rawStoragePath = - "storage_path" in artifact - ? (artifact as Record).storage_path - : undefined; - const storagePath = - typeof rawStoragePath === "string" ? rawStoragePath : undefined; - const storage = - storagePath && storagePath.trim().length > 0 - ? ` – \`${storagePath.trim()}\`` - : ""; - lines.push(`- ${artifact.name} (${artifact.type})${storage}`); - } - } - - return lines.join("\n\n"); -} - -function renderCodeFence(content: string): string { - const snippet = truncate(content, MAX_SNIPPET_LENGTH); - return ["```", snippet, "```"].join("\n"); -} - -function truncate(value: string, maxLength: number): string { - if (value.length <= maxLength) { - return value; - } - return `${value.slice(0, maxLength)}\n…`; -} - -function formatResearchSection(content: string): string | null { - try { - const parsed = JSON.parse(content); - const sections: string[] = []; - - if (parsed.context) { - sections.push("### Research summary"); - sections.push(parsed.context); - } - - if (parsed.questions?.length) { - sections.push(""); - sections.push("### Questions needing answers"); - for (const question of parsed.questions) { - sections.push(`- ${question.question ?? question}`); - } - } - - if (parsed.answers?.length) { - sections.push(""); - sections.push("### Answers provided"); - for (const answer of parsed.answers) { - const questionId = answer.questionId - ? ` (Q: ${answer.questionId})` - : ""; - sections.push( - `- ${answer.selectedOption || answer.customInput || "answer"}${questionId}`, - ); - } - } - - return sections.length ? sections.join("\n") : null; - } catch { - return null; - } -} - -function summarizeTodos(content: string): string | null { - try { - const data = JSON.parse(content); - const total = data?.metadata?.total ?? data?.items?.length; - const completed = - data?.metadata?.completed ?? - data?.items?.filter( - (item: { status?: string }) => item.status === "completed", - ).length; - - const lines = [`Progress: ${completed}/${total} completed`]; - - if (data?.items?.length) { - for (const item of data.items) { - lines.push(`- [${item.status}] ${item.content}`); - } - } - - return lines.join("\n"); - } catch { - return null; - } -} diff --git a/packages/agent/src/workflow/steps/plan.ts b/packages/agent/src/workflow/steps/plan.ts deleted file mode 100644 index 194a98e8..00000000 --- a/packages/agent/src/workflow/steps/plan.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { query } from "@anthropic-ai/claude-agent-sdk"; -import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions.js"; -import { PLANNING_SYSTEM_PROMPT } from "../../agents/planning.js"; -import { TodoManager } from "../../todo-manager.js"; -import type { WorkflowStepRunner } from "../types.js"; -import { finalizeStepGitActions } from "../utils.js"; - -export const planStep: WorkflowStepRunner = async ({ step, context }) => { - const { - task, - cwd, - isCloudMode, - options, - logger, - fileManager, - gitManager, - promptBuilder, - sessionId, - mcpServers, - sendNotification, - } = context; - - const stepLogger = logger.child("PlanStep"); - - const existingPlan = await fileManager.readPlan(task.id); - if (existingPlan) { - stepLogger.info("Plan already exists, skipping step", { taskId: task.id }); - return { status: "skipped" }; - } - - const researchData = await fileManager.readResearch(task.id); - if (researchData?.questions && !researchData.answered) { - stepLogger.info("Waiting for answered research questions", { - taskId: task.id, - }); - await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { - sessionId, - phase: "research_questions", - }); - return { status: "skipped", halt: true }; - } - - stepLogger.info("Starting planning phase", { taskId: task.id }); - await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_START, { - sessionId, - phase: "planning", - }); - let researchContext = ""; - if (researchData) { - researchContext += `## Research Context\n\n${researchData.context}\n\n`; - if (researchData.keyFiles.length > 0) { - researchContext += `**Key Files:**\n${researchData.keyFiles.map((f) => `- ${f}`).join("\n")}\n\n`; - } - if (researchData.blockers && researchData.blockers.length > 0) { - researchContext += `**Considerations:**\n${researchData.blockers.map((b) => `- ${b}`).join("\n")}\n\n`; - } - - // Add answered questions if they exist - if ( - researchData.questions && - researchData.answers && - researchData.answered - ) { - researchContext += `## Implementation Decisions\n\n`; - for (const question of researchData.questions) { - const answer = researchData.answers.find( - (a) => a.questionId === question.id, - ); - - researchContext += `### ${question.question}\n\n`; - if (answer) { - researchContext += `**Selected:** ${answer.selectedOption}\n`; - if (answer.customInput) { - researchContext += `**Details:** ${answer.customInput}\n`; - } - } else { - researchContext += `**Selected:** Not answered\n`; - } - researchContext += `\n`; - } - } - } - - const planningPrompt = await promptBuilder.buildPlanningPrompt(task, cwd); - const fullPrompt = `${PLANNING_SYSTEM_PROMPT}\n\n${planningPrompt}\n\n${researchContext}`; - - const baseOptions: Record = { - model: step.model, - cwd, - permissionMode: "plan", - settingSources: ["local"], - mcpServers, - // Allow research tools: read-only operations, web search, MCP resources, and ExitPlanMode - allowedTools: [ - "Read", - "Glob", - "Grep", - "WebFetch", - "WebSearch", - "ListMcpResources", - "ReadMcpResource", - "ExitPlanMode", - "TodoWrite", - "BashOutput", - ], - }; - - const response = query({ - prompt: fullPrompt, - options: { ...baseOptions, ...(options.queryOverrides || {}) }, - }); - - const todoManager = new TodoManager(fileManager, stepLogger); - - let planContent = ""; - try { - for await (const message of response) { - const todoList = await todoManager.checkAndPersistFromMessage( - message, - task.id, - ); - if (todoList) { - await sendNotification(POSTHOG_NOTIFICATIONS.ARTIFACT, { - sessionId, - kind: "todos", - content: todoList, - }); - } - - // Extract text content for plan - if (message.type === "assistant" && message.message?.content) { - for (const block of message.message.content) { - if (block.type === "text" && block.text) { - planContent += `${block.text}\n`; - } - } - } - } - } catch (error) { - stepLogger.error("Error during plan step query", error); - throw error; - } - - if (planContent.trim()) { - await fileManager.writePlan(task.id, planContent.trim()); - stepLogger.info("Plan completed", { taskId: task.id }); - } - - await gitManager.addAllPostHogFiles(); - await finalizeStepGitActions(context, step, { - commitMessage: `Planning phase for ${task.title}`, - }); - - if (!isCloudMode) { - await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { - sessionId, - phase: "planning", - }); - return { status: "completed", halt: true }; - } - - await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { - sessionId, - phase: "planning", - }); - return { status: "completed" }; -}; diff --git a/packages/agent/src/workflow/steps/research.ts b/packages/agent/src/workflow/steps/research.ts deleted file mode 100644 index aa53f2c6..00000000 --- a/packages/agent/src/workflow/steps/research.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { query } from "@anthropic-ai/claude-agent-sdk"; -import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions.js"; -import { RESEARCH_SYSTEM_PROMPT } from "../../agents/research.js"; -import type { ResearchEvaluation } from "../../types.js"; -import type { WorkflowStepRunner } from "../types.js"; -import { finalizeStepGitActions } from "../utils.js"; - -export const researchStep: WorkflowStepRunner = async ({ step, context }) => { - const { - task, - cwd, - isCloudMode, - options, - logger, - fileManager, - gitManager, - promptBuilder, - sessionId, - mcpServers, - sendNotification, - } = context; - - const stepLogger = logger.child("ResearchStep"); - - const existingResearch = await fileManager.readResearch(task.id); - if (existingResearch) { - stepLogger.info("Research already exists", { - taskId: task.id, - hasQuestions: !!existingResearch.questions, - answered: existingResearch.answered, - }); - - // If there are unanswered questions, re-emit them so UI can prompt user - if (existingResearch.questions && !existingResearch.answered) { - stepLogger.info("Re-emitting unanswered research questions", { - taskId: task.id, - questionCount: existingResearch.questions.length, - }); - - await sendNotification(POSTHOG_NOTIFICATIONS.ARTIFACT, { - sessionId, - kind: "research_questions", - content: existingResearch.questions, - }); - - // In local mode, halt to allow user to answer - if (!isCloudMode) { - await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { - sessionId, - phase: "research", - }); - return { status: "skipped", halt: true }; - } - } - - return { status: "skipped" }; - } - - stepLogger.info("Starting research phase", { taskId: task.id }); - await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_START, { - sessionId, - phase: "research", - }); - - const researchPrompt = await promptBuilder.buildResearchPrompt(task, cwd); - const fullPrompt = `${RESEARCH_SYSTEM_PROMPT}\n\n${researchPrompt}`; - - const baseOptions: Record = { - model: step.model, - cwd, - permissionMode: "plan", - settingSources: ["local"], - mcpServers, - // Allow research tools: read-only operations, web search, and MCP resources - allowedTools: [ - "Read", - "Glob", - "Grep", - "WebFetch", - "WebSearch", - "ListMcpResources", - "ReadMcpResource", - "TodoWrite", - "BashOutput", - ], - }; - - const response = query({ - prompt: fullPrompt, - options: { ...baseOptions, ...(options.queryOverrides || {}) }, - }); - - let jsonContent = ""; - try { - for await (const message of response) { - // Extract text content from assistant messages - if (message.type === "assistant" && message.message?.content) { - for (const c of message.message.content) { - if (c.type === "text" && c.text) { - jsonContent += c.text; - } - } - } - } - } catch (error) { - stepLogger.error("Error during research step query", error); - throw error; - } - - if (!jsonContent.trim()) { - stepLogger.error("No JSON output from research agent", { taskId: task.id }); - await sendNotification(POSTHOG_NOTIFICATIONS.ERROR, { - sessionId, - message: "Research agent returned no output", - }); - return { status: "completed", halt: true }; - } - - // Parse JSON response - let evaluation: ResearchEvaluation; - try { - // Extract JSON from potential markdown code blocks or other wrapping - const jsonMatch = jsonContent.match(/\{[\s\S]*\}/); - if (!jsonMatch) { - throw new Error("No JSON object found in response"); - } - evaluation = JSON.parse(jsonMatch[0]); - stepLogger.info("Parsed research evaluation", { - taskId: task.id, - score: evaluation.actionabilityScore, - hasQuestions: !!evaluation.questions, - }); - } catch (error) { - stepLogger.error("Failed to parse research JSON", { - taskId: task.id, - error: error instanceof Error ? error.message : String(error), - content: jsonContent.substring(0, 500), - }); - await sendNotification(POSTHOG_NOTIFICATIONS.ERROR, { - sessionId, - message: `Failed to parse research JSON: ${ - error instanceof Error ? error.message : String(error) - }`, - }); - return { status: "completed", halt: true }; - } - - // Add answered/answers fields to evaluation - if (evaluation.questions && evaluation.questions.length > 0) { - evaluation.answered = false; - evaluation.answers = undefined; - } - - // Always write research.json - await fileManager.writeResearch(task.id, evaluation); - stepLogger.info("Research evaluation written", { - taskId: task.id, - score: evaluation.actionabilityScore, - hasQuestions: !!evaluation.questions, - }); - - await sendNotification(POSTHOG_NOTIFICATIONS.ARTIFACT, { - sessionId, - kind: "research_evaluation", - content: evaluation, - }); - - await gitManager.addAllPostHogFiles(); - await finalizeStepGitActions(context, step, { - commitMessage: `Research phase for ${task.title}`, - }); - - // Log whether questions need answering - if ( - evaluation.actionabilityScore < 0.7 && - evaluation.questions && - evaluation.questions.length > 0 - ) { - stepLogger.info("Actionability score below threshold, questions needed", { - taskId: task.id, - score: evaluation.actionabilityScore, - questionCount: evaluation.questions.length, - }); - - await sendNotification(POSTHOG_NOTIFICATIONS.ARTIFACT, { - sessionId, - kind: "research_questions", - content: evaluation.questions, - }); - } else { - stepLogger.info("Actionability score acceptable, proceeding to planning", { - taskId: task.id, - score: evaluation.actionabilityScore, - }); - } - - // In local mode, always halt after research for user review - if (!isCloudMode) { - await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { - sessionId, - phase: "research", - }); - return { status: "completed", halt: true }; - } - - // In cloud mode, check if questions need answering - const researchData = await fileManager.readResearch(task.id); - if (researchData?.questions && !researchData.answered) { - // Questions need answering - halt for user input in cloud mode too - await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { - sessionId, - phase: "research", - }); - return { status: "completed", halt: true }; - } - - // No questions or questions already answered - proceed to planning - await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { - sessionId, - phase: "research", - }); - return { status: "completed" }; -}; diff --git a/packages/agent/src/workflow/types.ts b/packages/agent/src/workflow/types.ts deleted file mode 100644 index 7eb0d5d5..00000000 --- a/packages/agent/src/workflow/types.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { AgentSideConnection } from "@agentclientprotocol/sdk"; -import type { PostHogFileManager } from "../file-manager.js"; -import type { GitManager } from "../git-manager.js"; -import type { PostHogAPIClient } from "../posthog-api.js"; -import type { PromptBuilder } from "../prompt-builder.js"; -import type { PermissionMode, Task, TaskExecutionOptions } from "../types.js"; -import type { Logger } from "../utils/logger.js"; - -/** - * Function type for sending custom PostHog notifications via ACP extNotification. - * Used by workflow steps to emit artifacts, phase updates, etc. - */ -export type SendNotification = ( - method: string, - params: Record, -) => Promise; - -export interface WorkflowRuntime { - task: Task; - taskSlug: string; - runId: string; - cwd: string; - isCloudMode: boolean; - options: TaskExecutionOptions; - logger: Logger; - fileManager: PostHogFileManager; - gitManager: GitManager; - promptBuilder: PromptBuilder; - connection: AgentSideConnection; - sessionId: string; - mcpServers?: Record; - posthogAPI?: PostHogAPIClient; - sendNotification: SendNotification; - stepResults: Record; -} - -export interface WorkflowStepDefinition { - id: string; - name: string; - agent: string; - model: string; - permissionMode?: PermissionMode | string; - commit?: boolean; - push?: boolean; - run: WorkflowStepRunner; -} - -export interface WorkflowStepRuntime { - step: WorkflowStepDefinition; - context: WorkflowRuntime; -} - -export interface WorkflowStepResult { - status: "completed" | "skipped"; - halt?: boolean; -} - -export type WorkflowStepRunner = ( - runtime: WorkflowStepRuntime, -) => Promise; - -export type WorkflowDefinition = WorkflowStepDefinition[]; diff --git a/packages/agent/src/workflow/utils.ts b/packages/agent/src/workflow/utils.ts deleted file mode 100644 index 4d7104b4..00000000 --- a/packages/agent/src/workflow/utils.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { WorkflowRuntime, WorkflowStepDefinition } from "./types.js"; - -interface FinalizeGitOptions { - commitMessage: string; - allowEmptyCommit?: boolean; -} - -/** - * Commits (and optionally pushes) any staged changes according to the step configuration. - * Returns true if a commit was created. - */ -export async function finalizeStepGitActions( - context: WorkflowRuntime, - step: WorkflowStepDefinition, - options: FinalizeGitOptions, -): Promise { - if (!step.commit) { - return false; - } - - const { gitManager, logger } = context; - const hasStagedChanges = await gitManager.hasStagedChanges(); - - if (!hasStagedChanges && !options.allowEmptyCommit) { - logger.debug("No staged changes to commit for step", { stepId: step.id }); - return false; - } - - try { - await gitManager.commitChanges(options.commitMessage); - logger.info("Committed changes for step", { - stepId: step.id, - message: options.commitMessage, - }); - } catch (error) { - logger.error("Failed to commit changes for step", { - stepId: step.id, - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } - - if (step.push) { - const branchName = await gitManager.getCurrentBranch(); - await gitManager.pushBranch(branchName); - logger.info("Pushed branch after step", { - stepId: step.id, - branch: branchName, - }); - } - - return true; -}