diff --git a/app/api/agent/new/route.ts b/app/api/agent/new/route.ts new file mode 100644 index 00000000..f9545cff --- /dev/null +++ b/app/api/agent/new/route.ts @@ -0,0 +1,7 @@ +import { createFreshSandbox } from "@/lib/sandbox/createFreshSandbox"; +import { handleAgentRequest } from "@/lib/agent/createAgentResponse"; +import { AGENT_DATA_DIR } from "@/lib/agent/constants"; + +export async function POST(req: Request) { + return handleAgentRequest(req, () => createFreshSandbox(AGENT_DATA_DIR)); +} diff --git a/app/api/agent/route.ts b/app/api/agent/route.ts index 022fa314..7cde8263 100644 --- a/app/api/agent/route.ts +++ b/app/api/agent/route.ts @@ -1,133 +1,9 @@ -import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from "ai"; -import { createBashTool } from "bash-tool"; -import { Sandbox } from "@vercel/sandbox"; -import { readdirSync, readFileSync } from "fs"; -import { dirname, join, relative } from "path"; -import { fileURLToPath } from "url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const AGENT_DATA_DIR = join(__dirname, "./_agent-data"); -const SANDBOX_CWD = "/vercel/sandbox"; - -const SYSTEM_INSTRUCTIONS = `You are an expert on just-bash, a TypeScript bash interpreter with an in-memory virtual filesystem. - -You have access to a real bash sandbox with the full source code of: -- just-bash/ - The main bash interpreter -- bash-tool/ - AI SDK tool for bash - -The source files are located at ${SANDBOX_CWD}. - -Refer to the README.md of the projects to answer questions about just-bash and bash-tool -themselves which is your main focus. Never talk about this demo implementation unless asked explicitly. - -Use the sandbox to explore the source code, demonstrate commands, and help users understand: -- How to use just-bash and bash-tool -- Bash scripting in general -- The implementation details of just-bash - -Key features of just-bash: -- Pure TypeScript implementation (no WASM dependencies) -- In-memory virtual filesystem -- Supports common bash commands: ls, cat, grep, awk, sed, jq, etc. -- Custom command support via defineCommand -- Network access control with URL allowlists - -Use cat to read files. Use head, tail to read parts of large files. - -Keep responses concise. You have access to a full Linux environment with standard tools.`; - -/** - * Recursively read all files from a directory, returning them in the format - * expected by Sandbox.writeFiles(). - */ -function readSourceFiles( - dir: string, - baseDir?: string -): Array<{ path: string; content: Buffer }> { - const base = baseDir ?? dir; - const files: Array<{ path: string; content: Buffer }> = []; - - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - // Skip node_modules and other large/irrelevant dirs - if (entry.name === "node_modules" || entry.name === ".git") continue; - files.push(...readSourceFiles(fullPath, base)); - } else { - const relPath = relative(base, fullPath); - files.push({ - path: join(SANDBOX_CWD, relPath), - content: readFileSync(fullPath), - }); - } - } - - return files; -} +import { createSnapshotSandbox } from "@/lib/sandbox/createSnapshotSandbox"; +import { handleAgentRequest } from "@/lib/agent/createAgentResponse"; +import { AGENT_DATA_DIR } from "@/lib/agent/constants"; export async function POST(req: Request) { - const authHeader = req.headers.get("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return Response.json( - { error: "Unauthorized" }, - { status: 401 }, - ); - } - - const { messages } = await req.json(); - const lastUserMessage = messages - .filter((m: { role: string }) => m.role === "user") - .pop(); - console.log("Prompt:", lastUserMessage?.parts?.[0]?.text); - - const sandbox = await Sandbox.create(); - - try { - // Upload source files so the agent can explore them - const files = readSourceFiles(AGENT_DATA_DIR); - if (files.length > 0) { - await sandbox.writeFiles(files); - } - - const bashToolkit = await createBashTool({ - sandbox, - destination: SANDBOX_CWD, - }); - - // Create a fresh agent per request for proper streaming - const agent = new ToolLoopAgent({ - model: "claude-haiku-4-5", - instructions: SYSTEM_INSTRUCTIONS, - tools: { - bash: bashToolkit.tools.bash, - }, - stopWhen: stepCountIs(20), - }); - - const response = await createAgentUIStreamResponse({ - agent, - uiMessages: messages, - }); - - // Clean up sandbox after the stream finishes (not before). - // The original `finally` block killed the sandbox immediately when - // createAgentUIStreamResponse returned, before any tool calls ran. - const body = response.body; - if (body) { - const transform = new TransformStream(); - body.pipeTo(transform.writable).finally(() => { - sandbox.stop().catch(() => {}); - }); - return new Response(transform.readable, { - headers: response.headers, - status: response.status, - }); - } - - sandbox.stop().catch(() => {}); - return response; - } catch (error) { - sandbox.stop().catch(() => {}); - throw error; - } + return handleAgentRequest(req, (bearerToken) => + createSnapshotSandbox(bearerToken, AGENT_DATA_DIR), + ); } diff --git a/app/components/Terminal.tsx b/app/components/Terminal.tsx index 0eba43fb..77e7508f 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -32,8 +32,10 @@ function getTheme(isDark: boolean) { export default function TerminalComponent({ getAccessToken, + agentEndpoint, }: { getAccessToken: () => Promise; + agentEndpoint?: string; }) { const terminalRef = useRef(null); @@ -51,7 +53,7 @@ export default function TerminalComponent({ // Create commands const { aboutCmd, installCmd, githubCmd } = createStaticCommands(); - const agentCmd = createAgentCommand(term, getAccessToken); + const agentCmd = createAgentCommand(term, getAccessToken, agentEndpoint); // Files from DOM const files = { @@ -114,7 +116,7 @@ export default function TerminalComponent({ colorSchemeQuery.removeEventListener("change", onColorSchemeChange); term.dispose(); }; - }, [getAccessToken]); + }, [getAccessToken, agentEndpoint]); return (
{ + setMounted(true); + }, []); + + if (!mounted || !ready) { + return ( + <> + {children} + + + ); + } + + if (!authenticated) { + return ( + <> + {children} + +
+ +
+ + ); + } + + return ( + <> + {children} + + + + ); +} diff --git a/app/components/terminal-parts/agent-command.ts b/app/components/terminal-parts/agent-command.ts index b19aadcc..b829b2c3 100644 --- a/app/components/terminal-parts/agent-command.ts +++ b/app/components/terminal-parts/agent-command.ts @@ -20,6 +20,7 @@ function formatForTerminal(text: string): string { export function createAgentCommand( term: TerminalWriter, getAccessToken: () => Promise, + agentEndpoint = "/api/agent", ) { const agentMessages: UIMessage[] = []; let messageIdCounter = 0; @@ -62,7 +63,7 @@ export function createAgentCommand( }; } - const response = await fetch("/api/agent", { + const response = await fetch(agentEndpoint, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/app/new/page.tsx b/app/new/page.tsx new file mode 100644 index 00000000..16af2f34 --- /dev/null +++ b/app/new/page.tsx @@ -0,0 +1,5 @@ +import TerminalPage from "../components/TerminalPage"; + +export default function NewPage() { + return ; +} diff --git a/app/page.tsx b/app/page.tsx index cce7f4bd..2b61f47d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,4 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { usePrivy } from "@privy-io/react-auth"; -import TerminalComponent from "./components/Terminal"; -import { TerminalData } from "./components/TerminalData"; +import TerminalPage from "./components/TerminalPage"; const NOSCRIPT_CONTENT = ` _ _ _ _ @@ -94,66 +89,14 @@ const NOSCRIPT_CONTENT = ` `; export default function Home() { - const [mounted, setMounted] = useState(false); - const { ready, authenticated, login, getAccessToken } = usePrivy(); - - useEffect(() => { - setMounted(true); - }, []); - - if (!mounted || !ready) { - return ( - <> - - - - ); - } - - if (!authenticated) { - return ( - <> - -
- -
- - ); - } - return ( - <> + - - - + ); } diff --git a/lib/agent/constants.ts b/lib/agent/constants.ts new file mode 100644 index 00000000..900abf02 --- /dev/null +++ b/lib/agent/constants.ts @@ -0,0 +1,35 @@ +import { join } from "path"; + +export const AGENT_DATA_DIR = join(process.cwd(), "app/api/agent/_agent-data"); + +export const SANDBOX_CWD = "/vercel/sandbox"; + +export const TOOL_PROMPT = + "Available tools: awk, cat, column, curl, cut, diff, find, git, grep, head, jq, join, nl, node, od, paste, printf, rev, sed, sort, split, strings, tail, tee, tr, uniq, wc, xargs, xxd, and more"; + +export const SYSTEM_INSTRUCTIONS = `You are an expert on just-bash, a TypeScript bash interpreter with an in-memory virtual filesystem. + +You have access to a real bash sandbox with the full source code of: +- just-bash/ - The main bash interpreter +- bash-tool/ - AI SDK tool for bash + +The source files are located at ${SANDBOX_CWD}. + +Refer to the README.md of the projects to answer questions about just-bash and bash-tool +themselves which is your main focus. Never talk about this demo implementation unless asked explicitly. + +Use the sandbox to explore the source code, demonstrate commands, and help users understand: +- How to use just-bash and bash-tool +- Bash scripting in general +- The implementation details of just-bash + +Key features of just-bash: +- Pure TypeScript implementation (no WASM dependencies) +- In-memory virtual filesystem +- Supports common bash commands: ls, cat, grep, awk, sed, jq, etc. +- Custom command support via defineCommand +- Network access control with URL allowlists + +Use cat to read files. Use head, tail to read parts of large files. + +Keep responses concise. You have access to a full Linux environment with standard tools.`; diff --git a/lib/agent/createAgentResponse.ts b/lib/agent/createAgentResponse.ts new file mode 100644 index 00000000..03c451e0 --- /dev/null +++ b/lib/agent/createAgentResponse.ts @@ -0,0 +1,76 @@ +import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from "ai"; +import { createBashTool } from "bash-tool"; +import { Sandbox } from "@vercel/sandbox"; +import { SANDBOX_CWD, SYSTEM_INSTRUCTIONS, TOOL_PROMPT } from "./constants"; + +type CreateSandbox = (bearerToken: string) => Promise; + +export async function handleAgentRequest( + req: Request, + createSandbox: CreateSandbox, +): Promise { + const authHeader = req.headers.get("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const bearerToken = authHeader.slice("Bearer ".length); + + const { messages } = await req.json(); + const lastUserMessage = messages + .filter((m: { role: string }) => m.role === "user") + .pop(); + console.log("Prompt:", lastUserMessage?.parts?.[0]?.text); + + const sandbox = await createSandbox(bearerToken); + + return createAgentResponse(sandbox, messages); +} + +async function createAgentResponse( + sandbox: Sandbox, + messages: unknown[], +): Promise { + try { + const bashToolkit = await createBashTool({ + sandbox, + destination: SANDBOX_CWD, + promptOptions: { + toolPrompt: TOOL_PROMPT, + }, + }); + + const agent = new ToolLoopAgent({ + model: "claude-haiku-4-5", + instructions: SYSTEM_INSTRUCTIONS, + tools: { + bash: bashToolkit.tools.bash, + }, + stopWhen: stepCountIs(20), + }); + + const response = await createAgentUIStreamResponse({ + agent, + uiMessages: messages, + }); + + // Clean up sandbox after the stream finishes (not before). + const body = response.body; + if (body) { + const transform = new TransformStream(); + body.pipeTo(transform.writable).finally(() => { + sandbox.stop().catch(() => {}); + }); + return new Response(transform.readable, { + headers: response.headers, + status: response.status, + }); + } + + sandbox.stop().catch(() => {}); + return response; + } catch (error) { + sandbox.stop().catch(() => {}); + throw error; + } +} diff --git a/lib/recoup-api/createSandbox.ts b/lib/recoup-api/createSandbox.ts new file mode 100644 index 00000000..24e13208 --- /dev/null +++ b/lib/recoup-api/createSandbox.ts @@ -0,0 +1,27 @@ +const IS_PROD = process.env.NEXT_PUBLIC_VERCEL_ENV === "production"; +const RECOUP_API_URL = IS_PROD + ? "https://recoup-api.vercel.app" + : "https://test-recoup-api.vercel.app"; + +export async function createSandbox( + bearerToken: string, +): Promise { + try { + const response = await fetch(`${RECOUP_API_URL}/api/sandboxes`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${bearerToken}`, + }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) return null; + + const data = await response.json(); + return data?.sandboxes?.[0]?.sandboxId ?? null; + } catch { + return null; + } +} diff --git a/lib/sandbox/createFreshSandbox.ts b/lib/sandbox/createFreshSandbox.ts new file mode 100644 index 00000000..8026447c --- /dev/null +++ b/lib/sandbox/createFreshSandbox.ts @@ -0,0 +1,13 @@ +import { Sandbox } from "@vercel/sandbox"; +import { readSourceFiles } from "./readSourceFiles"; + +export async function createFreshSandbox(agentDataDir: string): Promise { + const sandbox = await Sandbox.create(); + + const files = readSourceFiles(agentDataDir); + if (files.length > 0) { + await sandbox.writeFiles(files); + } + + return sandbox; +} diff --git a/lib/sandbox/createSnapshotSandbox.ts b/lib/sandbox/createSnapshotSandbox.ts new file mode 100644 index 00000000..935b753e --- /dev/null +++ b/lib/sandbox/createSnapshotSandbox.ts @@ -0,0 +1,20 @@ +import { Sandbox } from "@vercel/sandbox"; +import { createSandbox } from "@/lib/recoup-api/createSandbox"; +import { createFreshSandbox } from "./createFreshSandbox"; + +export async function createSnapshotSandbox( + bearerToken: string, + agentDataDir: string, +): Promise { + const sandboxId = await createSandbox(bearerToken); + + if (sandboxId) { + try { + return await Sandbox.get({ sandboxId }); + } catch (err) { + console.warn("Snapshot sandbox connection failed, falling back:", err); + } + } + + return createFreshSandbox(agentDataDir); +} diff --git a/lib/sandbox/readSourceFiles.ts b/lib/sandbox/readSourceFiles.ts new file mode 100644 index 00000000..3d0e5afc --- /dev/null +++ b/lib/sandbox/readSourceFiles.ts @@ -0,0 +1,27 @@ +import { readdirSync, readFileSync } from "fs"; +import { join, relative } from "path"; +import { SANDBOX_CWD } from "@/lib/agent/constants"; + +export function readSourceFiles( + dir: string, + baseDir?: string, +): Array<{ path: string; content: Buffer }> { + const base = baseDir ?? dir; + const files: Array<{ path: string; content: Buffer }> = []; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === ".git") continue; + files.push(...readSourceFiles(fullPath, base)); + } else { + const relPath = relative(base, fullPath); + files.push({ + path: join(SANDBOX_CWD, relPath), + content: readFileSync(fullPath), + }); + } + } + + return files; +}