From 08edd1f6dc3bacfbd26965f4b506ebc3ea1b81aa Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 13:03:14 -0500 Subject: [PATCH 1/3] feat: save sandbox snapshot before stopping after agent session Replicates the snapshot-saving pattern from Recoup-Tasks so sandbox state is preserved between sessions. After the agent stream finishes, we call sandbox.snapshot() and PATCH /api/sandboxes with the snapshotId before calling sandbox.stop(). Also adds CLAUDE.md with project-specific guidance. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 67 +++++++++++++++++++++++++ lib/agent/createAgentResponse.ts | 16 ++++-- lib/recoup-api/updateAccountSnapshot.ts | 28 +++++++++++ lib/sandbox/saveSnapshot.ts | 14 ++++++ 4 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 CLAUDE.md create mode 100644 lib/recoup-api/updateAccountSnapshot.ts create mode 100644 lib/sandbox/saveSnapshot.ts 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/lib/agent/createAgentResponse.ts b/lib/agent/createAgentResponse.ts index 03c451e0..bcef59b3 100644 --- a/lib/agent/createAgentResponse.ts +++ b/lib/agent/createAgentResponse.ts @@ -2,6 +2,7 @@ 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"; +import { saveSnapshot } from "@/lib/sandbox/saveSnapshot"; type CreateSandbox = (bearerToken: string) => Promise; @@ -24,12 +25,13 @@ export async function handleAgentRequest( const sandbox = await createSandbox(bearerToken); - return createAgentResponse(sandbox, messages); + return createAgentResponse(sandbox, messages, bearerToken); } async function createAgentResponse( sandbox: Sandbox, messages: unknown[], + bearerToken: string, ): Promise { try { const bashToolkit = await createBashTool({ @@ -59,7 +61,9 @@ async function createAgentResponse( if (body) { const transform = new TransformStream(); body.pipeTo(transform.writable).finally(() => { - sandbox.stop().catch(() => {}); + saveSnapshot(sandbox, bearerToken).finally(() => + sandbox.stop().catch(() => {}), + ); }); return new Response(transform.readable, { headers: response.headers, @@ -67,10 +71,14 @@ async function createAgentResponse( }); } - sandbox.stop().catch(() => {}); + saveSnapshot(sandbox, bearerToken).finally(() => + sandbox.stop().catch(() => {}), + ); return response; } catch (error) { - sandbox.stop().catch(() => {}); + saveSnapshot(sandbox, bearerToken).finally(() => + sandbox.stop().catch(() => {}), + ); throw error; } } 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); + } +} From 611c95f90104a12c0d5ea2695f059074bf35a9cf Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 13:20:20 -0500 Subject: [PATCH 2/3] refactor: extract handleAgentRequest into its own file (SRP) Co-Authored-By: Claude Opus 4.6 --- app/api/agent/new/route.ts | 2 +- app/api/agent/route.ts | 2 +- lib/agent/createAgentResponse.ts | 26 +------------------------- lib/agent/handleAgentRequest.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 29 insertions(+), 27 deletions(-) create mode 100644 lib/agent/handleAgentRequest.ts 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 bcef59b3..f395b023 100644 --- a/lib/agent/createAgentResponse.ts +++ b/lib/agent/createAgentResponse.ts @@ -4,31 +4,7 @@ import { Sandbox } from "@vercel/sandbox"; 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, bearerToken); -} - -async function createAgentResponse( +export async function createAgentResponse( sandbox: Sandbox, messages: unknown[], bearerToken: string, 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); +} From c4642f432fc2cf52961c730615adfe496c4c93a7 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 9 Feb 2026 13:33:26 -0500 Subject: [PATCH 3/3] fix: use next/server after() to keep function alive for snapshot save The previous fire-and-forget pattern in .finally() was racing against Vercel's serverless function shutdown. The function would terminate before sandbox.snapshot() and the PATCH call could complete, so snapshots were never persisted. Using after() tells Vercel to keep the function alive until the snapshot save finishes. Co-Authored-By: Claude Opus 4.6 --- lib/agent/createAgentResponse.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/lib/agent/createAgentResponse.ts b/lib/agent/createAgentResponse.ts index f395b023..f5e0e9a3 100644 --- a/lib/agent/createAgentResponse.ts +++ b/lib/agent/createAgentResponse.ts @@ -1,6 +1,7 @@ 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"; @@ -36,25 +37,32 @@ export async function createAgentResponse( const body = response.body; if (body) { const transform = new TransformStream(); - body.pipeTo(transform.writable).finally(() => { - saveSnapshot(sandbox, bearerToken).finally(() => - sandbox.stop().catch(() => {}), - ); + 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, }); } - saveSnapshot(sandbox, bearerToken).finally(() => - sandbox.stop().catch(() => {}), - ); + after(async () => { + await saveSnapshot(sandbox, bearerToken); + sandbox.stop().catch(() => {}); + }); return response; } catch (error) { - saveSnapshot(sandbox, bearerToken).finally(() => - sandbox.stop().catch(() => {}), - ); + after(async () => { + await saveSnapshot(sandbox, bearerToken); + sandbox.stop().catch(() => {}); + }); throw error; } }