diff --git a/app/components/ComputerSidebar.tsx b/app/components/ComputerSidebar.tsx index 3bad0c72..ef9a599e 100644 --- a/app/components/ComputerSidebar.tsx +++ b/app/components/ComputerSidebar.tsx @@ -28,6 +28,14 @@ import { type ChatStatus, } from "@/types/chat"; +const SHELL_ACTION_LABELS: Record = { + exec: "Running command", + send: "Writing to terminal", + wait: "Waiting for completion", + kill: "Terminating process", + view: "Reading terminal", +}; + interface ComputerSidebarProps { sidebarOpen: boolean; sidebarContent: SidebarContent | null; @@ -175,13 +183,16 @@ export const ComputerSidebarBase: React.FC = ({ }; return actionMap[sidebarContent.action || "reading"]; } else if (isTerminal) { - return sidebarContent.isExecuting - ? "Executing command" - : "Command executed"; + // Use shellAction if available for accurate action text + if (sidebarContent.shellAction) { + return ( + SHELL_ACTION_LABELS[sidebarContent.shellAction] ?? "Executing command" + ); + } + // Fallback for legacy terminal entries without shellAction + return "Executing command"; } else if (isPython) { - return sidebarContent.isExecuting - ? "Executing Python" - : "Python executed"; + return "Executing Python"; } return "Unknown action"; }; @@ -212,6 +223,7 @@ export const ComputerSidebarBase: React.FC = ({ if (isFile) { return sidebarContent.path.split("/").pop() || sidebarContent.path; } else if (isTerminal) { + // If it's a shell tool call, just show the session ID (command field) return sidebarContent.command; } else if (isPython) { return sidebarContent.code.replace(/\n/g, " "); @@ -289,10 +301,9 @@ export const ComputerSidebarBase: React.FC = ({ {/* Title - far left */}
{isTerminal ? ( - + + {sidebarContent.sessionName || "Shell"} + ) : isPython ? ( = ({ output={sidebarContent.output} isExecuting={sidebarContent.isExecuting} isBackground={sidebarContent.isBackground} + showContentOnly={sidebarContent.showContentOnly} status={ sidebarContent.isExecuting ? "streaming" : "ready" } diff --git a/app/components/MessagePartHandler.tsx b/app/components/MessagePartHandler.tsx index 5053800a..ef3bcd97 100644 --- a/app/components/MessagePartHandler.tsx +++ b/app/components/MessagePartHandler.tsx @@ -2,6 +2,7 @@ import { UIMessage } from "@ai-sdk/react"; import { MemoizedMarkdown } from "./MemoizedMarkdown"; import { FileToolsHandler } from "./tools/FileToolsHandler"; import { TerminalToolHandler } from "./tools/TerminalToolHandler"; +import { ShellToolHandler } from "./tools/ShellToolHandler"; import { HttpRequestToolHandler } from "./tools/HttpRequestToolHandler"; import { PythonToolHandler } from "./tools/PythonToolHandler"; import { WebToolHandler } from "./tools/WebToolHandler"; @@ -87,6 +88,9 @@ export const MessagePartHandler = ({ ); + case "tool-shell": + return ; + case "tool-http_request": return ( diff --git a/app/components/TerminalCodeBlock.tsx b/app/components/TerminalCodeBlock.tsx index b9f855bb..9cddce26 100644 --- a/app/components/TerminalCodeBlock.tsx +++ b/app/components/TerminalCodeBlock.tsx @@ -11,6 +11,7 @@ interface TerminalCodeBlockProps { isExecuting?: boolean; status?: "ready" | "submitted" | "streaming" | "error"; isBackground?: boolean; + showContentOnly?: boolean; variant?: "default" | "sidebar"; wrap?: boolean; } @@ -172,7 +173,7 @@ const AnsiCodeBlock = ({ return (
- {isStreaming ? "Processing output..." : "Rendering output..."} + {isStreaming ? "Processing output…" : "Rendering output…"}
); @@ -198,6 +199,7 @@ export const TerminalCodeBlock = ({ isExecuting = false, status, isBackground = false, + showContentOnly = false, variant = "default", wrap = false, }: TerminalCodeBlockProps) => { @@ -209,7 +211,13 @@ export const TerminalCodeBlock = ({ }, [wrap]); // Combine command and output for full terminal session - const terminalContent = output ? `$ ${command}\n${output}` : `$ ${command}`; + // For views/non-exec actions, show raw output only + const terminalContent = + showContentOnly && output + ? output + : output + ? `$ ${command}\n${output}` + : `$ ${command}`; const displayContent = output || ""; // For non-sidebar variant, keep the original terminal look diff --git a/app/components/tools/FileToolsHandler.tsx b/app/components/tools/FileToolsHandler.tsx index 73053ee5..6581d29d 100644 --- a/app/components/tools/FileToolsHandler.tsx +++ b/app/components/tools/FileToolsHandler.tsx @@ -3,7 +3,7 @@ import { UIMessage } from "@ai-sdk/react"; import ToolBlock from "@/components/ui/tool-block"; import { FilePlus, FileText, FilePen, FileMinus } from "lucide-react"; import { useGlobalState } from "../../contexts/GlobalState"; -import type { ChatStatus } from "@/types"; +import type { ChatStatus, SidebarContent } from "@/types"; import { isSidebarFile } from "@/types/chat"; interface DiffDataPart { @@ -20,15 +20,20 @@ interface FileToolsHandlerProps { message: UIMessage; part: any; status: ChatStatus; + // Optional: pass openSidebar to make handler context-agnostic + externalOpenSidebar?: (content: SidebarContent) => void; } export const FileToolsHandler = ({ message, part, status, + externalOpenSidebar, }: FileToolsHandlerProps) => { - const { openSidebar, updateSidebarContent, sidebarContent, sidebarOpen } = - useGlobalState(); + const globalState = useGlobalState(); + // Use external openSidebar if provided, otherwise use from GlobalState + const openSidebar = externalOpenSidebar ?? globalState.openSidebar; + const { updateSidebarContent, sidebarContent, sidebarOpen } = globalState; // Track the last streamed content to avoid unnecessary updates const lastStreamedContentRef = useRef(null); @@ -45,7 +50,10 @@ export const FileToolsHandler = ({ }, [part.type, part.input]); // Update sidebar content as write_file content streams in + // Only applies when using GlobalState (not external openSidebar) useEffect(() => { + // Skip if using external openSidebar (read-only mode) + if (externalOpenSidebar) return; // Only update for write_file tool during streaming if (part.type !== "tool-write_file") return; if (part.state !== "input-streaming" && part.state !== "input-available") @@ -77,6 +85,7 @@ export const FileToolsHandler = ({ sidebarOpen, sidebarContent, updateSidebarContent, + externalOpenSidebar, ]); // Reset tracking refs when tool completes or changes @@ -182,7 +191,7 @@ export const FileToolsHandler = ({ } - action="Read" + action="Reading" target={`${readInput.target_file}${getFileRange()}`} isClickable={true} onClick={handleOpenInSidebar} @@ -272,7 +281,7 @@ export const FileToolsHandler = ({ } - action="Successfully wrote" + action="Writing to" target={writeInput.file_path} isClickable={true} onClick={() => { @@ -332,14 +341,12 @@ export const FileToolsHandler = ({ ) : null; case "output-available": { if (!deleteInput) return null; - const deleteOutput = output as { result: string }; - const isSuccess = deleteOutput.result.includes("Successfully deleted"); return ( } - action={isSuccess ? "Successfully deleted" : "Failed to delete"} + action="Deleting" target={deleteInput.target_file} /> ); @@ -385,8 +392,6 @@ export const FileToolsHandler = ({ case "output-available": { if (!searchReplaceInput) return null; const searchReplaceOutput = output as { result: string }; - const isSuccess = - searchReplaceOutput.result.includes("Successfully made"); const handleOpenInSidebar = () => { // Use diff data from stream if available (not persisted across reloads) @@ -412,7 +417,9 @@ export const FileToolsHandler = ({ } - action={isSuccess ? "Successfully edited" : "Failed to edit"} + action={ + searchReplaceInput?.replace_all ? "Replacing all in" : "Editing" + } target={searchReplaceInput.file_path} isClickable={true} onClick={handleOpenInSidebar} @@ -464,20 +471,12 @@ export const FileToolsHandler = ({ ) : null; case "output-available": { if (!multiEditInput) return null; - const multiEditOutput = output as { result: string }; - const isSuccess = multiEditOutput.result.includes( - "Successfully applied", - ); return ( } - action={ - isSuccess - ? `Successfully applied ${multiEditInput.edits.length} edits` - : "Failed to apply edits" - } + action={`Making ${multiEditInput.edits.length} edits to`} target={multiEditInput.file_path} /> ); diff --git a/app/components/tools/GetTerminalFilesHandler.tsx b/app/components/tools/GetTerminalFilesHandler.tsx index 66f92cee..4eb21bae 100644 --- a/app/components/tools/GetTerminalFilesHandler.tsx +++ b/app/components/tools/GetTerminalFilesHandler.tsx @@ -52,23 +52,20 @@ export const GetTerminalFilesHandler = ({ } - action={status === "streaming" ? "Sharing" : "Shared"} + action="Sharing" target={getFileNames(filesInput?.files || [])} isShimmer={status === "streaming"} /> ); case "output-available": { - // Support both new (files) and legacy (fileUrls) formats - const fileCount = - filesOutput?.files?.length || filesOutput?.fileUrls?.length || 0; const fileNames = getFileNames(filesInput?.files || []); return ( } - action={`Shared ${fileCount} file${fileCount !== 1 ? "s" : ""}`} + action="Sharing" target={fileNames} /> ); @@ -79,7 +76,7 @@ export const GetTerminalFilesHandler = ({ } - action="Failed to share" + action="Sharing" target={getFileNames(filesInput?.files || [])} /> ); diff --git a/app/components/tools/HttpRequestToolHandler.tsx b/app/components/tools/HttpRequestToolHandler.tsx index 40aeeed1..6ef7374e 100644 --- a/app/components/tools/HttpRequestToolHandler.tsx +++ b/app/components/tools/HttpRequestToolHandler.tsx @@ -117,9 +117,7 @@ export const HttpRequestToolHandler = ({ // Determine action text based on state const getActionText = (): string => { if (state === "input-streaming") return "Preparing request"; - if (isExecuting) return "Requesting"; - if (httpOutput?.error) return "Request failed"; - return "Requested"; + return "Requesting"; }; switch (state) { @@ -162,7 +160,7 @@ export const HttpRequestToolHandler = ({ } - action="Request failed" + action={getActionText()} target={displayCommand} isClickable={true} onClick={handleOpenInSidebar} diff --git a/app/components/tools/MatchToolHandler.tsx b/app/components/tools/MatchToolHandler.tsx index b511baf0..5c2e6ee0 100644 --- a/app/components/tools/MatchToolHandler.tsx +++ b/app/components/tools/MatchToolHandler.tsx @@ -37,36 +37,8 @@ export const MatchToolHandler = ({ part, status }: MatchToolHandlerProps) => { return matchInput.scope; }; - // Parse the output to get a summary label - const getResultLabel = (outputText: string) => { - if (outputText.startsWith("Found ")) { - // Extract "Found X file(s)" or "Found X match(es)" - const match = outputText.match(/^Found (\d+) (file|match)/); - if (match) { - const count = parseInt(match[1], 10); - const type = match[2]; - if (type === "file") { - return `Found ${count} file${count === 1 ? "" : "s"}`; - } - return `Found ${count} match${count === 1 ? "" : "es"}`; - } - } - if (outputText.startsWith("No files found")) { - return "No files found"; - } - if (outputText.startsWith("No matches found")) { - return "No matches found"; - } - if (outputText.startsWith("Search timed out")) { - return "Search timed out"; - } - if ( - outputText.startsWith("Error:") || - outputText.startsWith("Search failed") - ) { - return "Search failed"; - } - return isGlob ? "Search complete" : "Search complete"; + const getResultLabel = () => { + return isGlob ? "Finding files" : "Searching"; }; switch (state) { @@ -113,7 +85,7 @@ export const MatchToolHandler = ({ part, status }: MatchToolHandlerProps) => { } - action={getResultLabel(outputText)} + action={getResultLabel()} target={getTarget()} isClickable={true} onClick={handleOpenInSidebar} diff --git a/app/components/tools/MemoryToolHandler.tsx b/app/components/tools/MemoryToolHandler.tsx index 905c3669..f6c51aba 100644 --- a/app/components/tools/MemoryToolHandler.tsx +++ b/app/components/tools/MemoryToolHandler.tsx @@ -26,19 +26,6 @@ export const MemoryToolHandler = ({ part, status }: MemoryToolHandlerProps) => { }; const getActionText = (action?: string) => { - switch (action) { - case "create": - return "Created memory"; - case "update": - return "Updated memory"; - case "delete": - return "Deleted memory"; - default: - return "Updated memory"; - } - }; - - const getStreamingActionText = (action?: string) => { switch (action) { case "create": return "Creating memory"; @@ -113,7 +100,7 @@ export const MemoryToolHandler = ({ part, status }: MemoryToolHandlerProps) => { } - action={getStreamingActionText(memoryInput.action)} + action={getActionText(memoryInput.action)} isShimmer={true} /> ) : null; @@ -123,7 +110,7 @@ export const MemoryToolHandler = ({ part, status }: MemoryToolHandlerProps) => { } - action={getStreamingActionText(memoryInput.action)} + action={getActionText(memoryInput.action)} target={memoryInput.title} isShimmer={true} /> diff --git a/app/components/tools/PythonToolHandler.tsx b/app/components/tools/PythonToolHandler.tsx index 5324a492..f15e36b5 100644 --- a/app/components/tools/PythonToolHandler.tsx +++ b/app/components/tools/PythonToolHandler.tsx @@ -3,22 +3,27 @@ import { UIMessage } from "@ai-sdk/react"; import ToolBlock from "@/components/ui/tool-block"; import { Code2 } from "lucide-react"; import { useGlobalState } from "../../contexts/GlobalState"; -import type { ChatStatus, SidebarPython } from "@/types/chat"; +import type { ChatStatus, SidebarPython, SidebarContent } from "@/types/chat"; import { isSidebarPython } from "@/types/chat"; interface PythonToolHandlerProps { message: UIMessage; part: any; status: ChatStatus; + // Optional: pass openSidebar to make handler context-agnostic + externalOpenSidebar?: (content: SidebarContent) => void; } export const PythonToolHandler = ({ message, part, status, + externalOpenSidebar, }: PythonToolHandlerProps) => { - const { openSidebar, sidebarOpen, sidebarContent, updateSidebarContent } = - useGlobalState(); + const globalState = useGlobalState(); + // Use external openSidebar if provided, otherwise use from GlobalState + const openSidebar = externalOpenSidebar ?? globalState.openSidebar; + const { sidebarOpen, sidebarContent, updateSidebarContent } = globalState; const { toolCallId, state, input, output, errorText } = part; const pythonInput = input as { code: string }; const pythonOutput = output as { @@ -91,16 +96,18 @@ export const PythonToolHandler = ({ [handleOpenInSidebar], ); - // Track if this sidebar is currently active + // Track if this sidebar is currently active (only for GlobalState mode) const isSidebarActive = + !externalOpenSidebar && sidebarOpen && sidebarContent && isSidebarPython(sidebarContent) && sidebarContent.toolCallId === toolCallId; // Update sidebar content in real-time if it's currently open for this tool call + // Only applies when using GlobalState (not external openSidebar) useEffect(() => { - if (!isSidebarActive) return; + if (!isSidebarActive || externalOpenSidebar) return; updateSidebarContent({ code: codePreview, @@ -108,7 +115,7 @@ export const PythonToolHandler = ({ isExecuting, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSidebarActive, codePreview, finalOutput, isExecuting]); + }, [isSidebarActive, codePreview, finalOutput, isExecuting, externalOpenSidebar]); switch (state) { case "input-streaming": @@ -129,9 +136,7 @@ export const PythonToolHandler = ({ } - action={ - status === "streaming" ? "Executing Python" : "Executed Python" - } + action="Executing Python" target={codePreview} isShimmer={status === "streaming"} isClickable={true} @@ -144,7 +149,7 @@ export const PythonToolHandler = ({ } - action="Executed Python" + action="Executing Python" target={codePreview} isClickable={true} onClick={handleOpenInSidebar} @@ -156,7 +161,7 @@ export const PythonToolHandler = ({ } - action="Executed Python" + action="Executing Python" target={codePreview} isClickable={true} onClick={handleOpenInSidebar} diff --git a/app/components/tools/ShellToolHandler.tsx b/app/components/tools/ShellToolHandler.tsx new file mode 100644 index 00000000..ed811cc1 --- /dev/null +++ b/app/components/tools/ShellToolHandler.tsx @@ -0,0 +1,251 @@ +import React, { useEffect, useMemo } from "react"; +import { UIMessage } from "@ai-sdk/react"; +import ToolBlock from "@/components/ui/tool-block"; +import { Terminal } from "lucide-react"; +import { useOptionalGlobalState } from "../../contexts/GlobalState"; +import type { + ChatStatus, + SidebarTerminal, + SidebarContent, + ShellAction, +} from "@/types/chat"; +import { isSidebarTerminal } from "@/types/chat"; + +interface ShellInput { + action: ShellAction; + command?: string; + input?: string; + session: string; + timeout?: number; + brief?: string; +} + +interface ShellResult { + success: boolean; + content?: string; + running?: boolean; + completed?: boolean; + exitCode?: number; + waiting_for_input?: boolean; + current_command?: string; +} + +interface ShellToolHandlerProps { + message: UIMessage; + part: any; + status: ChatStatus; + // Optional: pass openSidebar to make handler context-agnostic + externalOpenSidebar?: (content: SidebarContent) => void; +} + +const ACTION_LABELS: Record = { + exec: "Running command", + send: "Writing to terminal", + wait: "Waiting for completion", + kill: "Terminating process", + view: "Reading terminal", +}; + +/** + * Format shell input text for display, converting special keys to readable format + */ +const formatShellInput = (inputText: string): string => { + if (!inputText) return ""; + + // Format Ctrl key combinations + if (inputText.startsWith("C-")) { + const key = inputText.slice(2).toUpperCase(); + return `[Ctrl+${key}]`; + } + // Format Alt/Meta key combinations + if (inputText.startsWith("M-")) { + const key = inputText.slice(2).toUpperCase(); + return `[Alt+${key}]`; + } + // Handle other special keys + const specialKeyMap: Record = { + Enter: "[Enter]", + Escape: "[Escape]", + Tab: "[Tab]", + Space: "[Space]", + Up: "[Up]", + Down: "[Down]", + Left: "[Left]", + Right: "[Right]", + Home: "[Home]", + End: "[End]", + PageUp: "[PageUp]", + PageDown: "[PageDown]", + BSpace: "[Backspace]", + }; + if (specialKeyMap[inputText]) { + return specialKeyMap[inputText]; + } + // Return as-is for regular text + return inputText; +}; + +export const ShellToolHandler = ({ + message, + part, + status, + externalOpenSidebar, +}: ShellToolHandlerProps) => { + // Use optional hook to avoid throwing when used outside GlobalStateProvider + const globalState = useOptionalGlobalState(); + // Use external openSidebar if provided, otherwise use from GlobalState + const openSidebar = externalOpenSidebar ?? globalState?.openSidebar; + const { sidebarOpen, sidebarContent, updateSidebarContent } = globalState ?? {}; + const { toolCallId, state, input, output, errorText } = part; + + const shellInput = input as ShellInput | undefined; + const shellOutput = output as { result: ShellResult } | undefined; + + // Memoize streaming output computation + const streamingOutput = useMemo(() => { + const terminalDataParts = message.parts.filter( + (p) => + p.type === "data-terminal" && + (p as any).data?.toolCallId === toolCallId, + ); + return terminalDataParts + .map((p) => (p as any).data?.terminal || "") + .join(""); + }, [message.parts, toolCallId]); + + // Memoize final output computation + // Use nullish coalescing to preserve explicit empty string content from the tool + const finalOutput = useMemo(() => { + const content = shellOutput?.result?.content; + // Only fall through to streaming/error if content is null/undefined (not empty string) + return content ?? streamingOutput ?? errorText ?? ""; + }, [shellOutput, streamingOutput, errorText]); + + const isStreaming = status === "streaming"; + const isExecuting = state === "input-available" && isStreaming; + + const action = shellInput?.action ?? "exec"; + + const getActionLabel = (): string => { + return ACTION_LABELS[action] ?? ACTION_LABELS.exec; + }; + + const getTargetLabel = (): string => { + if (!shellInput) return ""; + + switch (shellInput.action) { + case "exec": + return shellInput.command || shellInput.session; + case "send": { + const inputText = shellInput.input || ""; + const formatted = formatShellInput(inputText); + // Truncate long input (only for non-special keys) + return formatted.length > 40 + ? `${formatted.slice(0, 37)}...` + : formatted; + } + case "kill": + return `session: ${shellInput.session}`; + case "wait": + return `session: ${shellInput.session}`; + case "view": + return `session: ${shellInput.session}`; + default: + return shellInput.session || ""; + } + }; + + const getSidebarTitle = (): string => { + if (!shellInput) return "Shell"; + + const session = shellInput.session || "default"; + + switch (shellInput.action) { + case "exec": + return shellInput.command || `Session: ${session}`; + case "send": { + const formatted = formatShellInput(shellInput.input || ""); + return formatted || `Input to ${session}`; + } + case "wait": + return `Waiting: ${session}`; + case "kill": + return `Kill: ${session}`; + case "view": + return `Session: ${session}`; + default: + return `Session: ${session}`; + } + }; + + const handleOpenInSidebar = () => { + const sidebarTerminal: SidebarTerminal = { + command: getSidebarTitle(), + output: finalOutput, + isExecuting, + isBackground: false, + showContentOnly: shellInput?.action !== "exec", + toolCallId: toolCallId, + shellAction: shellInput?.action, + sessionName: shellInput?.session, + }; + + openSidebar?.(sidebarTerminal); + }; + + // Track if this sidebar is currently active (only for GlobalState mode) + const isSidebarActive = + !externalOpenSidebar && + sidebarOpen && + sidebarContent && + isSidebarTerminal(sidebarContent) && + sidebarContent.toolCallId === toolCallId; + + // Update sidebar content in real-time if it's currently open for this tool call + // Only applies when using GlobalState (not external openSidebar) + useEffect(() => { + if (!isSidebarActive || externalOpenSidebar) return; + + updateSidebarContent?.({ + output: finalOutput, + isExecuting, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSidebarActive, finalOutput, isExecuting, externalOpenSidebar]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleOpenInSidebar(); + } + }; + + switch (state) { + case "input-streaming": + return status === "streaming" ? ( + } + action="Preparing shell" + isShimmer={true} + /> + ) : null; + case "input-available": + case "output-available": + case "output-error": + return ( + } + action={getActionLabel()} + target={getTargetLabel()} + isShimmer={state === "input-available" && status === "streaming"} + isClickable={true} + onClick={handleOpenInSidebar} + onKeyDown={handleKeyDown} + /> + ); + default: + return null; + } +}; diff --git a/app/components/tools/TerminalToolHandler.tsx b/app/components/tools/TerminalToolHandler.tsx index 7470c024..40bd59da 100644 --- a/app/components/tools/TerminalToolHandler.tsx +++ b/app/components/tools/TerminalToolHandler.tsx @@ -4,22 +4,27 @@ import { CommandResult } from "@e2b/code-interpreter"; import ToolBlock from "@/components/ui/tool-block"; import { Terminal } from "lucide-react"; import { useGlobalState } from "../../contexts/GlobalState"; -import type { ChatStatus, SidebarTerminal } from "@/types/chat"; +import type { ChatStatus, SidebarTerminal, SidebarContent } from "@/types/chat"; import { isSidebarTerminal } from "@/types/chat"; interface TerminalToolHandlerProps { message: UIMessage; part: any; status: ChatStatus; + // Optional: pass openSidebar to make handler context-agnostic + externalOpenSidebar?: (content: SidebarContent) => void; } export const TerminalToolHandler = ({ message, part, status, + externalOpenSidebar, }: TerminalToolHandlerProps) => { - const { openSidebar, sidebarOpen, sidebarContent, updateSidebarContent } = - useGlobalState(); + const globalState = useGlobalState(); + // Use external openSidebar if provided, otherwise use from GlobalState + const openSidebar = externalOpenSidebar ?? globalState.openSidebar; + const { sidebarOpen, sidebarContent, updateSidebarContent } = globalState; const { toolCallId, state, input, output, errorText } = part; const terminalInput = input as { command: string; @@ -75,23 +80,25 @@ export const TerminalToolHandler = ({ openSidebar(sidebarTerminal); }; - // Track if this sidebar is currently active + // Track if this sidebar is currently active (only for GlobalState mode) const isSidebarActive = + !externalOpenSidebar && sidebarOpen && sidebarContent && isSidebarTerminal(sidebarContent) && sidebarContent.toolCallId === toolCallId; // Update sidebar content in real-time if it's currently open for this tool call + // Only applies when using GlobalState (not external openSidebar) useEffect(() => { - if (!isSidebarActive) return; + if (!isSidebarActive || externalOpenSidebar) return; updateSidebarContent({ output: finalOutput, isExecuting, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSidebarActive, finalOutput, isExecuting]); + }, [isSidebarActive, finalOutput, isExecuting, externalOpenSidebar]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { @@ -115,7 +122,7 @@ export const TerminalToolHandler = ({ } - action={status === "streaming" ? "Executing" : "Executed"} + action="Executing" target={terminalInput?.command || ""} isShimmer={status === "streaming"} isClickable={true} @@ -128,7 +135,7 @@ export const TerminalToolHandler = ({ } - action="Executed" + action="Executing" target={terminalInput?.command || ""} isClickable={true} onClick={handleOpenInSidebar} @@ -140,7 +147,7 @@ export const TerminalToolHandler = ({ } - action="Executed" + action="Executing" target={terminalInput?.command || ""} isClickable={true} onClick={handleOpenInSidebar} diff --git a/app/components/tools/WebToolHandler.tsx b/app/components/tools/WebToolHandler.tsx index a13742a4..6c58fd66 100644 --- a/app/components/tools/WebToolHandler.tsx +++ b/app/components/tools/WebToolHandler.tsx @@ -42,9 +42,8 @@ export const WebToolHandler = ({ part, status }: WebToolHandlerProps) => { return isOpenUrl ? : ; }; - const getAction = (isCompleted = false) => { - const action = isOpenUrl ? "Opening URL" : "Searching web"; - return isCompleted ? action.replace("ing", "ed") : action; + const getAction = () => { + return isOpenUrl ? "Opening URL" : "Searching web"; }; const getTarget = () => { @@ -97,7 +96,7 @@ export const WebToolHandler = ({ part, status }: WebToolHandlerProps) => { ); diff --git a/app/contexts/GlobalState.tsx b/app/contexts/GlobalState.tsx index 37f67955..1b515df5 100644 --- a/app/contexts/GlobalState.tsx +++ b/app/contexts/GlobalState.tsx @@ -756,3 +756,12 @@ export const useGlobalState = (): GlobalStateType => { } return context; }; + +/** + * Optional version of useGlobalState that returns undefined instead of throwing + * when used outside of GlobalStateProvider. Useful for components that can work + * both with and without the global state context. + */ +export const useOptionalGlobalState = (): GlobalStateType | undefined => { + return useContext(GlobalStateContext); +}; diff --git a/app/share/[shareId]/SharedChatView.tsx b/app/share/[shareId]/SharedChatView.tsx index fb7a1cb1..d7fd8c3a 100644 --- a/app/share/[shareId]/SharedChatView.tsx +++ b/app/share/[shareId]/SharedChatView.tsx @@ -13,6 +13,7 @@ import ChatHeader from "@/app/components/ChatHeader"; import MainSidebar from "@/app/components/Sidebar"; import { SidebarProvider } from "@/components/ui/sidebar"; import { useGlobalState } from "@/app/contexts/GlobalState"; +import { TodoBlockProvider } from "@/app/contexts/TodoBlockContext"; import { useEffect } from "react"; // Desktop wrapper component that connects ComputerSidebarBase to SharedChatContext @@ -144,8 +145,9 @@ export function SharedChatView({ shareId }: SharedChatViewProps) { } return ( - -
+ + +
{/* Header for unlogged users */} {!authLoading && !user && (
@@ -228,7 +230,8 @@ export function SharedChatView({ shareId }: SharedChatViewProps) {
)} -
-
+
+
+ ); } diff --git a/app/share/[shareId]/SharedMessagePartHandler.tsx b/app/share/[shareId]/SharedMessagePartHandler.tsx new file mode 100644 index 00000000..90a66a39 --- /dev/null +++ b/app/share/[shareId]/SharedMessagePartHandler.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { MemoizedMarkdown } from "@/app/components/MemoizedMarkdown"; +import { FileToolsHandler } from "@/app/components/tools/FileToolsHandler"; +import { TerminalToolHandler } from "@/app/components/tools/TerminalToolHandler"; +import { ShellToolHandler } from "@/app/components/tools/ShellToolHandler"; +import { PythonToolHandler } from "@/app/components/tools/PythonToolHandler"; +import { WebToolHandler } from "@/app/components/tools/WebToolHandler"; +import { TodoToolHandler } from "@/app/components/tools/TodoToolHandler"; +import { MemoryToolHandler } from "@/app/components/tools/MemoryToolHandler"; +import { + Reasoning, + ReasoningContent, + ReasoningTrigger, +} from "@/components/ai-elements/reasoning"; +import { useSharedChatContext } from "./SharedChatContext"; + +interface SharedMessagePartHandlerProps { + message: any; + part: any; + partIndex: number; + isUser: boolean; +} + +// Helper to collect consecutive reasoning text +const collectReasoningText = (parts: any[], startIndex: number): string => { + const collected: string[] = []; + for (let i = startIndex; i < parts.length; i++) { + const part = parts[i]; + if (part?.type === "reasoning") { + collected.push(part.text ?? ""); + } else { + break; + } + } + return collected.join(""); +}; + +export const SharedMessagePartHandler = ({ + message, + part, + partIndex, + isUser, +}: SharedMessagePartHandlerProps) => { + const { openSidebar } = useSharedChatContext(); + const partId = `${message.id}-${partIndex}`; + const parts = Array.isArray(message.parts) ? message.parts : []; + + // Reasoning blocks + if (part.type === "reasoning") { + // Skip if previous part is also reasoning (avoid duplicate renders) + const previousPart = parts[partIndex - 1]; + if (previousPart?.type === "reasoning") return null; + + const combined = collectReasoningText(parts, partIndex); + + // Don't show reasoning if empty or only contains [REDACTED] + if (!combined || /^(\[REDACTED\])+$/.test(combined.trim())) return null; + + return ( + + + {combined && ( + + + + )} + + ); + } + + // Text content + if (part.type === "text" && part.text) { + if (isUser) { + return ( +
+ {part.text} +
+ ); + } + return ; + } + + // File tools + if ( + part.type === "tool-read_file" || + part.type === "tool-write_file" || + part.type === "tool-delete_file" || + part.type === "tool-search_replace" || + part.type === "tool-multi_edit" + ) { + return ( + + ); + } + + // Terminal commands (legacy) + if (part.type === "data-terminal" || part.type === "tool-run_terminal_cmd") { + return ( + + ); + } + + // Shell tool + if (part.type === "tool-shell") { + return ( + + ); + } + + // Python execution + if (part.type === "data-python" || part.type === "tool-python") { + return ( + + ); + } + + // Web search - reuse existing handler (no sidebar needed) + if ( + part.type === "tool-web_search" || + part.type === "tool-open_url" || + part.type === "tool-web" + ) { + return ; + } + + // Todo - reuse existing handler (no sidebar needed) + if (part.type === "tool-todo_write") { + return ( + + ); + } + + // Memory - reuse existing handler (no sidebar needed) + if (part.type === "tool-update_memory") { + return ; + } + + return null; +}; diff --git a/app/share/[shareId]/SharedMessages.tsx b/app/share/[shareId]/SharedMessages.tsx index 5b4468da..4a41a15b 100644 --- a/app/share/[shareId]/SharedMessages.tsx +++ b/app/share/[shareId]/SharedMessages.tsx @@ -1,22 +1,7 @@ "use client"; -import { - ImageIcon, - Terminal, - FileCode, - Search, - Brain, - CheckSquare, - FileText, - FilePlus, - FilePen, - FileMinus, - Code2, - FileIcon, -} from "lucide-react"; -import { MemoizedMarkdown } from "@/app/components/MemoizedMarkdown"; -import ToolBlock from "@/components/ui/tool-block"; -import { useSharedChatContext } from "./SharedChatContext"; +import { ImageIcon, FileIcon } from "lucide-react"; +import { SharedMessagePartHandler } from "./SharedMessagePartHandler"; interface MessagePart { type: string; @@ -43,7 +28,6 @@ interface SharedMessagesProps { } export function SharedMessages({ messages, shareDate }: SharedMessagesProps) { - const { openSidebar } = useSharedChatContext(); if (messages.length === 0) { return (
@@ -54,273 +38,6 @@ export function SharedMessages({ messages, shareDate }: SharedMessagesProps) { ); } - const formatTime = (timestamp: number) => { - const date = new Date(timestamp); - return date.toLocaleString(); - }; - - const renderPart = (part: MessagePart, idx: number, isUser: boolean) => { - // Text content - if (part.type === "text" && part.text) { - return ( -
- {isUser ? part.text : } -
- ); - } - - // File/Image placeholder - simple indicator style - if ((part.type === "file" || part.type === "image") && part.placeholder) { - const isImage = part.type === "image"; - return ( -
-
- {isImage ? ( - - ) : ( - - )} - {isImage ? "Uploaded an image" : "Uploaded a file"} -
-
- ); - } - - // Terminal commands - if ( - part.type === "data-terminal" || - part.type === "tool-run_terminal_cmd" - ) { - const terminalInput = part.input as { command?: string }; - const terminalOutput = part.output as { - result?: string; - output?: string; - }; - const command = terminalInput?.command || ""; - const output = terminalOutput?.result || terminalOutput?.output || ""; - - if ( - part.state === "input-available" || - part.state === "output-available" || - part.state === "output-error" - ) { - const handleOpenInSidebar = () => { - openSidebar({ - command, - output, - isExecuting: false, - toolCallId: part.toolCallId || "", - }); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleOpenInSidebar(); - } - }; - - return ( - } - action="Executed" - target={command} - isClickable={true} - onClick={handleOpenInSidebar} - onKeyDown={handleKeyDown} - /> - ); - } - } - - // File operations - if ( - part.type === "tool-read_file" || - part.type === "tool-write_file" || - part.type === "tool-delete_file" || - part.type === "tool-search_replace" || - part.type === "tool-multi_edit" - ) { - const fileInput = part.input as { - file_path?: string; - path?: string; - target_file?: string; - offset?: number; - limit?: number; - content?: string; - }; - const fileOutput = part.output as { result?: string }; - const filePath = - fileInput?.file_path || fileInput?.path || fileInput?.target_file || ""; - - let action = "File operation"; - let icon = ; - let sidebarAction: "reading" | "creating" | "editing" | "writing" = - "reading"; - - if (part.type === "tool-read_file") { - action = "Read"; - icon = ; - sidebarAction = "reading"; - } - if (part.type === "tool-write_file") { - action = "Wrote"; - icon = ; - sidebarAction = "writing"; - } - if (part.type === "tool-delete_file") { - action = "Deleted"; - icon = ; - } - if ( - part.type === "tool-search_replace" || - part.type === "tool-multi_edit" - ) { - action = "Edited"; - icon = ; - sidebarAction = "editing"; - } - - if (part.state === "output-available") { - // For delete operations, don't make it clickable (no content to show) - if (part.type === "tool-delete_file") { - return ( - - ); - } - - const handleOpenInSidebar = () => { - let content = ""; - - if (part.type === "tool-read_file") { - // Clean line numbers from read output - content = (fileOutput?.result || "").replace(/^\s*\d+\|/gm, ""); - } else if (part.type === "tool-write_file") { - content = fileInput?.content || ""; - } else { - content = fileOutput?.result || ""; - } - - const range = - fileInput?.offset && fileInput?.limit - ? { - start: fileInput.offset, - end: fileInput.offset + fileInput.limit - 1, - } - : undefined; - - openSidebar({ - path: filePath, - content, - range, - action: sidebarAction, - }); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleOpenInSidebar(); - } - }; - - return ( - - ); - } - } - - // Python execution - if (part.type === "data-python" || part.type === "tool-python") { - const pythonInput = part.input as { code?: string }; - const pythonOutput = part.output as { result?: string; output?: string }; - const code = pythonInput?.code || ""; - const output = pythonOutput?.result || pythonOutput?.output || ""; - const codePreview = code.split("\n")[0]?.substring(0, 50); - - if ( - part.state === "input-available" || - part.state === "output-available" - ) { - const handleOpenInSidebar = () => { - openSidebar({ - code, - output, - isExecuting: false, - toolCallId: part.toolCallId || "", - }); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleOpenInSidebar(); - } - }; - - return ( - } - action="Executed Python" - target={codePreview} - isClickable={true} - onClick={handleOpenInSidebar} - onKeyDown={handleKeyDown} - /> - ); - } - } - - // Web search - if (part.type === "tool-web_search" || part.type === "tool-web") { - const webInput = part.input as { query?: string; url?: string }; - const target = webInput?.query || webInput?.url; - - if (part.state === "output-available") { - return ( - } - action={part.type === "tool-web_search" ? "Searched" : "Fetched"} - target={target} - /> - ); - } - } - - // Todo/Memory operations - if (part.type === "tool-todo_write") { - if (part.state === "output-available") { - return ( - } action="Updated todos" /> - ); - } - } - - if (part.type === "tool-update_memory") { - if (part.state === "output-available") { - return } action="Updated memory" />; - } - } - - return null; - }; - return ( <> {/* Shared conversation notice */} @@ -393,15 +110,37 @@ export function SharedMessages({ messages, shareDate }: SharedMessagesProps) { : "w-full prose space-y-3 max-w-none dark:prose-invert min-w-0" } overflow-hidden`} > - {/* Message Parts */} + {/* Message Parts - use SharedMessagePartHandler for consistent rendering */} {isUser ? (
- {otherParts.map((part, idx) => - renderPart(part, idx, isUser), - )} + {otherParts.map((part, idx) => { + // Compute the original index in message.parts for correct reasoning logic + const originalIndex = message.parts.indexOf(part); + return ( + + ); + })}
) : ( - otherParts.map((part, idx) => renderPart(part, idx, isUser)) + otherParts.map((part, idx) => { + // Compute the original index in message.parts for correct reasoning logic + const originalIndex = message.parts.indexOf(part); + return ( + + ); + }) )}
)} diff --git a/app/share/[shareId]/__tests__/SharedMessages.test.tsx b/app/share/[shareId]/__tests__/SharedMessages.test.tsx index d9ab610c..cf1bd4df 100644 --- a/app/share/[shareId]/__tests__/SharedMessages.test.tsx +++ b/app/share/[shareId]/__tests__/SharedMessages.test.tsx @@ -1,12 +1,21 @@ +import React from "react"; import "@testing-library/jest-dom"; import { describe, it, expect } from "@jest/globals"; import { render, screen } from "@testing-library/react"; import { SharedMessages } from "../SharedMessages"; import { SharedChatProvider } from "../SharedChatContext"; +import { TodoBlockProvider } from "@/app/contexts/TodoBlockContext"; +import { GlobalStateProvider } from "@/app/contexts/GlobalState"; // Wrapper component to provide context const renderWithContext = (ui: React.ReactElement) => { - return render({ui}); + return render( + + + {ui} + + , + ); }; describe("SharedMessages", () => { @@ -158,7 +167,7 @@ describe("SharedMessages", () => { renderWithContext( , ); - expect(screen.getByText("Executed")).toBeInTheDocument(); + expect(screen.getByText("Executing")).toBeInTheDocument(); expect(screen.getByText("ls -la")).toBeInTheDocument(); }); }); @@ -173,7 +182,8 @@ describe("SharedMessages", () => { { type: "tool-read_file", state: "output-available", - input: { file_path: "/path/to/file.txt" }, + input: { target_file: "/path/to/file.txt" }, + output: { result: "file content" }, }, ], update_time: mockShareDate, @@ -183,7 +193,7 @@ describe("SharedMessages", () => { renderWithContext( , ); - expect(screen.getByText("Read")).toBeInTheDocument(); + expect(screen.getByText("Reading")).toBeInTheDocument(); expect(screen.getByText("/path/to/file.txt")).toBeInTheDocument(); }); @@ -206,7 +216,7 @@ describe("SharedMessages", () => { renderWithContext( , ); - expect(screen.getByText("Wrote")).toBeInTheDocument(); + expect(screen.getByText("Writing to")).toBeInTheDocument(); expect(screen.getByText("/path/to/new-file.js")).toBeInTheDocument(); }); @@ -229,7 +239,7 @@ describe("SharedMessages", () => { renderWithContext( , ); - expect(screen.getByText("Edited")).toBeInTheDocument(); + expect(screen.getByText("Editing")).toBeInTheDocument(); expect(screen.getByText("/path/to/edited.ts")).toBeInTheDocument(); }); }); @@ -254,7 +264,7 @@ describe("SharedMessages", () => { renderWithContext( , ); - expect(screen.getByText("Executed Python")).toBeInTheDocument(); + expect(screen.getByText("Executing Python")).toBeInTheDocument(); expect(screen.getByText("print('Hello World')")).toBeInTheDocument(); }); }); @@ -269,7 +279,7 @@ describe("SharedMessages", () => { { type: "tool-web_search", state: "output-available", - input: { query: "best practices for testing" }, + input: { queries: ["best practices for testing"] }, }, ], update_time: mockShareDate, @@ -279,7 +289,7 @@ describe("SharedMessages", () => { renderWithContext( , ); - expect(screen.getByText("Searched")).toBeInTheDocument(); + expect(screen.getByText("Searching web")).toBeInTheDocument(); expect( screen.getByText("best practices for testing"), ).toBeInTheDocument(); @@ -296,6 +306,12 @@ describe("SharedMessages", () => { { type: "tool-todo_write", state: "output-available", + input: { todos: [{ content: "Test task", status: "pending" }] }, + output: { + result: "success", + counts: { completed: 0, total: 1 }, + currentTodos: [{ content: "Test task", status: "pending" }], + }, }, ], update_time: mockShareDate, @@ -305,7 +321,8 @@ describe("SharedMessages", () => { renderWithContext( , ); - expect(screen.getByText("Updated todos")).toBeInTheDocument(); + // TodoBlock component renders the todo items + expect(screen.getByText("Test task")).toBeInTheDocument(); }); it("should render memory update tool block", () => { @@ -317,6 +334,7 @@ describe("SharedMessages", () => { { type: "tool-update_memory", state: "output-available", + input: { action: "update" }, }, ], update_time: mockShareDate, @@ -326,7 +344,7 @@ describe("SharedMessages", () => { renderWithContext( , ); - expect(screen.getByText("Updated memory")).toBeInTheDocument(); + expect(screen.getByText("Updating memory")).toBeInTheDocument(); }); }); @@ -343,7 +361,8 @@ describe("SharedMessages", () => { { type: "tool-read_file", state: "output-available", - input: { file_path: "/test.js" }, + input: { target_file: "/test.js" }, + output: { result: "file content" }, }, { type: "text", text: "Here's what I found." }, ], @@ -355,7 +374,7 @@ describe("SharedMessages", () => { , ); expect(screen.getByText("I'll help you with that.")).toBeInTheDocument(); - expect(screen.getByText("Read")).toBeInTheDocument(); + expect(screen.getByText("Reading")).toBeInTheDocument(); expect(screen.getByText("Here's what I found.")).toBeInTheDocument(); }); diff --git a/docker/Dockerfile b/docker/Dockerfile index a8192d53..46dc13aa 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -130,6 +130,7 @@ RUN set -eux; \ golang \ nodejs \ npm \ + tmux \ # Forensics binwalk \ foremost \ @@ -286,6 +287,7 @@ RUN set -eux; \ which python3 && echo "✓ python3" && \ which node && echo "✓ node" && \ which ruby && echo "✓ ruby" && \ + which tmux && echo "✓ tmux" && \ python3 -c "import reportlab; print('✓ reportlab')" && \ python3 -c "import docx; print('✓ python-docx')" && \ python3 -c "import openpyxl; print('✓ openpyxl')" && \ diff --git a/lib/ai/tools/index.ts b/lib/ai/tools/index.ts index 5c6bf7ef..33814e07 100644 --- a/lib/ai/tools/index.ts +++ b/lib/ai/tools/index.ts @@ -5,7 +5,8 @@ import { type SandboxPreference, } from "./utils/hybrid-sandbox-manager"; import { TodoManager } from "./utils/todo-manager"; -import { createRunTerminalCmd } from "./run-terminal-cmd"; +// import { createRunTerminalCmd } from "./run-terminal-cmd"; +import { createShell } from "./shell"; import { createGetTerminalFiles } from "./get-terminal-files"; import { createReadFile } from "./read-file"; import { createWriteFile } from "./write-file"; @@ -44,6 +45,7 @@ export const createTools = ( serviceKey?: string, scopeExclusions?: string, guardrailsConfig?: string, + chatId?: string, ) => { let sandbox: AnySandbox | null = null; @@ -77,6 +79,7 @@ export const createTools = ( userLocation, todoManager, userID, + chatId: chatId || "unknown", assistantMessageId, fileAccumulator, backgroundProcessTracker, @@ -88,7 +91,8 @@ export const createTools = ( // Create all available tools const allTools = { - run_terminal_cmd: createRunTerminalCmd(context), + // run_terminal_cmd: createRunTerminalCmd(context), + shell: createShell(context), get_terminal_files: createGetTerminalFiles(context), read_file: createReadFile(context), write_file: createWriteFile(context), diff --git a/lib/ai/tools/match.ts b/lib/ai/tools/match.ts index 8735d775..f7dcee17 100644 --- a/lib/ai/tools/match.ts +++ b/lib/ai/tools/match.ts @@ -3,50 +3,10 @@ import { z } from "zod"; import type { ToolContext } from "@/types"; import { truncateOutput } from "@/lib/token-utils"; import { waitForSandboxReady } from "./utils/sandbox-health"; +import { buildGlobCommand, buildGrepCommand } from "./utils/bash-commands"; -const MAX_FILES_GLOB = 1000; -const MAX_GREP_LINES = 5000; const TIMEOUT_MS = 60 * 1000; // 60 seconds -/** - * Escape a string for safe use in bash single quotes - */ -const escapeForBashSingleQuote = (str: string): string => { - return str.replace(/'/g, "'\\''"); -}; - -/** - * Build the glob command to find files matching the pattern - */ -const buildGlobCommand = (scope: string): string => { - const escapedScope = escapeForBashSingleQuote(scope); - return `bash -c 'shopt -s globstar nullglob; files=(${escapedScope}); for f in "\${files[@]}"; do [[ -f "$f" ]] && echo "$f"; done | head -n ${MAX_FILES_GLOB}'`; -}; - -/** - * Build the grep command to search file contents - * Uses grep -r, skips binary files with -I - */ -const buildGrepCommand = ( - scope: string, - regex: string, - leading: number, - trailing: number, -): string => { - const escapedRegex = escapeForBashSingleQuote(regex); - - // Build context flags - const contextFlags = [ - leading > 0 ? `-B ${leading}` : "", - trailing > 0 ? `-A ${trailing}` : "", - ] - .filter(Boolean) - .join(" "); - - // grep: -r recursive, -I skip binary, -H filename, -n line numbers, -E extended regex - return `grep -r -I -H -n -E ${contextFlags} '${escapedRegex}' ${scope} 2>/dev/null | head -n ${MAX_GREP_LINES}`; -}; - export const createMatch = (context: ToolContext) => { const { sandboxManager, writer } = context; diff --git a/lib/ai/tools/run-terminal-cmd.ts b/lib/ai/tools/run-terminal-cmd.ts index bc61c4c0..21697aaf 100644 --- a/lib/ai/tools/run-terminal-cmd.ts +++ b/lib/ai/tools/run-terminal-cmd.ts @@ -1,480 +1,480 @@ -import { tool } from "ai"; -import { z } from "zod"; -import { CommandExitError } from "@e2b/code-interpreter"; -import { randomUUID } from "crypto"; -import type { ToolContext } from "@/types"; -import { createTerminalHandler } from "@/lib/utils/terminal-executor"; -import { TIMEOUT_MESSAGE } from "@/lib/token-utils"; -import { BackgroundProcessTracker } from "./utils/background-process-tracker"; -import { terminateProcessReliably } from "./utils/process-termination"; -import { findProcessPid } from "./utils/pid-discovery"; -import { retryWithBackoff } from "./utils/retry-with-backoff"; -import { waitForSandboxReady } from "./utils/sandbox-health"; -import { buildSandboxCommandOptions } from "./utils/sandbox-command-options"; -import { - parseScopeExclusions, - checkCommandScopeExclusion, -} from "./utils/scope-exclusions"; -import { - parseGuardrailConfig, - getEffectiveGuardrails, - checkCommandGuardrails, -} from "./utils/guardrails"; - -const DEFAULT_STREAM_TIMEOUT_SECONDS = 60; -const MAX_TIMEOUT_SECONDS = 600; - -export const createRunTerminalCmd = (context: ToolContext) => { - const { - sandboxManager, - writer, - backgroundProcessTracker, - isE2BSandbox, - scopeExclusions, - guardrailsConfig, - } = context; - const exclusionsList = parseScopeExclusions(scopeExclusions || ""); - - // Parse user guardrail configuration and get effective guardrails - const userGuardrailConfig = parseGuardrailConfig(guardrailsConfig); - const effectiveGuardrails = getEffectiveGuardrails(userGuardrailConfig); - - return tool({ - description: `Execute a command on behalf of the user. -If you have this tool, note that you DO have the ability to run commands directly in the sandbox environment. -Commands execute immediately without requiring user approval. -In using these tools, adhere to the following guidelines: -1. Use command chaining and pipes for efficiency: - - Chain commands with \`&&\` to execute multiple commands together and handle errors cleanly (e.g., \`cd /app && npm install && npm start\`) - - Use pipes \`|\` to pass outputs between commands and simplify workflows (e.g., \`cat log.txt | grep error | wc -l\`) -2. NEVER run code directly via interpreter inline commands (like \`python3 -c "..."\` or \`node -e "..."\`). ALWAYS save code to a file first, then execute the file. -3. For ANY commands that would require user interaction, ASSUME THE USER IS NOT AVAILABLE TO INTERACT and PASS THE NON-INTERACTIVE FLAGS (e.g. --yes for npx). -4. If the command would use a pager, append \` | cat\` to the command. -5. For commands that are long running/expected to run indefinitely until interruption, please run them in the background. To run jobs in the background, set \`is_background\` to true rather than changing the details of the command. EXCEPTION: Never use background mode if you plan to retrieve the output file immediately afterward. -6. Dont include any newlines in the command. -7. Handle large outputs and save scan results to files: - - For complex and long-running scans (e.g., nmap, dirb, gobuster), save results to files using appropriate output flags (e.g., -oN for nmap) if the tool supports it, otherwise use redirect with > operator. - - For large outputs (>10KB expected: sqlmap --dump, nmap -A, nikto full scan): - - Pipe to file: \`sqlmap ... 2>&1 | tee sqlmap_output.txt\` - - Extract relevant information: \`grep -E "password|hash|Database:" sqlmap_output.txt\` - - Anti-pattern: Never let full verbose output return to context (causes overflow) - - Always redirect excessive output to files to avoid context overflow. -8. Install missing tools when needed: Use \`apt install tool\` or \`pip install package\` (no sudo needed in container). -9. After creating files that the user needs (reports, scan results, generated documents), use the get_terminal_files tool to share them as downloadable attachments. -10. For pentesting tools, always use time-efficient flags and targeted scans to keep execution under 7 minutes (e.g., targeted ports for nmap, small wordlists for fuzzing, specific templates for nuclei, vulnerable-only enumeration for wpscan). Timeout handling: On timeout → reduce scope, break into smaller operations. -11. When users make vague requests (e.g., "do recon", "scan this", "check security"), start with fast, lightweight tools and quick scans to provide initial results quickly. Use comprehensive/deep scans only when explicitly requested or after initial findings warrant deeper investigation. - -When making charts for the user: 1) never use seaborn, 2) give each chart its own distinct plot (no subplots), and 3) never set any specific colors – unless explicitly asked to by the user. -I REPEAT: when making charts for the user: 1) use matplotlib over seaborn, 2) give each chart its own distinct plot (no subplots), and 3) never, ever, specify colors or matplotlib styles – unless explicitly asked to by the user - -If you are generating files: -- You MUST use the instructed library for each supported file format. (Do not assume any other libraries are available): - - pdf --> reportlab - - docx --> python-docx - - xlsx --> openpyxl - - pptx --> python-pptx - - csv --> pandas - - rtf --> pypandoc - - txt --> pypandoc - - md --> pypandoc - - ods --> odfpy - - odt --> odfpy - - odp --> odfpy -- If you are generating a pdf: - - You MUST prioritize generating text content using reportlab.platypus rather than canvas - - If you are generating text in korean, chinese, OR japanese, you MUST use the following built-in UnicodeCIDFont. To use these fonts, you must call pdfmetrics.registerFont(UnicodeCIDFont(font_name)) and apply the style to all text elements: - - japanese --> HeiseiMin-W3 or HeiseiKakuGo-W5 - - simplified chinese --> STSong-Light - - traditional chinese --> MSung-Light - - korean --> HYSMyeongJo-Medium -- If you are to use pypandoc, you are only allowed to call the method pypandoc.convert_text and you MUST include the parameter extra_args=['--standalone']. Otherwise the file will be corrupt/incomplete - - For example: pypandoc.convert_text(text, 'rtf', format='md', outputfile='output.rtf', extra_args=['--standalone'])`, - inputSchema: z.object({ - command: z.string().describe("The terminal command to execute"), - explanation: z - .string() - .describe( - "One sentence explanation as to why this command needs to be run and how it contributes to the goal.", - ), - is_background: z - .boolean() - .describe( - "Whether the command should be run in the background. Set to FALSE if you need to retrieve output files immediately after with get_terminal_files. Only use TRUE for indefinite processes where you don't need immediate file access.", - ), - timeout: z - .number() - .optional() - .default(DEFAULT_STREAM_TIMEOUT_SECONDS) - .describe( - `Timeout in seconds to wait for command execution. On timeout, command continues running in background. Capped at ${MAX_TIMEOUT_SECONDS} seconds. Defaults to ${DEFAULT_STREAM_TIMEOUT_SECONDS} seconds.`, - ), - }), - execute: async ( - { - command, - is_background, - timeout, - }: { - command: string; - is_background: boolean; - timeout?: number; - }, - { toolCallId, abortSignal }, - ) => { - // Calculate effective stream timeout (capped at MAX_TIMEOUT_SECONDS) - // This controls how long we wait for output, not how long the command runs - const effectiveStreamTimeout = Math.min( - timeout ?? DEFAULT_STREAM_TIMEOUT_SECONDS, - MAX_TIMEOUT_SECONDS, - ); - // Check guardrails before executing the command - const guardrailResult = checkCommandGuardrails( - command, - effectiveGuardrails, - ); - if (!guardrailResult.allowed) { - return { - result: { - output: "", - exitCode: 1, - error: `Command blocked by security guardrail "${guardrailResult.policyName}": ${guardrailResult.message}. This command pattern has been blocked for safety. If you believe this is a false positive, the user can adjust guardrail settings.`, - }, - }; - } - - // Check scope exclusions before executing the command - const scopeViolation = checkCommandScopeExclusion( - command, - exclusionsList, - ); - if (scopeViolation) { - return { - result: { - output: "", - exitCode: 1, - error: `Command blocked: Target "${scopeViolation.target}" is out of scope. It matches the scope exclusion pattern: ${scopeViolation.exclusion}. This target has been excluded from testing by the user's scope configuration.`, - }, - }; - } - - try { - // Get fresh sandbox and verify it's ready - const { sandbox } = await sandboxManager.getSandbox(); - - // Check for sandbox fallback and notify frontend - const fallbackInfo = sandboxManager.consumeFallbackInfo?.(); - if (fallbackInfo?.occurred) { - writer.write({ - type: "data-sandbox-fallback", - id: `sandbox-fallback-${toolCallId}`, - data: fallbackInfo, - }); - } - - try { - await waitForSandboxReady(sandbox); - } catch (healthError) { - // Sandbox health check failed - force recreation by resetting the cached instance - console.warn( - "[Terminal Command] Sandbox health check failed, recreating sandbox", - ); - - // Reset cached instance to force ensureSandboxConnection to create a fresh one - sandboxManager.setSandbox(null as any); - const { sandbox: freshSandbox } = await sandboxManager.getSandbox(); - - // Verify the fresh sandbox is ready - await waitForSandboxReady(freshSandbox); - - return executeCommand(freshSandbox); - } - - return executeCommand(sandbox); - - async function executeCommand(sandboxInstance: typeof sandbox) { - const terminalSessionId = `terminal-${randomUUID()}`; - let outputCounter = 0; - - const createTerminalWriter = (output: string) => { - writer.write({ - type: "data-terminal", - id: `${terminalSessionId}-${++outputCounter}`, - data: { terminal: output, toolCallId }, - }); - }; - - return new Promise((resolve, reject) => { - let resolved = false; - let execution: any = null; - let handler: ReturnType | null = null; - let processId: number | null = null; // Store PID for all processes - - // Handle abort signal - const onAbort = async () => { - if (resolved) { - return; - } - - // Set resolved IMMEDIATELY to prevent race with retry logic - // This must happen before we kill the process, otherwise the error - // from the killed process might trigger retries - resolved = true; - - // For foreground commands, attempt to discover PID if not already known - if (!processId && !is_background) { - processId = await findProcessPid(sandboxInstance, command); - } - - // Terminate the current process - try { - if ((execution && execution.kill) || processId) { - await terminateProcessReliably( - sandboxInstance, - execution, - processId, - ); - } else { - console.warn( - "[Terminal Command] Cannot kill process: no execution handle or PID available", - ); - } - } catch (error) { - console.error( - "[Terminal Command] Error during abort termination:", - error, - ); - } - - // Clean up and resolve - const result = handler - ? handler.getResult(processId ?? undefined) - : { output: "" }; - if (handler) { - handler.cleanup(); - } - - resolve({ - result: { - output: result.output, - exitCode: 130, // Standard SIGINT exit code - error: "Command execution aborted by user", - }, - }); - }; - - // Check if already aborted before starting - if (abortSignal?.aborted) { - return resolve({ - result: { - output: "", - exitCode: 130, - error: "Command execution aborted by user", - }, - }); - } - - handler = createTerminalHandler( - (output) => createTerminalWriter(output), - { - timeoutSeconds: effectiveStreamTimeout, - onTimeout: async () => { - if (resolved) { - return; - } - - // Try to get PID from execution object first (if available) - if (!processId && execution && (execution as any)?.pid) { - processId = (execution as any).pid; - } - - // For foreground commands on stream timeout, try to discover PID for user reference - // DO NOT kill the process - it may still be working and saving to files - // The process has its own MAX_COMMAND_EXECUTION_TIME timeout via commonOptions - if (!processId && !is_background) { - processId = await findProcessPid(sandboxInstance, command); - } - - createTerminalWriter( - TIMEOUT_MESSAGE( - effectiveStreamTimeout, - processId ?? undefined, - ), - ); - - resolved = true; - const result = handler - ? handler.getResult(processId ?? undefined) - : { output: "" }; - if (handler) { - handler.cleanup(); - } - resolve({ - result: { output: result.output, exitCode: null }, - }); - }, - }, - ); - - // Register abort listener - abortSignal?.addEventListener("abort", onAbort, { once: true }); - - const commonOptions = buildSandboxCommandOptions( - sandboxInstance, - is_background - ? undefined - : { - onStdout: handler!.stdout, - onStderr: handler!.stderr, - }, - ); - - // Determine if an error is a permanent command failure (don't retry) - // vs a transient sandbox issue (do retry) - const isPermanentError = (error: unknown): boolean => { - // Command exit errors are permanent (command ran but failed) - if (error instanceof CommandExitError) { - return true; - } - - if (error instanceof Error) { - // Signal errors (like "signal: killed") are permanent - they occur when - // a process is terminated externally (e.g., by our abort handler). - // We must not retry these as the termination was intentional. - if (error.message.includes("signal:")) { - return true; - } - - // Sandbox termination errors are permanent - return ( - error.name === "NotFoundError" || - error.message.includes("not running anymore") || - error.message.includes("Sandbox not found") - ); - } - - return false; - }; - - // Execute command with retry logic for transient failures - // Sandbox readiness already checked, so these retries handle race conditions - // Retries: 6 attempts with exponential backoff (500ms, 1s, 2s, 4s, 8s, 16s) + jitter (±50ms) - const runPromise: Promise<{ - stdout: string; - stderr: string; - exitCode: number; - pid?: number; - }> = is_background - ? retryWithBackoff( - async () => { - const result = await sandboxInstance.commands.run(command, { - ...commonOptions, - background: true, - }); - // Normalize the result to include exitCode - return { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode ?? 0, - pid: (result as { pid?: number }).pid, - }; - }, - { - maxRetries: 6, - baseDelayMs: 500, - jitterMs: 50, - isPermanentError, - // Retry logs are too noisy - they're expected behavior - logger: () => {}, - }, - ) - : retryWithBackoff( - () => sandboxInstance.commands.run(command, commonOptions), - { - maxRetries: 6, - baseDelayMs: 500, - jitterMs: 50, - isPermanentError, - // Retry logs are too noisy - they're expected behavior - logger: () => {}, - }, - ); - - runPromise - .then(async (exec) => { - execution = exec; - - // Capture PID for background processes - if (is_background && exec?.pid) { - processId = exec.pid; - } - - if (handler) { - handler.cleanup(); - } - - if (!resolved) { - resolved = true; - abortSignal?.removeEventListener("abort", onAbort); - const finalResult = handler - ? handler.getResult(processId ?? undefined) - : { output: "" }; - - // Track background processes with their output files - if (is_background && processId) { - const backgroundOutput = `Background process started with PID: ${processId}\n`; - createTerminalWriter(backgroundOutput); - - const outputFiles = - BackgroundProcessTracker.extractOutputFiles(command); - backgroundProcessTracker.addProcess( - processId, - command, - outputFiles, - ); - } - - resolve({ - result: is_background - ? { - pid: processId, - output: `Background process started with PID: ${processId ?? "unknown"}\n`, - } - : { - exitCode: 0, - output: finalResult.output, - }, - }); - } - }) - .catch((error) => { - if (handler) { - handler.cleanup(); - } - if (!resolved) { - resolved = true; - abortSignal?.removeEventListener("abort", onAbort); - // Handle CommandExitError as a valid result (non-zero exit code) - if (error instanceof CommandExitError) { - const finalResult = handler - ? handler.getResult(processId ?? undefined) - : { output: "" }; - resolve({ - result: { - exitCode: error.exitCode, - output: finalResult.output, - error: error.message, - }, - }); - } else { - reject(error); - } - } - }); - }); - } // end of executeCommand - } catch (error) { - return error as CommandExitError; - } - }, - }); -}; +// import { tool } from "ai"; +// import { z } from "zod"; +// import { CommandExitError } from "@e2b/code-interpreter"; +// import { randomUUID } from "crypto"; +// import type { ToolContext } from "@/types"; +// import { createTerminalHandler } from "@/lib/utils/terminal-executor"; +// import { TIMEOUT_MESSAGE } from "@/lib/token-utils"; +// import { BackgroundProcessTracker } from "./utils/background-process-tracker"; +// import { terminateProcessReliably } from "./utils/process-termination"; +// import { findProcessPid } from "./utils/pid-discovery"; +// import { retryWithBackoff } from "./utils/retry-with-backoff"; +// import { waitForSandboxReady } from "./utils/sandbox-health"; +// import { buildSandboxCommandOptions } from "./utils/sandbox-command-options"; +// import { +// parseScopeExclusions, +// checkCommandScopeExclusion, +// } from "./utils/scope-exclusions"; +// import { +// parseGuardrailConfig, +// getEffectiveGuardrails, +// checkCommandGuardrails, +// } from "./utils/guardrails"; + +// const DEFAULT_STREAM_TIMEOUT_SECONDS = 60; +// const MAX_TIMEOUT_SECONDS = 600; + +// export const createRunTerminalCmd = (context: ToolContext) => { +// const { +// sandboxManager, +// writer, +// backgroundProcessTracker, +// isE2BSandbox, +// scopeExclusions, +// guardrailsConfig, +// } = context; +// const exclusionsList = parseScopeExclusions(scopeExclusions || ""); + +// // Parse user guardrail configuration and get effective guardrails +// const userGuardrailConfig = parseGuardrailConfig(guardrailsConfig); +// const effectiveGuardrails = getEffectiveGuardrails(userGuardrailConfig); + +// return tool({ +// description: `Execute a command on behalf of the user. +// If you have this tool, note that you DO have the ability to run commands directly in the sandbox environment. +// Commands execute immediately without requiring user approval. +// In using these tools, adhere to the following guidelines: +// 1. Use command chaining and pipes for efficiency: +// - Chain commands with \`&&\` to execute multiple commands together and handle errors cleanly (e.g., \`cd /app && npm install && npm start\`) +// - Use pipes \`|\` to pass outputs between commands and simplify workflows (e.g., \`cat log.txt | grep error | wc -l\`) +// 2. NEVER run code directly via interpreter inline commands (like \`python3 -c "..."\` or \`node -e "..."\`). ALWAYS save code to a file first, then execute the file. +// 3. For ANY commands that would require user interaction, ASSUME THE USER IS NOT AVAILABLE TO INTERACT and PASS THE NON-INTERACTIVE FLAGS (e.g. --yes for npx). +// 4. If the command would use a pager, append \` | cat\` to the command. +// 5. For commands that are long running/expected to run indefinitely until interruption, please run them in the background. To run jobs in the background, set \`is_background\` to true rather than changing the details of the command. EXCEPTION: Never use background mode if you plan to retrieve the output file immediately afterward. +// 6. Dont include any newlines in the command. +// 7. Handle large outputs and save scan results to files: +// - For complex and long-running scans (e.g., nmap, dirb, gobuster), save results to files using appropriate output flags (e.g., -oN for nmap) if the tool supports it, otherwise use redirect with > operator. +// - For large outputs (>10KB expected: sqlmap --dump, nmap -A, nikto full scan): +// - Pipe to file: \`sqlmap ... 2>&1 | tee sqlmap_output.txt\` +// - Extract relevant information: \`grep -E "password|hash|Database:" sqlmap_output.txt\` +// - Anti-pattern: Never let full verbose output return to context (causes overflow) +// - Always redirect excessive output to files to avoid context overflow. +// 8. Install missing tools when needed: Use \`apt install tool\` or \`pip install package\` (no sudo needed in container). +// 9. After creating files that the user needs (reports, scan results, generated documents), use the get_terminal_files tool to share them as downloadable attachments. +// 10. For pentesting tools, always use time-efficient flags and targeted scans to keep execution under 7 minutes (e.g., targeted ports for nmap, small wordlists for fuzzing, specific templates for nuclei, vulnerable-only enumeration for wpscan). Timeout handling: On timeout → reduce scope, break into smaller operations. +// 11. When users make vague requests (e.g., "do recon", "scan this", "check security"), start with fast, lightweight tools and quick scans to provide initial results quickly. Use comprehensive/deep scans only when explicitly requested or after initial findings warrant deeper investigation. + +// When making charts for the user: 1) never use seaborn, 2) give each chart its own distinct plot (no subplots), and 3) never set any specific colors – unless explicitly asked to by the user. +// I REPEAT: when making charts for the user: 1) use matplotlib over seaborn, 2) give each chart its own distinct plot (no subplots), and 3) never, ever, specify colors or matplotlib styles – unless explicitly asked to by the user + +// If you are generating files: +// - You MUST use the instructed library for each supported file format. (Do not assume any other libraries are available): +// - pdf --> reportlab +// - docx --> python-docx +// - xlsx --> openpyxl +// - pptx --> python-pptx +// - csv --> pandas +// - rtf --> pypandoc +// - txt --> pypandoc +// - md --> pypandoc +// - ods --> odfpy +// - odt --> odfpy +// - odp --> odfpy +// - If you are generating a pdf: +// - You MUST prioritize generating text content using reportlab.platypus rather than canvas +// - If you are generating text in korean, chinese, OR japanese, you MUST use the following built-in UnicodeCIDFont. To use these fonts, you must call pdfmetrics.registerFont(UnicodeCIDFont(font_name)) and apply the style to all text elements: +// - japanese --> HeiseiMin-W3 or HeiseiKakuGo-W5 +// - simplified chinese --> STSong-Light +// - traditional chinese --> MSung-Light +// - korean --> HYSMyeongJo-Medium +// - If you are to use pypandoc, you are only allowed to call the method pypandoc.convert_text and you MUST include the parameter extra_args=['--standalone']. Otherwise the file will be corrupt/incomplete +// - For example: pypandoc.convert_text(text, 'rtf', format='md', outputfile='output.rtf', extra_args=['--standalone'])`, +// inputSchema: z.object({ +// command: z.string().describe("The terminal command to execute"), +// explanation: z +// .string() +// .describe( +// "One sentence explanation as to why this command needs to be run and how it contributes to the goal.", +// ), +// is_background: z +// .boolean() +// .describe( +// "Whether the command should be run in the background. Set to FALSE if you need to retrieve output files immediately after with get_terminal_files. Only use TRUE for indefinite processes where you don't need immediate file access.", +// ), +// timeout: z +// .number() +// .optional() +// .default(DEFAULT_STREAM_TIMEOUT_SECONDS) +// .describe( +// `Timeout in seconds to wait for command execution. On timeout, command continues running in background. Capped at ${MAX_TIMEOUT_SECONDS} seconds. Defaults to ${DEFAULT_STREAM_TIMEOUT_SECONDS} seconds.`, +// ), +// }), +// execute: async ( +// { +// command, +// is_background, +// timeout, +// }: { +// command: string; +// is_background: boolean; +// timeout?: number; +// }, +// { toolCallId, abortSignal }, +// ) => { +// // Calculate effective stream timeout (capped at MAX_TIMEOUT_SECONDS) +// // This controls how long we wait for output, not how long the command runs +// const effectiveStreamTimeout = Math.min( +// timeout ?? DEFAULT_STREAM_TIMEOUT_SECONDS, +// MAX_TIMEOUT_SECONDS, +// ); +// // Check guardrails before executing the command +// const guardrailResult = checkCommandGuardrails( +// command, +// effectiveGuardrails, +// ); +// if (!guardrailResult.allowed) { +// return { +// result: { +// output: "", +// exitCode: 1, +// error: `Command blocked by security guardrail "${guardrailResult.policyName}": ${guardrailResult.message}. This command pattern has been blocked for safety. If you believe this is a false positive, the user can adjust guardrail settings.`, +// }, +// }; +// } + +// // Check scope exclusions before executing the command +// const scopeViolation = checkCommandScopeExclusion( +// command, +// exclusionsList, +// ); +// if (scopeViolation) { +// return { +// result: { +// output: "", +// exitCode: 1, +// error: `Command blocked: Target "${scopeViolation.target}" is out of scope. It matches the scope exclusion pattern: ${scopeViolation.exclusion}. This target has been excluded from testing by the user's scope configuration.`, +// }, +// }; +// } + +// try { +// // Get fresh sandbox and verify it's ready +// const { sandbox } = await sandboxManager.getSandbox(); + +// // Check for sandbox fallback and notify frontend +// const fallbackInfo = sandboxManager.consumeFallbackInfo?.(); +// if (fallbackInfo?.occurred) { +// writer.write({ +// type: "data-sandbox-fallback", +// id: `sandbox-fallback-${toolCallId}`, +// data: fallbackInfo, +// }); +// } + +// try { +// await waitForSandboxReady(sandbox); +// } catch (healthError) { +// // Sandbox health check failed - force recreation by resetting the cached instance +// console.warn( +// "[Terminal Command] Sandbox health check failed, recreating sandbox", +// ); + +// // Reset cached instance to force ensureSandboxConnection to create a fresh one +// sandboxManager.setSandbox(null as any); +// const { sandbox: freshSandbox } = await sandboxManager.getSandbox(); + +// // Verify the fresh sandbox is ready +// await waitForSandboxReady(freshSandbox); + +// return executeCommand(freshSandbox); +// } + +// return executeCommand(sandbox); + +// async function executeCommand(sandboxInstance: typeof sandbox) { +// const terminalSessionId = `terminal-${randomUUID()}`; +// let outputCounter = 0; + +// const createTerminalWriter = (output: string) => { +// writer.write({ +// type: "data-terminal", +// id: `${terminalSessionId}-${++outputCounter}`, +// data: { terminal: output, toolCallId }, +// }); +// }; + +// return new Promise((resolve, reject) => { +// let resolved = false; +// let execution: any = null; +// let handler: ReturnType | null = null; +// let processId: number | null = null; // Store PID for all processes + +// // Handle abort signal +// const onAbort = async () => { +// if (resolved) { +// return; +// } + +// // Set resolved IMMEDIATELY to prevent race with retry logic +// // This must happen before we kill the process, otherwise the error +// // from the killed process might trigger retries +// resolved = true; + +// // For foreground commands, attempt to discover PID if not already known +// if (!processId && !is_background) { +// processId = await findProcessPid(sandboxInstance, command); +// } + +// // Terminate the current process +// try { +// if ((execution && execution.kill) || processId) { +// await terminateProcessReliably( +// sandboxInstance, +// execution, +// processId, +// ); +// } else { +// console.warn( +// "[Terminal Command] Cannot kill process: no execution handle or PID available", +// ); +// } +// } catch (error) { +// console.error( +// "[Terminal Command] Error during abort termination:", +// error, +// ); +// } + +// // Clean up and resolve +// const result = handler +// ? handler.getResult(processId ?? undefined) +// : { output: "" }; +// if (handler) { +// handler.cleanup(); +// } + +// resolve({ +// result: { +// output: result.output, +// exitCode: 130, // Standard SIGINT exit code +// error: "Command execution aborted by user", +// }, +// }); +// }; + +// // Check if already aborted before starting +// if (abortSignal?.aborted) { +// return resolve({ +// result: { +// output: "", +// exitCode: 130, +// error: "Command execution aborted by user", +// }, +// }); +// } + +// handler = createTerminalHandler( +// (output) => createTerminalWriter(output), +// { +// timeoutSeconds: effectiveStreamTimeout, +// onTimeout: async () => { +// if (resolved) { +// return; +// } + +// // Try to get PID from execution object first (if available) +// if (!processId && execution && (execution as any)?.pid) { +// processId = (execution as any).pid; +// } + +// // For foreground commands on stream timeout, try to discover PID for user reference +// // DO NOT kill the process - it may still be working and saving to files +// // The process has its own MAX_COMMAND_EXECUTION_TIME timeout via commonOptions +// if (!processId && !is_background) { +// processId = await findProcessPid(sandboxInstance, command); +// } + +// createTerminalWriter( +// TIMEOUT_MESSAGE( +// effectiveStreamTimeout, +// processId ?? undefined, +// ), +// ); + +// resolved = true; +// const result = handler +// ? handler.getResult(processId ?? undefined) +// : { output: "" }; +// if (handler) { +// handler.cleanup(); +// } +// resolve({ +// result: { output: result.output, exitCode: null }, +// }); +// }, +// }, +// ); + +// // Register abort listener +// abortSignal?.addEventListener("abort", onAbort, { once: true }); + +// const commonOptions = buildSandboxCommandOptions( +// sandboxInstance, +// is_background +// ? undefined +// : { +// onStdout: handler!.stdout, +// onStderr: handler!.stderr, +// }, +// ); + +// // Determine if an error is a permanent command failure (don't retry) +// // vs a transient sandbox issue (do retry) +// const isPermanentError = (error: unknown): boolean => { +// // Command exit errors are permanent (command ran but failed) +// if (error instanceof CommandExitError) { +// return true; +// } + +// if (error instanceof Error) { +// // Signal errors (like "signal: killed") are permanent - they occur when +// // a process is terminated externally (e.g., by our abort handler). +// // We must not retry these as the termination was intentional. +// if (error.message.includes("signal:")) { +// return true; +// } + +// // Sandbox termination errors are permanent +// return ( +// error.name === "NotFoundError" || +// error.message.includes("not running anymore") || +// error.message.includes("Sandbox not found") +// ); +// } + +// return false; +// }; + +// // Execute command with retry logic for transient failures +// // Sandbox readiness already checked, so these retries handle race conditions +// // Retries: 6 attempts with exponential backoff (500ms, 1s, 2s, 4s, 8s, 16s) + jitter (±50ms) +// const runPromise: Promise<{ +// stdout: string; +// stderr: string; +// exitCode: number; +// pid?: number; +// }> = is_background +// ? retryWithBackoff( +// async () => { +// const result = await sandboxInstance.commands.run(command, { +// ...commonOptions, +// background: true, +// }); +// // Normalize the result to include exitCode +// return { +// stdout: result.stdout, +// stderr: result.stderr, +// exitCode: result.exitCode ?? 0, +// pid: (result as { pid?: number }).pid, +// }; +// }, +// { +// maxRetries: 6, +// baseDelayMs: 500, +// jitterMs: 50, +// isPermanentError, +// // Retry logs are too noisy - they're expected behavior +// logger: () => {}, +// }, +// ) +// : retryWithBackoff( +// () => sandboxInstance.commands.run(command, commonOptions), +// { +// maxRetries: 6, +// baseDelayMs: 500, +// jitterMs: 50, +// isPermanentError, +// // Retry logs are too noisy - they're expected behavior +// logger: () => {}, +// }, +// ); + +// runPromise +// .then(async (exec) => { +// execution = exec; + +// // Capture PID for background processes +// if (is_background && exec?.pid) { +// processId = exec.pid; +// } + +// if (handler) { +// handler.cleanup(); +// } + +// if (!resolved) { +// resolved = true; +// abortSignal?.removeEventListener("abort", onAbort); +// const finalResult = handler +// ? handler.getResult(processId ?? undefined) +// : { output: "" }; + +// // Track background processes with their output files +// if (is_background && processId) { +// const backgroundOutput = `Background process started with PID: ${processId}\n`; +// createTerminalWriter(backgroundOutput); + +// const outputFiles = +// BackgroundProcessTracker.extractOutputFiles(command); +// backgroundProcessTracker.addProcess( +// processId, +// command, +// outputFiles, +// ); +// } + +// resolve({ +// result: is_background +// ? { +// pid: processId, +// output: `Background process started with PID: ${processId ?? "unknown"}\n`, +// } +// : { +// exitCode: 0, +// output: finalResult.output, +// }, +// }); +// } +// }) +// .catch((error) => { +// if (handler) { +// handler.cleanup(); +// } +// if (!resolved) { +// resolved = true; +// abortSignal?.removeEventListener("abort", onAbort); +// // Handle CommandExitError as a valid result (non-zero exit code) +// if (error instanceof CommandExitError) { +// const finalResult = handler +// ? handler.getResult(processId ?? undefined) +// : { output: "" }; +// resolve({ +// result: { +// exitCode: error.exitCode, +// output: finalResult.output, +// error: error.message, +// }, +// }); +// } else { +// reject(error); +// } +// } +// }); +// }); +// } // end of executeCommand +// } catch (error) { +// return error as CommandExitError; +// } +// }, +// }); +// }; diff --git a/lib/ai/tools/shell.ts b/lib/ai/tools/shell.ts new file mode 100644 index 00000000..66c1d793 --- /dev/null +++ b/lib/ai/tools/shell.ts @@ -0,0 +1,460 @@ +import { tool } from "ai"; +import { z } from "zod"; +import { randomUUID } from "crypto"; +import type { ToolContext } from "@/types"; +import { createTerminalHandler } from "@/lib/utils/terminal-executor"; +import { SESSION_MANAGER_PATH } from "./utils/session-manager-script"; +import { + ensureSessionManager, + escapeShellArg, + parseSessionResult, +} from "./utils/session-manager-utils"; +import { retryWithBackoff } from "./utils/retry-with-backoff"; +import { waitForSandboxReady } from "./utils/sandbox-health"; +import { + parseScopeExclusions, + checkCommandScopeExclusion, +} from "./utils/scope-exclusions"; +import { + parseGuardrailConfig, + getEffectiveGuardrails, + checkCommandGuardrails, +} from "./utils/guardrails"; +import { buildSandboxCommandOptions } from "./utils/sandbox-command-options"; + +const DEFAULT_TIMEOUT_SECONDS = 30; +const MAX_TIMEOUT_SECONDS = 300; + +export const createShell = (context: ToolContext) => { + const { sandboxManager, writer, scopeExclusions, guardrailsConfig, chatId } = + context; + const exclusionsList = parseScopeExclusions(scopeExclusions || ""); + + // Parse user guardrail configuration and get effective guardrails + const userGuardrailConfig = parseGuardrailConfig(guardrailsConfig); + const effectiveGuardrails = getEffectiveGuardrails(userGuardrailConfig); + + return tool({ + description: `Interact with persistent shell sessions in the sandbox environment. + + +- \`view\`: View the content of a shell session +- \`exec\`: Execute command in a shell session +- \`wait\`: Wait for the running process in a shell session to return +- \`send\`: Send input to the active process (stdin) in a shell session +- \`kill\`: Terminate the running process in a shell session + + + +- Prioritize using \`write_file\` tool instead of this tool for file content operations to avoid escaping errors +- When using \`view\` action, ensure command has completed execution before using its output +- \`exec\` action will automatically create new shell sessions based on unique identifier +- The default working directory for newly created shell sessions is /home/user +- Working directory will be reset to /home/user in every new shell session; Use \`cd\` command to change directories as needed +- MUST avoid commands that require confirmation; use flags like \`-y\` or \`-f\` for automatic execution +- Avoid commands with excessive output; redirect to files when necessary +- Chain multiple commands with \`&&\` to reduce interruptions and handle errors cleanly +- Use pipes (\`|\`) to simplify workflows by passing outputs between commands +- NEVER run code directly via interpreter commands; MUST save code to a file using the \`write_file\` tool before execution +- Set a short \`timeout\` (such as 5s) for commands that don't return (like starting web servers) to avoid meaningless waiting time +- Commands are NEVER killed on timeout - they keep running in the background; timeout only controls how long to wait for output before returning +- For daemons, servers, or very long-running jobs, append \`&\` to run in background (e.g., \`python app.py > server.log 2>&1 &\`) +- Use \`wait\` action when a command needs additional time to complete and return +- Only use \`wait\` after \`exec\`, and determine whether to wait based on the result of \`exec\` +- DO NOT use \`wait\` for long-running daemon processes +- When using \`send\`, add a newline character (\\n) at the end of the \`input\` parameter to simulate pressing Enter +- For special keys, use official tmux key names: C-c (Ctrl+C), C-d (Ctrl+D), C-z (Ctrl+Z), Up, Down, Left, Right, Home, End, Escape, Tab, Enter, Space, F1-F12, PageUp, PageDown +- For modifier combinations: M-key (Alt), S-key (Shift), C-S-key (Ctrl+Shift) +- Note: Use official tmux names (BSpace not Backspace, DC not Delete, Escape not Esc) +- For non-key strings in \`input\`, DO NOT perform any escaping; send the raw string directly + + + +- Use \`view\` to check shell session history and latest status +- Use \`exec\` to install packages or dependencies +- Use \`exec\` to copy, move, or delete files +- Use \`exec\` to run scripts and tools +- Use \`wait\` to wait for the completion of long-running commands +- Use \`send\` to interact with processes that require user input (e.g., responding to prompts) +- Use \`send\` with special keys like C-c to interrupt, C-d to send EOF +- Use \`kill\` to stop background processes that are no longer needed +- Use \`kill\` to clean up dead or unresponsive processes +- After creating files that the user needs (reports, scan results, generated documents), use the \`get_terminal_files\` tool to share them as downloadable attachments +`, + inputSchema: z.object({ + action: z + .enum(["view", "exec", "wait", "send", "kill"]) + .describe("The action to perform"), + brief: z + .string() + .describe( + "A one-sentence preamble describing the purpose of this operation", + ), + command: z + .string() + .optional() + .describe("The shell command to execute. Required for `exec` action."), + input: z + .string() + .optional() + .describe( + "Input text to send to the interactive session. End with a newline character (\\n) to simulate pressing Enter if needed. Required for `send` action.", + ), + session: z + .string() + .default("default") + .describe("The unique identifier of the target shell session"), + timeout: z + .number() + .int() + .optional() + .default(DEFAULT_TIMEOUT_SECONDS) + .describe( + `Timeout in seconds to wait for command execution. Only used for \`exec\` and \`wait\` actions. Defaults to ${DEFAULT_TIMEOUT_SECONDS} seconds. Max ${MAX_TIMEOUT_SECONDS} seconds.`, + ), + }), + execute: async ( + { + action, + command, + input, + session, + timeout, + }: { + action: "view" | "exec" | "wait" | "send" | "kill"; + brief: string; + command?: string; + input?: string; + session: string; + timeout?: number; + }, + { toolCallId, abortSignal }, + ) => { + // Validate required parameters + if (action === "exec" && !command) { + return { + result: { + content: "The 'command' parameter is required for 'exec' action.", + status: "error" as const, + exitCode: null, + workingDir: "/home/user", + }, + }; + } + + if (action === "send" && input === undefined) { + return { + result: { + content: "The 'input' parameter is required for 'send' action.", + status: "error" as const, + exitCode: null, + workingDir: "/home/user", + }, + }; + } + + // Check guardrails for exec action + if (action === "exec" && command) { + const guardrailResult = checkCommandGuardrails( + command, + effectiveGuardrails, + ); + if (!guardrailResult.allowed) { + return { + result: { + content: `Command blocked by security guardrail "${guardrailResult.policyName}": ${guardrailResult.message}`, + status: "error" as const, + exitCode: null, + workingDir: "/home/user", + }, + }; + } + + // Check scope exclusions + const scopeViolation = checkCommandScopeExclusion( + command, + exclusionsList, + ); + if (scopeViolation) { + return { + result: { + content: `Command blocked: Target "${scopeViolation.target}" is out of scope. It matches the scope exclusion pattern: ${scopeViolation.exclusion}`, + status: "error" as const, + exitCode: null, + workingDir: "/home/user", + }, + }; + } + } + + const effectiveTimeout = Math.min( + timeout && timeout > 0 ? timeout : DEFAULT_TIMEOUT_SECONDS, + MAX_TIMEOUT_SECONDS, + ); + + try { + // Get sandbox + const { sandbox } = await sandboxManager.getSandbox(); + + // Ensure sandbox is ready + try { + await waitForSandboxReady(sandbox); + } catch { + // Reset and retry + sandboxManager.setSandbox(null as never); + const { sandbox: freshSandbox } = await sandboxManager.getSandbox(); + await waitForSandboxReady(freshSandbox); + return executeAction(freshSandbox); + } + + return executeAction(sandbox); + + async function executeAction(sandboxInstance: typeof sandbox) { + // Ensure session manager is installed + const installed = await ensureSessionManager(sandboxInstance); + if (!installed) { + return { + result: { + content: + "Failed to install session manager in sandbox. Please try again.", + status: "error" as const, + exitCode: null, + workingDir: "/home/user", + }, + }; + } + + const terminalSessionId = `session-${randomUUID()}`; + let outputCounter = 0; + let hasStreamedOutput = false; + + const createTerminalWriter = (output: string) => { + if (output) { + writer.write({ + type: "data-terminal", + id: `${terminalSessionId}-${++outputCounter}`, + data: { terminal: output, toolCallId }, + }); + } + }; + + // Parse streaming output from Python script + const parseStreamOutput = (line: string): { output: string; final: boolean } | null => { + if (line.startsWith("STREAM:")) { + try { + const data = JSON.parse(line.slice(7)); + if (data.type === "stream" && data.output) { + return { output: data.output, final: data.final || false }; + } + } catch { + // Ignore parse errors + } + } + return null; + }; + + // Build tmux session name with chatId to avoid session reuse across different chats + // Format: hackerai-{session}-{chatId} + const tmuxSessionName = `hackerai-${session}-${chatId}`; + + // Build the session manager command based on action + let sessionManagerCmd: string; + + switch (action) { + case "view": + sessionManagerCmd = `python3 ${SESSION_MANAGER_PATH} view ${escapeShellArg(tmuxSessionName)}`; + break; + + case "exec": + sessionManagerCmd = `python3 ${SESSION_MANAGER_PATH} exec ${escapeShellArg(tmuxSessionName)} ${escapeShellArg(command!)} ${effectiveTimeout}`; + break; + + case "wait": + sessionManagerCmd = `python3 ${SESSION_MANAGER_PATH} wait ${escapeShellArg(tmuxSessionName)} ${effectiveTimeout}`; + break; + + case "send": + sessionManagerCmd = `python3 ${SESSION_MANAGER_PATH} send ${escapeShellArg(tmuxSessionName)} ${escapeShellArg(input!)}`; + break; + + case "kill": + sessionManagerCmd = `python3 ${SESSION_MANAGER_PATH} kill ${escapeShellArg(tmuxSessionName)}`; + break; + } + + return new Promise((resolve) => { + let resolved = false; + + // Handle abort signal + const onAbort = () => { + if (resolved) return; + resolved = true; + resolve({ + result: { + content: "Operation aborted by user", + status: "error" as const, + exitCode: null, + workingDir: "/home/user", + }, + }); + }; + + if (abortSignal?.aborted) { + return resolve({ + result: { + content: "Operation aborted by user", + status: "error" as const, + exitCode: null, + workingDir: "/home/user", + }, + }); + } + + abortSignal?.addEventListener("abort", onAbort, { once: true }); + + // Calculate total timeout including buffer for session manager + const totalTimeoutMs = (effectiveTimeout + 10) * 1000; + + const handler = createTerminalHandler( + () => { + // Not used directly - we use customStdoutHandler instead + }, + { + timeoutSeconds: effectiveTimeout + 10, + onTimeout: () => { + if (resolved) return; + resolved = true; + handler.cleanup(); + resolve({ + result: { + content: `Session manager operation timed out after ${effectiveTimeout}s`, + status: "error" as const, + exitCode: null, + workingDir: "/home/user", + }, + }); + }, + }, + ); + + // Custom stdout handler that intercepts streaming output + const customStdoutHandler = (data: string) => { + // Process streaming lines + const lines = data.split("\n"); + for (const line of lines) { + const streamData = parseStreamOutput(line); + if (streamData) { + hasStreamedOutput = true; + createTerminalWriter(streamData.output); + } + } + // Also pass to original handler for timeout tracking + handler.stdout(data); + }; + + const commonOptions = buildSandboxCommandOptions(sandboxInstance, { + onStdout: customStdoutHandler, + onStderr: handler.stderr, + }); + + // Execute the session manager command + // Only use retry for idempotent actions (view, wait, kill) + // exec and send are non-idempotent and could cause duplicate effects if retried + const isIdempotentAction = action === "view" || action === "wait" || action === "kill"; + + const executeCommand = () => + sandboxInstance.commands.run(sessionManagerCmd, { + ...commonOptions, + timeoutMs: totalTimeoutMs, + }); + + const commandPromise = isIdempotentAction + ? retryWithBackoff(executeCommand, { + maxRetries: 3, + baseDelayMs: 500, + jitterMs: 50, + isPermanentError: (error: unknown) => { + if (error instanceof Error) { + return ( + error.name === "NotFoundError" || + error.message.includes("not running anymore") || + error.message.includes("signal:") + ); + } + return false; + }, + logger: () => {}, + }) + : executeCommand(); + + commandPromise + .then((exec) => { + handler.cleanup(); + abortSignal?.removeEventListener("abort", onAbort); + + if (resolved) return; + resolved = true; + + // Filter out STREAM: lines from stdout before parsing JSON result + const cleanStdout = exec.stdout + .split("\n") + .filter((line) => !line.startsWith("STREAM:")) + .join("\n"); + + // Parse the result from session manager + const parsedResult = parseSessionResult( + cleanStdout, + exec.stderr, + ); + + // Replace internal tmux session name with user-friendly name in messages + const result = { + ...parsedResult, + content: parsedResult.content.split(tmuxSessionName).join(session), + }; + + // Only write final content if we haven't been streaming + // (streaming already sent the content as deltas) + if (result.content && !hasStreamedOutput) { + createTerminalWriter(result.content); + } + + resolve({ result }); + }) + .catch((error) => { + handler.cleanup(); + abortSignal?.removeEventListener("abort", onAbort); + + if (resolved) return; + resolved = true; + + resolve({ + result: { + content: + error instanceof Error + ? error.message + : "Unknown error occurred", + status: "error" as const, + exitCode: null, + workingDir: "/home/user", + }, + }); + }); + }); + } + } catch (error) { + return { + result: { + content: + error instanceof Error + ? error.message + : "Failed to get sandbox instance", + status: "error" as const, + exitCode: null, + workingDir: "/home/user", + }, + }; + } + }, + }); +}; diff --git a/lib/ai/tools/utils/bash-commands.ts b/lib/ai/tools/utils/bash-commands.ts new file mode 100644 index 00000000..22d20204 --- /dev/null +++ b/lib/ai/tools/utils/bash-commands.ts @@ -0,0 +1,70 @@ +const MAX_FILES_GLOB = 1000; +const MAX_GREP_LINES = 5000; + +/** + * Escape a string for safe use in bash single quotes + */ +export const escapeForBashSingleQuote = (str: string): string => { + return str.replace(/'/g, "'\\''"); +}; + +/** + * Build the glob command to find files matching the pattern + * Uses find command for POSIX compatibility (globstar requires bash 4.0+, macOS ships bash 3.2) + */ +export const buildGlobCommand = (scope: string): string => { + const escapedScope = escapeForBashSingleQuote(scope); + + // Convert glob pattern to find command + // Extract base directory (everything before first wildcard) and pattern + const firstWildcardIndex = scope.search(/[*?[]/); + if (firstWildcardIndex === -1) { + // No wildcards - just check if the file exists + return `test -f '${escapedScope}' && echo '${escapedScope}' || true`; + } + + // Find the base directory (last / before first wildcard) + const baseDir = + scope.substring(0, firstWildcardIndex).replace(/\/[^/]*$/, "") || "/"; + const escapedBaseDir = escapeForBashSingleQuote(baseDir); + + // Convert glob pattern to find -name/-path pattern + // Handle ** (recursive) and * (single level) patterns + const pattern = scope.substring(baseDir.length + 1); // Remove base dir and leading / + + // Use find with -path for patterns containing /, -name otherwise + // ** in glob means "any depth" which find handles with -path + if (pattern.includes("/") || pattern.includes("**")) { + // Convert ** to * for find -path (find's * matches across /) + const findPattern = pattern.replace(/\*\*/g, "*"); + const escapedFindPattern = escapeForBashSingleQuote(findPattern); + return `find '${escapedBaseDir}' -type f -path '*/${escapedFindPattern}' 2>/dev/null | head -n ${MAX_FILES_GLOB}`; + } else { + const escapedPattern = escapeForBashSingleQuote(pattern); + return `find '${escapedBaseDir}' -type f -name '${escapedPattern}' 2>/dev/null | head -n ${MAX_FILES_GLOB}`; + } +}; + +/** + * Build the grep command to search file contents + * Uses grep -r, skips binary files with -I + */ +export const buildGrepCommand = ( + scope: string, + regex: string, + leading: number, + trailing: number, +): string => { + const escapedRegex = escapeForBashSingleQuote(regex); + + // Build context flags + const contextFlags = [ + leading > 0 ? `-B ${leading}` : "", + trailing > 0 ? `-A ${trailing}` : "", + ] + .filter(Boolean) + .join(" "); + + // grep: -r recursive, -I skip binary, -H filename, -n line numbers, -E extended regex + return `grep -r -I -H -n -E ${contextFlags} '${escapedRegex}' ${scope} 2>/dev/null | head -n ${MAX_GREP_LINES}`; +}; diff --git a/lib/ai/tools/utils/session-manager-script.ts b/lib/ai/tools/utils/session-manager-script.ts new file mode 100644 index 00000000..e62416bb --- /dev/null +++ b/lib/ai/tools/utils/session-manager-script.ts @@ -0,0 +1,684 @@ +/** + * Python script that runs inside the sandbox to manage tmux sessions. + * This script is written to the sandbox on first use and handles all session operations. + */ +export const SESSION_MANAGER_SCRIPT = `#!/usr/bin/env python3 +""" +Terminal session manager using tmux for persistent interactive shell sessions. +Provides session management with command completion detection and special key support. + +All responses use a simplified 4-field format: +- content: string - command output or error message +- status: "completed" | "running" | "error" +- exit_code: int | None +- working_dir: string +""" +import subprocess +import sys +import json +import time +import re + +# Custom PS1 for command completion detection +PS1_MARKER = "[SESS_$?]$ " +PS1_PATTERN = r"\\[SESS_(\\d+)\\]\\$" + +def run_cmd(cmd, timeout=30): + """Run a command and return result.""" + try: + result = subprocess.run( + cmd, shell=True, capture_output=True, text=True, timeout=timeout + ) + return result.stdout, result.stderr, result.returncode + except subprocess.TimeoutExpired: + return "", "Command timed out", -1 + except Exception as e: + return "", str(e), -1 + +def ensure_tmux(): + """Ensure tmux is installed, trying multiple package managers if needed.""" + stdout, _, rc = run_cmd("command -v tmux") + if rc == 0 and stdout.strip(): + return True + + # Try different package managers based on what's available + package_managers = [ + ("apt-get", "apt-get update -qq 2>/dev/null && apt-get install -y -qq tmux 2>/dev/null"), + ("apk", "apk add --no-cache tmux 2>/dev/null"), + ("dnf", "dnf install -y -q tmux 2>/dev/null"), + ("yum", "yum install -y -q tmux 2>/dev/null"), + ("pacman", "pacman -Sy --noconfirm tmux 2>/dev/null"), + ] + + for pm_check, install_cmd in package_managers: + pm_stdout, _, pm_rc = run_cmd(f"command -v {pm_check}") + if pm_rc == 0 and pm_stdout.strip(): + run_cmd(install_cmd, timeout=60) + stdout, _, rc = run_cmd("command -v tmux") + if rc == 0 and stdout.strip(): + return True + + return False + +def session_exists(session_id): + """Check if a tmux session exists.""" + _, _, rc = run_cmd(f"tmux has-session -t '{session_id}' 2>/dev/null") + return rc == 0 + +def create_session(session_id, work_dir="/home/user"): + """Create a new tmux session.""" + if session_exists(session_id): + return True + + # Explicitly use bash for consistent PS1 behavior across platforms (macOS defaults to zsh) + cmd = f"tmux new-session -d -s '{session_id}' -c '{work_dir}' 'exec bash --norc --noprofile'" + _, _, rc = run_cmd(cmd) + if rc != 0: + # Fallback: try without explicit bash (in case bash isn't available) + cmd = f"tmux new-session -d -s '{session_id}' -c '{work_dir}'" + _, _, rc = run_cmd(cmd) + if rc != 0: + return False + + time.sleep(0.3) + # Use PROMPT_COMMAND to ensure PS1 is set before every prompt display + # This is more robust than setting PS1 once - it survives commands that try to change it + # Also disable PS2 (continuation prompt) for simpler output parsing + send_keys(session_id, 'export PROMPT_COMMAND=\\'export PS1=\"[SESS_$?]$ \"\\'; export PS2=\"\"', enter=True) + time.sleep(0.3) + send_keys(session_id, "clear", enter=True) + time.sleep(0.2) + + return True + +def send_keys(session_id, keys, enter=True): + """Send keys to a tmux session.""" + escaped_keys = keys.replace("'", "'\\\\''") + + if enter: + cmd = f"tmux send-keys -t '{session_id}' '{escaped_keys}' Enter" + else: + cmd = f"tmux send-keys -t '{session_id}' '{escaped_keys}'" + + _, _, rc = run_cmd(cmd) + return rc == 0 + +def send_special_key(session_id, key): + """Send special key (like C-c, C-d, Up, Down, etc.) to a tmux session.""" + cmd = f"tmux send-keys -t '{session_id}' {key}" + _, _, rc = run_cmd(cmd) + return rc == 0 + +def capture_pane(session_id, history_lines=1000): + """Capture the content of a tmux pane.""" + cmd = f"tmux capture-pane -t '{session_id}' -p -S -{history_lines}" + stdout, _, rc = run_cmd(cmd) + if rc == 0: + return stdout + return "" + +def get_working_dir(session_id): + """Get the current working directory of a tmux session.""" + cmd = f"tmux display-message -t '{session_id}' -p '#{{pane_current_path}}'" + stdout, _, rc = run_cmd(cmd) + if rc == 0 and stdout.strip(): + return stdout.strip() + return "/home/user" + +def clean_output(content, command=None): + """Clean up output by removing internal prompt markers and command echo.""" + if not content: + return "" + + lines = content.split("\\n") + cleaned_lines = [] + prompt_pattern = re.compile(r"^\\[SESS_\\d+\\]\\$ ") + + for line in lines: + if not cleaned_lines and not line.strip(): + continue + + cleaned_line = prompt_pattern.sub("", line) + + if command and not cleaned_lines and cleaned_line.strip() == command.strip(): + continue + + if re.match(r"^\\[SESS_\\d+\\]\\$ $", line.rstrip() + " "): + continue + + cleaned_lines.append(cleaned_line) + + while cleaned_lines and (not cleaned_lines[-1].strip() or re.match(r"^\\[SESS_\\d+\\]\\$ ?$", cleaned_lines[-1].strip())): + cleaned_lines.pop() + + return "\\n".join(cleaned_lines) + +def get_pane_command(session_id): + """Get the current command running in the pane.""" + cmd = f"tmux display-message -t '{session_id}' -p '#{{pane_current_command}}'" + stdout, _, rc = run_cmd(cmd) + if rc == 0: + return stdout.strip() + return "" + +def is_command_running(session_id): + """Check if a command is currently running in the session.""" + pane_cmd = get_pane_command(session_id) + shell_names = {"bash", "zsh", "sh", "fish", "dash", "ash", "ksh", "tcsh", "csh"} + if pane_cmd: + # Normalize the command name: + # - Strip leading dash for login shells (e.g., "-zsh" -> "zsh") + # - Extract basename from full paths (e.g., "/bin/zsh" -> "zsh") + normalized_cmd = pane_cmd.lstrip("-").split("/")[-1] + if normalized_cmd not in shell_names: + return True + + content = capture_pane(session_id) + if not content: + return False + + lines = content.rstrip().split("\\n") + if not lines: + return False + + last_line = lines[-1].rstrip() + return not (last_line.endswith("]$ ") or re.search(PS1_PATTERN, last_line)) + +def get_exit_code(session_id): + """Extract the exit code from the last prompt.""" + content = capture_pane(session_id) + if not content: + return None + + matches = list(re.finditer(PS1_PATTERN, content)) + if matches: + return int(matches[-1].group(1)) + return None + +def is_special_key(key): + """Check if the key is a special tmux key sequence.""" + if not key: + return False + + if key.startswith("C-") and len(key) >= 3: + return True + if key.startswith("M-") and len(key) >= 3: + return True + if key.startswith("S-") and len(key) >= 3: + return True + + if key.startswith("F") and len(key) <= 3: + try: + num = int(key[1:]) + if 1 <= num <= 12: + return True + except ValueError: + pass + + special_keys = { + "Up", "Down", "Left", "Right", + "Home", "End", "PageUp", "PageDown", "PPage", "NPage", + "Enter", "Escape", "Tab", "BTab", + "Space", "BSpace", "DC", "IC" + } + + return key in special_keys + +def wait_for_command_start(session_id, command, max_wait=2.0): + """Wait for the command to appear in the pane output.""" + start_time = time.time() + while time.time() - start_time < max_wait: + content = capture_pane(session_id) + if command in content: + return True + time.sleep(0.1) + return True + +def extract_command_output(pre_content, post_content, command=None): + """Extract only the new output (delta) between pre and post execution content.""" + if not post_content: + return "" + + pre_lines = pre_content.split("\\n") if pre_content else [] + post_lines = post_content.split("\\n") + + start_idx = 0 + pre_line_count = len(pre_lines) + while pre_line_count > 0 and not pre_lines[pre_line_count - 1].strip(): + pre_line_count -= 1 + + start_idx = max(0, pre_line_count - 1) + + prompt_pattern = re.compile(r"^\\[SESS_\\d+\\]\\$ ") + command_found = False + + for i in range(start_idx, len(post_lines)): + line = post_lines[i] + clean_line = prompt_pattern.sub("", line).strip() + + if command and clean_line == command.strip(): + start_idx = i + 1 + command_found = True + break + if command and line.strip().endswith(command.strip()): + start_idx = i + 1 + command_found = True + break + + if not command_found and command: + for i in range(start_idx, len(post_lines)): + if command in post_lines[i]: + start_idx = i + 1 + break + + new_lines = post_lines[start_idx:] + + cleaned_lines = [] + for line in new_lines: + cleaned_line = prompt_pattern.sub("", line) + + if re.match(r"^\\[SESS_\\d+\\]\\$ ?$", line.rstrip() + " "): + continue + + cleaned_lines.append(cleaned_line) + + while cleaned_lines and (not cleaned_lines[-1].strip() or re.match(r"^\\[SESS_\\d+\\]\\$ ?$", cleaned_lines[-1].strip())): + cleaned_lines.pop() + + return "\\n".join(cleaned_lines) + +def stream_output(new_content, is_final=False): + """Output a streaming update to stdout.""" + if new_content: + stream_data = {"type": "stream", "output": new_content, "final": is_final} + print(f"STREAM:{json.dumps(stream_data)}", flush=True) + +def make_result(content, status, exit_code, working_dir): + """Create a standardized result object.""" + # Content passed through - TypeScript handles token-based truncation + return { + "content": content or "", + "status": status, + "exit_code": exit_code, + "working_dir": working_dir + } + +def action_view(session_id): + """View the content of a shell session.""" + if not session_exists(session_id): + return make_result( + f"Session '{session_id}' does not exist. Use 'exec' action to create it.", + "error", + None, + "/home/user" + ) + + raw_content = capture_pane(session_id) + content = clean_output(raw_content) + running = is_command_running(session_id) + exit_code = None if running else get_exit_code(session_id) + working_dir = get_working_dir(session_id) + + status = "running" if running else "completed" + return make_result(content, status, exit_code, working_dir) + +def action_exec(session_id, command, timeout=30, work_dir="/home/user"): + """Execute a command in a shell session.""" + working_dir = work_dir + + # Create session if it doesn't exist + if not session_exists(session_id): + if not create_session(session_id, work_dir): + return make_result( + f"Failed to create session '{session_id}'", + "error", + None, + working_dir + ) + + working_dir = get_working_dir(session_id) + + # Check if something is already running + if is_command_running(session_id): + raw_content = capture_pane(session_id) + content = clean_output(raw_content) + return make_result( + "A command is already running. Use is_input=true to send input to it, or interrupt it first (e.g., with C-c).", + "error", + None, + working_dir + ) + + # Capture pane content BEFORE executing to calculate delta later + pre_exec_content = capture_pane(session_id) + + # Execute the command + send_keys(session_id, command, enter=True) + + # Wait for command to start + time.sleep(0.3) + wait_for_command_start(session_id, command) + time.sleep(0.2) + + # Wait for completion or timeout with streaming updates + start_time = time.time() + consecutive_prompt_checks = 0 + last_streamed_length = 0 + stream_interval = 0.5 + last_stream_time = time.time() + + while time.time() - start_time < timeout: + time.sleep(0.3) + + current_content = capture_pane(session_id) + current_output = extract_command_output(pre_exec_content, current_content, command) + + if current_output and len(current_output) > last_streamed_length: + if time.time() - last_stream_time >= stream_interval: + new_content = current_output[last_streamed_length:] + if new_content.strip(): + stream_output(new_content) + last_streamed_length = len(current_output) + last_stream_time = time.time() + + if not is_command_running(session_id): + consecutive_prompt_checks += 1 + if consecutive_prompt_checks >= 2: + raw_content = capture_pane(session_id) + content = extract_command_output(pre_exec_content, raw_content, command) + exit_code = get_exit_code(session_id) + working_dir = get_working_dir(session_id) + + if content and len(content) > last_streamed_length: + remaining = content[last_streamed_length:] + if remaining.strip(): + stream_output(remaining, is_final=True) + + return make_result(content, "completed", exit_code, working_dir) + else: + consecutive_prompt_checks = 0 + + # Timeout - command still running + raw_content = capture_pane(session_id) + content = extract_command_output(pre_exec_content, raw_content, command) + working_dir = get_working_dir(session_id) + + if content and len(content) > last_streamed_length: + remaining = content[last_streamed_length:] + if remaining.strip(): + stream_output(remaining, is_final=True) + + if content.strip(): + timeout_msg = f"\\n[Command still running after {timeout}s - showing output so far.]" + else: + timeout_msg = f"[Command still running after {timeout}s. No output yet.]" + return make_result(content + timeout_msg, "running", None, working_dir) + +def action_wait(session_id, timeout=30): + """Wait for the running process in a shell session to complete.""" + if not session_exists(session_id): + return make_result( + f"Session '{session_id}' does not exist.", + "error", + None, + "/home/user" + ) + + working_dir = get_working_dir(session_id) + pre_wait_content = capture_pane(session_id) + + # Check if anything is running + if not is_command_running(session_id): + raw_content = capture_pane(session_id) + content = clean_output(raw_content) + exit_code = get_exit_code(session_id) + return make_result(content, "completed", exit_code, working_dir) + + # Wait for completion with streaming updates + start_time = time.time() + last_streamed_length = 0 + stream_interval = 0.5 + last_stream_time = time.time() + + while time.time() - start_time < timeout: + time.sleep(0.3) + + current_content = capture_pane(session_id) + current_output = extract_command_output(pre_wait_content, current_content, None) + + if current_output and len(current_output) > last_streamed_length: + if time.time() - last_stream_time >= stream_interval: + new_content = current_output[last_streamed_length:] + if new_content.strip(): + stream_output(new_content) + last_streamed_length = len(current_output) + last_stream_time = time.time() + + if not is_command_running(session_id): + raw_content = capture_pane(session_id) + content = extract_command_output(pre_wait_content, raw_content, None) + exit_code = get_exit_code(session_id) + working_dir = get_working_dir(session_id) + + if content and len(content) > last_streamed_length: + remaining = content[last_streamed_length:] + if remaining.strip(): + stream_output(remaining, is_final=True) + + return make_result(content, "completed", exit_code, working_dir) + + # Timeout + raw_content = capture_pane(session_id) + content = extract_command_output(pre_wait_content, raw_content, None) + working_dir = get_working_dir(session_id) + + if content and len(content) > last_streamed_length: + remaining = content[last_streamed_length:] + if remaining.strip(): + stream_output(remaining, is_final=True) + + if content.strip(): + timeout_msg = f"\\n[Command still running after {timeout}s - showing output so far.]" + else: + timeout_msg = f"[Command still running after {timeout}s. No output yet.]" + return make_result(content + timeout_msg, "running", None, working_dir) + +def decode_escape_sequences(text): + """Decode common escape sequences from literal strings.""" + try: + return text.encode('raw_unicode_escape').decode('unicode_escape') + except (UnicodeDecodeError, UnicodeEncodeError): + result = text + replacements = [ + ('\\\\n', '\\n'), + ('\\\\t', '\\t'), + ('\\\\r', '\\r'), + ('\\\\\\\\', '\\\\'), + ] + for old, new in replacements: + result = result.replace(old, new) + return result + +def action_send(session_id, input_text): + """Send input to the active process in a shell session.""" + if not session_exists(session_id): + return make_result( + f"Session '{session_id}' does not exist.", + "error", + None, + "/home/user" + ) + + working_dir = get_working_dir(session_id) + + # Check if a command is running - if not, can't send input + if not is_command_running(session_id): + raw_content = capture_pane(session_id) + content = clean_output(raw_content) + return make_result( + "No command is currently running. Cannot send input.", + "error", + None, + working_dir + ) + + # Handle special keys vs regular input + stripped = input_text.strip() + if is_special_key(stripped): + send_special_key(session_id, stripped) + else: + decoded = decode_escape_sequences(input_text) + + if decoded.endswith("\\n"): + send_keys(session_id, decoded[:-1], enter=True) + else: + send_keys(session_id, decoded, enter=False) + + time.sleep(0.3) + raw_content = capture_pane(session_id) + content = clean_output(raw_content) + running = is_command_running(session_id) + working_dir = get_working_dir(session_id) + + if running: + return make_result(content, "running", None, working_dir) + else: + exit_code = get_exit_code(session_id) + return make_result(content, "completed", exit_code, working_dir) + +def get_pane_pid(session_id): + """Get the PID of the process running in the tmux pane.""" + cmd = f"tmux display-message -t '{session_id}' -p '#{{pane_pid}}'" + stdout, _, rc = run_cmd(cmd) + if rc == 0 and stdout.strip(): + try: + return int(stdout.strip()) + except ValueError: + pass + return None + +def get_foreground_pid(pane_pid): + """Get the foreground process PID for a given shell PID.""" + if not pane_pid: + return None + cmd = f"ps -o pid= --ppid {pane_pid} 2>/dev/null | head -1" + stdout, _, rc = run_cmd(cmd) + if rc == 0 and stdout.strip(): + try: + return int(stdout.strip()) + except ValueError: + pass + return None + +def action_kill(session_id): + """Terminate the running process in a shell session.""" + if not session_exists(session_id): + return make_result( + f"Session '{session_id}' does not exist.", + "error", + None, + "/home/user" + ) + + # Try various methods to kill the process + methods = [ + ("C-c", 0.5), + ("C-d", 0.5), + ("C-c", 0.2), + ("C-d", 0.3), + ("C-\\\\", 0.5), + ] + + for key, wait_time in methods: + send_special_key(session_id, key) + time.sleep(wait_time) + + if not is_command_running(session_id): + raw_content = capture_pane(session_id) + content = clean_output(raw_content) + exit_code = get_exit_code(session_id) + working_dir = get_working_dir(session_id) + return make_result(content, "completed", exit_code, working_dir) + + # Try to kill the foreground process directly + pane_pid = get_pane_pid(session_id) + fg_pid = get_foreground_pid(pane_pid) + if fg_pid and fg_pid != pane_pid: + run_cmd(f"kill -9 {fg_pid} 2>/dev/null") + time.sleep(0.5) + + raw_content = capture_pane(session_id) + content = clean_output(raw_content) + running = is_command_running(session_id) + working_dir = get_working_dir(session_id) + + if running: + return make_result( + content + "\\n[Process may still be running]", + "running", + None, + working_dir + ) + else: + exit_code = get_exit_code(session_id) + return make_result(content, "completed", exit_code, working_dir) + +def main(): + if len(sys.argv) < 2: + print(json.dumps(make_result("No action specified", "error", None, "/home/user"))) + sys.exit(1) + + if not ensure_tmux(): + print(json.dumps(make_result( + "tmux is not available and could not be installed. Please install tmux manually.", + "error", + None, + "/home/user" + ))) + sys.exit(1) + + action = sys.argv[1] + + try: + if action == "view": + session_id = sys.argv[2] if len(sys.argv) > 2 else "default" + result = action_view(session_id) + + elif action == "exec": + session_id = sys.argv[2] if len(sys.argv) > 2 else "default" + command = sys.argv[3] if len(sys.argv) > 3 else "" + timeout = int(sys.argv[4]) if len(sys.argv) > 4 else 30 + work_dir = sys.argv[5] if len(sys.argv) > 5 else "/home/user" + result = action_exec(session_id, command, timeout, work_dir) + + elif action == "wait": + session_id = sys.argv[2] if len(sys.argv) > 2 else "default" + timeout = int(sys.argv[3]) if len(sys.argv) > 3 else 30 + result = action_wait(session_id, timeout) + + elif action == "send": + session_id = sys.argv[2] if len(sys.argv) > 2 else "default" + input_text = " ".join(sys.argv[3:]) if len(sys.argv) > 3 else "" + result = action_send(session_id, input_text) + + elif action == "kill": + session_id = sys.argv[2] if len(sys.argv) > 2 else "default" + result = action_kill(session_id) + + else: + result = make_result(f"Unknown action: {action}", "error", None, "/home/user") + + print(json.dumps(result)) + + except Exception as e: + print(json.dumps(make_result(str(e), "error", None, "/home/user"))) + sys.exit(1) + +if __name__ == "__main__": + main() +`; + +/** + * Path where the session manager script is stored in the sandbox + */ +export const SESSION_MANAGER_PATH = "/tmp/.hackerai_session_manager.py"; diff --git a/lib/ai/tools/utils/session-manager-utils.ts b/lib/ai/tools/utils/session-manager-utils.ts new file mode 100644 index 00000000..886d33f1 --- /dev/null +++ b/lib/ai/tools/utils/session-manager-utils.ts @@ -0,0 +1,148 @@ +import { + SESSION_MANAGER_SCRIPT, + SESSION_MANAGER_PATH, +} from "./session-manager-script"; +import { truncateContent, TOOL_DEFAULT_MAX_TOKENS } from "@/lib/token-utils"; + +/** + * Sandbox interface for session manager operations. + */ +export type SandboxForSessionManager = { + commands: { + run: ( + cmd: string, + opts?: { timeoutMs?: number }, + ) => Promise<{ exitCode: number; stdout: string; stderr: string }>; + }; + files: { + write: (path: string, content: string) => Promise; + }; +}; + +/** + * Result type from the session manager script. + * Simplified to 4 fields for cleaner API. + */ +export type SessionResult = { + content: string; + status: "completed" | "running" | "error"; + exitCode: number | null; + workingDir: string; +}; + +// Track which sandboxes have the session manager installed +const installedSandboxes = new WeakMap(); + +/** + * Ensure the session manager script is installed in the sandbox. + * Uses a WeakMap to track installation per sandbox instance. + * Checks actual file content once per execution, updates if different. + */ +export const ensureSessionManager = async ( + sandbox: SandboxForSessionManager, +): Promise => { + // Already verified for this sandbox instance this execution + if (installedSandboxes.get(sandbox)) { + return true; + } + + try { + // Check if script exists and matches current content + const checkResult = await sandbox.commands.run( + `cat ${SESSION_MANAGER_PATH} 2>/dev/null || echo ""`, + { timeoutMs: 5000 }, + ); + + const existingContent = checkResult.stdout; + + // Only write if content differs + if (existingContent.trim() !== SESSION_MANAGER_SCRIPT.trim()) { + await sandbox.files.write(SESSION_MANAGER_PATH, SESSION_MANAGER_SCRIPT); + await sandbox.commands.run(`chmod +x ${SESSION_MANAGER_PATH}`, { + timeoutMs: 5000, + }); + } + + installedSandboxes.set(sandbox, true); + return true; + } catch (error) { + console.error("[Shell Session] Failed to install session manager:", error); + return false; + } +}; + +/** + * Escape a string for use as a shell argument. + * Uses single quotes and escapes any embedded single quotes. + */ +export const escapeShellArg = (arg: string): string => { + // Replace single quotes with '\'' (end quote, escaped quote, start quote) + return `'${arg.replace(/'/g, "'\\''")}'`; +}; + +/** + * Parse the JSON result from the session manager script. + * Truncates content using the same token-based strategy as other tools. + */ +export const parseSessionResult = ( + stdout: string, + stderr: string, +): SessionResult => { + try { + // Find the last line that contains our result JSON + // The session manager outputs JSON on the last non-empty line + const lines = stdout.split("\n"); + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line.startsWith("{") && line.endsWith("}")) { + try { + const parsed = JSON.parse(line); + if ("status" in parsed && "working_dir" in parsed) { + // Truncate content using token-based strategy from lib/token-utils.ts + const content = parsed.content ?? ""; + return { + content: truncateContent(content, undefined, TOOL_DEFAULT_MAX_TOKENS), + status: parsed.status ?? "error", + exitCode: parsed.exit_code ?? null, + workingDir: parsed.working_dir ?? "/home/user", + }; + } + } catch { + // Not valid JSON, continue searching + } + } + } + + // Fallback: try greedy regex for backwards compatibility + const jsonMatch = stdout.match(/\{[\s\S]*\}/); + if (jsonMatch) { + try { + const parsed = JSON.parse(jsonMatch[0]); + const content = parsed.content ?? ""; + return { + content: truncateContent(content, undefined, TOOL_DEFAULT_MAX_TOKENS), + status: parsed.status ?? "error", + exitCode: parsed.exit_code ?? null, + workingDir: parsed.working_dir ?? "/home/user", + }; + } catch { + // JSON parse failed + } + } + + return { + content: stderr || "No JSON response from session manager", + status: "error", + exitCode: null, + workingDir: "/home/user", + }; + } catch { + return { + content: `Failed to parse response: ${stderr || stdout}`, + status: "error", + exitCode: null, + workingDir: "/home/user", + }; + } +}; diff --git a/lib/api/chat-handler.ts b/lib/api/chat-handler.ts index 13410ae4..c80a3a41 100644 --- a/lib/api/chat-handler.ts +++ b/lib/api/chat-handler.ts @@ -236,6 +236,7 @@ export const createChatHandler = () => { process.env.CONVEX_SERVICE_ROLE_KEY, userCustomization?.scope_exclusions, userCustomization?.guardrails_config, + chatId, ); // Helper to send file metadata via stream for resumable stream clients @@ -444,7 +445,7 @@ export const createChatHandler = () => { if (posthog) { // Tools that interact with the sandbox environment const sandboxEnvironmentTools = [ - "run_terminal_cmd", + "shell", "get_terminal_files", "read_file", "write_file", diff --git a/lib/utils/message-processor.ts b/lib/utils/message-processor.ts index 13e8e0fb..b886583d 100644 --- a/lib/utils/message-processor.ts +++ b/lib/utils/message-processor.ts @@ -79,17 +79,22 @@ interface BaseToolPart { // Specific interface for terminal tools that have special data handling interface TerminalToolPart extends BaseToolPart { - type: "tool-run_terminal_cmd"; + type: "tool-run_terminal_cmd" | "tool-shell"; input?: { - command: string; - explanation: string; - is_background: boolean; + command?: string; + input?: string; + action?: string; + explanation?: string; + is_background?: boolean; }; output?: { result: { - exitCode: number; + success?: boolean; + exitCode?: number; stdout?: string; stderr?: string; + output?: string; + content?: string; error?: string; }; }; @@ -258,7 +263,10 @@ const transformIncompleteToolPart = ( terminalDataMap: Map, ): BaseToolPart => { // Handle terminal tools with special terminal output handling - if (toolPart.type === "tool-run_terminal_cmd") { + if ( + toolPart.type === "tool-run_terminal_cmd" || + toolPart.type === "tool-shell" + ) { return transformTerminalToolPart( toolPart as TerminalToolPart, terminalDataMap, @@ -278,6 +286,24 @@ const transformTerminalToolPart = ( ): TerminalToolPart => { const stdout = terminalDataMap.get(terminalPart.toolCallId) || ""; + if (terminalPart.type === "tool-shell") { + return { + type: "tool-shell", + toolCallId: terminalPart.toolCallId, + state: "output-available", + input: terminalPart.input, + output: { + result: { + success: false, + exitCode: 130, + content: stdout, + error: + stdout.length === 0 ? "Operation was stopped/aborted by user" : "", + }, + }, + }; + } + return { type: "tool-run_terminal_cmd", toolCallId: terminalPart.toolCallId, diff --git a/lib/utils/sidebar-utils.ts b/lib/utils/sidebar-utils.ts index d5bd82bc..b8a1cc70 100644 --- a/lib/utils/sidebar-utils.ts +++ b/lib/utils/sidebar-utils.ts @@ -57,8 +57,22 @@ export function extractAllSidebarContent( message.parts.forEach((part) => { // Terminal - if (part.type === "tool-run_terminal_cmd" && part.input?.command) { - const command = part.input.command; + if ( + part.type === "tool-run_terminal_cmd" || + (part.type === "tool-shell" && part.input?.action) + ) { + // For shell tool: use command for exec, input for send, session info for view/wait/kill + let command: string; + if (part.input?.command) { + command = part.input.command; + } else if (part.input?.input) { + command = part.input.input; + } else if (part.input?.session) { + // For view/wait/kill actions, show the session name + command = `Session: ${part.input.session}`; + } else { + command = "shell"; + } // Get streaming output from data-terminal parts const streamingOutput = @@ -69,9 +83,11 @@ export function extractAllSidebarContent( let output = ""; if (result) { - // New format: result.output + // New format: result.output or result.content if (typeof result.output === "string") { output = result.output; + } else if (typeof result.content === "string") { + output = result.content; } // Legacy format: result.stdout + result.stderr else if (result.stdout !== undefined || result.stderr !== undefined) { @@ -92,7 +108,7 @@ export function extractAllSidebarContent( output: finalOutput, isExecuting: part.state === "input-available" || part.state === "running", - isBackground: part.input.is_background, + isBackground: part.input.is_background || false, toolCallId: part.toolCallId || "", }); } diff --git a/packages/local/package.json b/packages/local/package.json index 03d2930e..976dd3d3 100644 --- a/packages/local/package.json +++ b/packages/local/package.json @@ -1,6 +1,6 @@ { "name": "@hackerai/local", - "version": "0.2.0", + "version": "0.2.1", "description": "HackerAI Local Sandbox Client - Execute commands on your local machine", "bin": { "hackerai-local": "./dist/index.js" diff --git a/packages/local/src/utils.ts b/packages/local/src/utils.ts index 15f079f3..71050315 100644 --- a/packages/local/src/utils.ts +++ b/packages/local/src/utils.ts @@ -3,12 +3,13 @@ * Extracted for testability. */ -// Align with LLM context limits: ~2048 tokens ≈ 6000 chars -export const MAX_OUTPUT_SIZE = 6000; +// Large limit to prevent sandbox from breaking structured output (like JSON). +// Actual token-based truncation is handled by the tools themselves. +// This is just a safety limit to prevent memory issues with massive output. +export const MAX_OUTPUT_SIZE = 100000; -// Truncation marker for 25% head + 75% tail strategy -export const TRUNCATION_MARKER = - "\n\n[... OUTPUT TRUNCATED - middle content removed to fit context limits ...]\n\n"; +// Minimal marker - tools handle their own truncation messages +export const TRUNCATION_MARKER = "\n...\n"; /** * Required Docker capabilities for penetration testing tools. diff --git a/types/agent.ts b/types/agent.ts index 8a28607c..8e157afb 100644 --- a/types/agent.ts +++ b/types/agent.ts @@ -32,6 +32,7 @@ export interface ToolContext { userLocation: Geo; todoManager: TodoManager; userID: string; + chatId: string; assistantMessageId?: string; fileAccumulator: FileAccumulator; backgroundProcessTracker: BackgroundProcessTracker; diff --git a/types/chat.ts b/types/chat.ts index 697ef488..a4103537 100644 --- a/types/chat.ts +++ b/types/chat.ts @@ -23,13 +23,20 @@ export interface SidebarFile { modifiedContent?: string; } +export type ShellAction = "view" | "exec" | "wait" | "send" | "kill"; + export interface SidebarTerminal { command: string; output: string; isExecuting: boolean; isBackground?: boolean; + showContentOnly?: boolean; pid?: number | null; toolCallId: string; + /** Shell action type for correct action text display */ + shellAction?: ShellAction; + /** Session name for display in sidebar header */ + sessionName?: string; } export interface SidebarPython {