diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..5a93df98 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Git Workflow + +**Always commit and push changes after completing a task.** Follow these rules: + +1. After making code changes, always commit with a descriptive message +2. Push commits to the current feature branch +3. **NEVER push directly to `main`** - always use feature branches and PRs +4. Before pushing, verify the current branch is not `main` +5. **Open PRs against the `main` branch** +6. After pushing, check if a PR exists for the branch. If not, create one with `gh pr create --base main` +7. **After creating a PR, always wait for explicit user approval before merging.** Never merge PRs autonomously. + +### Starting a New Task + +Checkout main, pull latest, and create your feature branch from there: + +```bash +git checkout main && git pull origin main && git checkout -b +``` + +## Build Commands + +```bash +pnpm install # Install dependencies +pnpm dev # Start dev server +pnpm build # Fetch agent data + production build +pnpm lint # Run ESLint +``` + +## Architecture + +- **Next.js 16** with App Router, React 19 +- `app/` - Pages and API routes + - `app/api/agent/` - AI agent endpoint (Claude Haiku 4.5 via ToolLoopAgent) + - `app/api/fs/` - File serving endpoint + - `app/components/` - Terminal UI components + - `app/components/lite-terminal/` - Custom terminal emulator with ANSI support + - `app/components/terminal-parts/` - Terminal commands, input handling, markdown +- `lib/` - Core business logic: + - `lib/agent/` - AI agent configuration (system instructions, response handling) + - `lib/recoup-api/` - Recoup-API integration (sandbox creation, snapshot persistence) + - `lib/sandbox/` - Vercel Sandbox management (create, restore, snapshot) + +## Key Technologies + +- **AI**: Vercel AI SDK (`ai` package), ToolLoopAgent with Claude Haiku 4.5 +- **Terminal**: `just-bash` (TypeScript bash interpreter), custom `LiteTerminal` emulator +- **Sandbox**: `@vercel/sandbox` for isolated execution environments +- **Auth**: Privy (`@privy-io/react-auth`) +- **Styling**: Tailwind CSS 4, Geist design system + +## Code Principles + +- **SRP (Single Responsibility Principle)**: One exported function per file +- **DRY (Don't Repeat Yourself)**: Extract shared logic into reusable utilities +- **KISS (Keep It Simple)**: Prefer simple solutions over clever ones +- **YAGNI**: Don't build for hypothetical future needs +- **File Organization**: Domain-specific directories (e.g., `lib/sandbox/`, `lib/recoup-api/`) + +## Environment Variables + +- `NEXT_PUBLIC_PRIVY_APP_ID` - Privy authentication +- `NEXT_PUBLIC_VERCEL_ENV` - Environment detection (`production` vs other) for API URL routing diff --git a/app/api/agent/new/route.ts b/app/api/agent/new/route.ts index f9545cff..63f0ba43 100644 --- a/app/api/agent/new/route.ts +++ b/app/api/agent/new/route.ts @@ -1,5 +1,5 @@ import { createFreshSandbox } from "@/lib/sandbox/createFreshSandbox"; -import { handleAgentRequest } from "@/lib/agent/createAgentResponse"; +import { handleAgentRequest } from "@/lib/agent/handleAgentRequest"; import { AGENT_DATA_DIR } from "@/lib/agent/constants"; export async function POST(req: Request) { diff --git a/app/api/agent/route.ts b/app/api/agent/route.ts index 7cde8263..6aa0cb41 100644 --- a/app/api/agent/route.ts +++ b/app/api/agent/route.ts @@ -1,5 +1,5 @@ import { createSnapshotSandbox } from "@/lib/sandbox/createSnapshotSandbox"; -import { handleAgentRequest } from "@/lib/agent/createAgentResponse"; +import { handleAgentRequest } from "@/lib/agent/handleAgentRequest"; import { AGENT_DATA_DIR } from "@/lib/agent/constants"; export async function POST(req: Request) { diff --git a/lib/agent/createAgentResponse.ts b/lib/agent/createAgentResponse.ts index 03c451e0..f5e0e9a3 100644 --- a/lib/agent/createAgentResponse.ts +++ b/lib/agent/createAgentResponse.ts @@ -1,35 +1,14 @@ import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from "ai"; import { createBashTool } from "bash-tool"; import { Sandbox } from "@vercel/sandbox"; +import { after } from "next/server"; import { SANDBOX_CWD, SYSTEM_INSTRUCTIONS, TOOL_PROMPT } from "./constants"; +import { saveSnapshot } from "@/lib/sandbox/saveSnapshot"; -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( +export async function createAgentResponse( sandbox: Sandbox, messages: unknown[], + bearerToken: string, ): Promise { try { const bashToolkit = await createBashTool({ @@ -58,19 +37,32 @@ async function createAgentResponse( const body = response.body; if (body) { const transform = new TransformStream(); - body.pipeTo(transform.writable).finally(() => { + const pipePromise = body.pipeTo(transform.writable); + + // Use after() so Vercel keeps the function alive until + // the snapshot save completes after streaming ends. + after(async () => { + await pipePromise.catch(() => {}); + await saveSnapshot(sandbox, bearerToken); sandbox.stop().catch(() => {}); }); + return new Response(transform.readable, { headers: response.headers, status: response.status, }); } - sandbox.stop().catch(() => {}); + after(async () => { + await saveSnapshot(sandbox, bearerToken); + sandbox.stop().catch(() => {}); + }); return response; } catch (error) { - sandbox.stop().catch(() => {}); + after(async () => { + await saveSnapshot(sandbox, bearerToken); + sandbox.stop().catch(() => {}); + }); throw error; } } diff --git a/lib/agent/handleAgentRequest.ts b/lib/agent/handleAgentRequest.ts new file mode 100644 index 00000000..ea19cbe6 --- /dev/null +++ b/lib/agent/handleAgentRequest.ts @@ -0,0 +1,26 @@ +import { Sandbox } from "@vercel/sandbox"; +import { createAgentResponse } from "./createAgentResponse"; + +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, bearerToken); +} diff --git a/lib/recoup-api/updateAccountSnapshot.ts b/lib/recoup-api/updateAccountSnapshot.ts new file mode 100644 index 00000000..19712984 --- /dev/null +++ b/lib/recoup-api/updateAccountSnapshot.ts @@ -0,0 +1,28 @@ +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 updateAccountSnapshot( + bearerToken: string, + snapshotId: string, +): Promise { + try { + const response = await fetch(`${RECOUP_API_URL}/api/sandboxes`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${bearerToken}`, + }, + body: JSON.stringify({ snapshotId }), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.warn("Failed to update account snapshot:", response.status, errorText); + } + } catch (err) { + console.warn("Error updating account snapshot:", err); + } +} diff --git a/lib/sandbox/saveSnapshot.ts b/lib/sandbox/saveSnapshot.ts new file mode 100644 index 00000000..68eab9c1 --- /dev/null +++ b/lib/sandbox/saveSnapshot.ts @@ -0,0 +1,14 @@ +import { Sandbox } from "@vercel/sandbox"; +import { updateAccountSnapshot } from "@/lib/recoup-api/updateAccountSnapshot"; + +export async function saveSnapshot( + sandbox: Sandbox, + bearerToken: string, +): Promise { + try { + const result = await sandbox.snapshot(); + await updateAccountSnapshot(bearerToken, result.snapshotId); + } catch (err) { + console.warn("Failed to save snapshot:", err); + } +}