## File: README.md

# Open Agents

[](https://vercel.com/new/clone?project-name=open-agents&repository-name=open-agents&repository-url=https%3A%2F%2Fgithub.com%2Fvercel-labs%2Fopen-agents&demo-title=Open+Agents&demo-description=Open-source+reference+app+for+building+and+running+background+coding+agents+on+Vercel.&demo-url=https%3A%2F%2Fopen-agents.dev%2F&env=POSTGRES_URL%2CBETTER_AUTH_SECRET%2CNEXT_PUBLIC_VERCEL_APP_CLIENT_ID%2CVERCEL_APP_CLIENT_SECRET%2CNEXT_PUBLIC_GITHUB_CLIENT_ID%2CGITHUB_CLIENT_SECRET%2CGITHUB_APP_ID%2CGITHUB_APP_PRIVATE_KEY%2CNEXT_PUBLIC_GITHUB_APP_SLUG%2CGITHUB_WEBHOOK_SECRET&envDescription=Neon+can+provide+POSTGRES_URL+automatically.+Generate+BETTER_AUTH_SECRET+yourself%2C+then+add+your+Vercel+OAuth+and+GitHub+App+credentials+for+a+full+deployment.&products=%255B%257B%2522type%2522%253A%2522integration%2522%252C%2522protocol%2522%253A%2522storage%2522%252C%2522productSlug%2522%253A%2522neon%2522%252C%2522integrationSlug%2522%253A%2522neon%2522%257D%252C%257B%2522type%2522%253A%2522integration%2522%252C%2522protocol%2522%253A%2522storage%2522%252C%2522productSlug%2522%253A%2522upstash-kv%2522%252C%2522integrationSlug%2522%253A%2522upstash%2522%257D%255D&skippable-integrations=1)

Open Agents is an open-source reference app for building and running background coding agents on Vercel. It includes the web UI, the agent runtime, sandbox orchestration, and the GitHub integration needed to go from prompt to code changes without keeping your laptop involved.

The repo is meant to be forked and adapted, not treated as a black box.

## What it is

Open Agents is a three-layer system:

```text
Web -> Agent workflow -> Sandbox VM
```

- The web app handles auth, sessions, chat, and streaming UI.
- The agent runs as a durable workflow on Vercel.
- The sandbox is the execution environment: filesystem, shell, git, dev servers, and preview ports.

### The key architectural decision: the agent is not the sandbox

The agent does not run inside the VM. It runs outside the sandbox and interacts with it through tools like file reads, edits, search, and shell commands.

That separation is the main point of the project:

- agent execution is not tied to a single request lifecycle
- sandbox lifecycle can hibernate and resume independently
- model/provider choices and sandbox implementation can evolve separately
- the VM stays a plain execution environment instead of becoming the control plane

## Current capabilities

- chat-driven coding agent with file, search, shell, task, skill, and web tools
- durable multi-step execution with Workflow SDK-backed runs, streaming, and cancellation
- isolated Vercel sandboxes with snapshot-based resume
- repo cloning and branch work inside the sandbox
- optional auto-commit, push, and PR creation after a successful run
- session sharing via read-only links
- optional voice input via ElevenLabs transcription

## Runtime notes

A few details that matter for understanding the current implementation:

- Chat requests start a workflow run instead of executing the agent inline.
- Each agent turn can continue across many persisted workflow steps.
- Active runs can be resumed by reconnecting to the stream for the existing workflow.
- Sandboxes expose ports `3000`, `5173`, `4321`, and `8000`, can optionally use a configured base snapshot, and hibernate after inactivity.
- Auto-commit and auto-PR are supported, but they are preference-driven features, not always-on behavior.

## Environment variables

See `apps/web/.env.example` for the full list. Summary:

### Minimum runtime

```env
POSTGRES_URL=
BETTER_AUTH_SECRET=
```

### Required for sign-in (Vercel OAuth)

```env
NEXT_PUBLIC_VERCEL_APP_CLIENT_ID=
VERCEL_APP_CLIENT_SECRET=
```

### Required for GitHub repo access, pushes, and PRs

```env
NEXT_PUBLIC_GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
NEXT_PUBLIC_GITHUB_APP_SLUG=
GITHUB_WEBHOOK_SECRET=
```

### Optional

```env
REDIS_URL=
KV_URL=
OPEN_AGENTS_RESOURCE_PROFILE=
VERCEL_PROJECT_PRODUCTION_URL=
NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL=
VERCEL_SANDBOX_BASE_SNAPSHOT_ID=
ELEVENLABS_API_KEY=
```

- `REDIS_URL` / `KV_URL`: optional skills metadata cache (falls back to in-memory when not configured).
- `OPEN_AGENTS_RESOURCE_PROFILE`: optional deployment resource profile. Set to `hobby` to use Hobby-compatible defaults for chat and sandbox resources; leave unset for standard behavior.
- `VERCEL_PROJECT_PRODUCTION_URL` / `NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL`: canonical production URL for metadata and some callback behavior.
- `VERCEL_SANDBOX_BASE_SNAPSHOT_ID`: optional base snapshot for fresh sandboxes. If unset, sandboxes start from Vercel's standard Sandbox runtime. Use a snapshot created in/accessible to your own Vercel scope.
- `ELEVENLABS_API_KEY`: voice transcription.

## Deploy your own copy on Vercel

1. Fork this repo.
2. Import the repo into Vercel. Neon Postgres is auto-provisioned if you use the deploy button above.
3. Generate a secret for session signing:

   ```bash
   openssl rand -base64 32   # BETTER_AUTH_SECRET
   ```

4. Add env vars in Vercel project settings:

   ```env
   POSTGRES_URL=
   BETTER_AUTH_SECRET=
   ```

5. Deploy once to get a stable production URL.
6. Create a Vercel OAuth app with callback URL:

   ```text
   https://YOUR_DOMAIN/api/auth/callback/vercel
   ```

7. Add these env vars and redeploy:

   ```env
   NEXT_PUBLIC_VERCEL_APP_CLIENT_ID=
   VERCEL_APP_CLIENT_SECRET=
   ```

8. If you want the full GitHub-enabled coding-agent flow, create a GitHub App using:

   - Homepage URL: `https://YOUR_DOMAIN`
   - Callback URL: `https://YOUR_DOMAIN/api/auth/callback/github`
   - Setup URL: `https://YOUR_DOMAIN/api/github/app/callback`

   In the GitHub App settings:
   - use the GitHub App's Client ID and Client Secret for `NEXT_PUBLIC_GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`
   - make the app public if you want org installs to work cleanly

9. Add the GitHub App env vars and redeploy.
10. Optionally add Redis/KV, `OPEN_AGENTS_RESOURCE_PROFILE=hobby` for Hobby-compatible resource defaults, the canonical production URL vars, and your own `VERCEL_SANDBOX_BASE_SNAPSHOT_ID` if you want fresh sandboxes to start from a preconfigured image.

## Local setup

1. Install dependencies:

   ```bash
   corepack enable
   pnpm install
   ```

2. Create your local env file:

   ```bash
   cp apps/web/.env.example apps/web/.env
   ```

3. Fill in the required values in `apps/web/.env`.
4. Start the app:

   ```bash
   pnpm web
   ```

If you already have a linked Vercel project, you can pull env vars locally with `vc env pull`.

## OAuth and integration setup

### Vercel OAuth

Authentication is handled by [Better Auth](https://www.better-auth.com/) with Vercel and GitHub as social providers. All auth routes are served from the `/api/auth/[...all]` catchall.

Create a Vercel OAuth app and use this callback:

```text
https://YOUR_DOMAIN/api/auth/callback/vercel
```

For local development, use:

```text
http://localhost:3000/api/auth/callback/vercel
```

Then set:

```env
NEXT_PUBLIC_VERCEL_APP_CLIENT_ID=...
VERCEL_APP_CLIENT_SECRET=...
```

### GitHub App

You do not need a separate GitHub OAuth app. Open Agents uses the GitHub App's OAuth credentials as a Better Auth social provider, plus the App's installation tokens for repo access.

Create a GitHub App for installation-based repo access and configure:

- Homepage URL: `https://YOUR_DOMAIN`
- Callback URL: `https://YOUR_DOMAIN/api/auth/callback/github`
- Setup URL: `https://YOUR_DOMAIN/api/github/app/callback`
- make the app public if you want org installs to work cleanly

For local development, use `http://localhost:3000` as the homepage URL, `http://localhost:3000/api/auth/callback/github` as the callback URL, and `http://localhost:3000/api/github/app/callback` as the setup URL.

Then set:

```env
NEXT_PUBLIC_GITHUB_CLIENT_ID=...   # GitHub App Client ID
GITHUB_CLIENT_SECRET=...           # GitHub App Client Secret
GITHUB_APP_ID=...
GITHUB_APP_PRIVATE_KEY=...
NEXT_PUBLIC_GITHUB_APP_SLUG=...
GITHUB_WEBHOOK_SECRET=...
```

`GITHUB_APP_PRIVATE_KEY` can be stored as the PEM contents with escaped newlines or as a base64-encoded PEM.

## Useful commands

```bash
pnpm web                    # run dev server
pnpm check                  # lint + format check
pnpm fix                    # lint + format fix
pnpm typecheck              # typecheck all packages
pnpm run ci                 # full CI: check, typecheck, tests, migration check
pnpm sandbox:snapshot-base  # refresh sandbox base snapshot
```

## Repo layout

```text
apps/web         Next.js app, workflows, auth, chat UI
packages/agent   agent implementation, tools, subagents, skills
packages/sandbox sandbox abstraction and Vercel sandbox integration
packages/shared  shared utilities
```

---

## File: apps/web/docs/design-system.md

# Design System

This document defines the visual design system used across the Open Agents web application, particularly for unauthenticated/marketing pages.

## Design Philosophy

The design follows a **dark terminal aesthetic** that reflects the developer-focused, CLI-centric nature of Open Agents. The visual language communicates:

- **Technical credibility** - feels like a tool built by developers, for developers
- **Terminal familiarity** - macOS window chrome, command prompts, monospace typography
- **Modern polish** - glassmorphism, ambient lighting, subtle animations

## Color Palette

### Background Colors

| Token | Value | Usage |
|-------|-------|-------|
| `bg-primary` | `#0a0a0b` | Main page background |
| `bg-card` | `#111113` | Card/panel backgrounds |
| `bg-subtle` | `white/[0.02]` | Subtle surface elevation |
| `bg-hover` | `white/[0.04]` | Hover states |

### Text Colors

| Token | Value | Usage |
|-------|-------|-------|
| `text-primary` | `white` | Headings, primary text |
| `text-secondary` | `white/50` | Body text, descriptions |
| `text-muted` | `white/40` | Secondary descriptions |
| `text-subtle` | `white/30` | Hints, labels, metadata |

### Border Colors

| Token | Value | Usage |
|-------|-------|-------|
| `border-default` | `white/[0.08]` | Card borders, dividers |
| `border-subtle` | `white/[0.06]` | Subtle separators |
| `border-hover` | `white/[0.12]` | Hover state borders |

### Accent Colors

| Token | Value | Usage |
|-------|-------|-------|
| `accent-emerald` | `emerald-400` / `emerald-500` | Success, terminal prompts, status indicators |
| `accent-blue` | `blue-400` / `blue-500` | Links, secondary highlights |
| `accent-violet` | `violet-400` / `violet-500` | Tertiary accents |
| `accent-amber` | `amber-400` | Warnings |

### macOS Window Chrome

| Element | Color |
|---------|-------|
| Close button | `#ff5f57` |
| Minimize button | `#febc2e` |
| Maximize button | `#28c840` |

## Background Effects

### Ambient Glow

Large, blurred gradient orbs that create depth and atmosphere:

```tsx
{/* Primary glow - top center */}


{/* Secondary glow - bottom right */}


{/* Tertiary glow - left */}

```

### Dot Grid Pattern

Subtle dot matrix for texture:

```tsx

```

### Scanline Effect

Retro CRT-style scanlines for terminal feel:

```tsx

```

## Components

### Terminal Window Card

macOS-style window with traffic light controls:

```tsx

  {/* Window chrome */}
  
    
      
      
      
    
    
      terminal
    
  
  
  {/* Content */}
  
    {/* ... */}
  

```

### Command Prompt

Animated terminal prompt with blinking cursor:

```tsx

  
    $
    openharness auth login
    
  
  
    ! Authentication required
  

```

### Icon Container

Bordered container for icons:

```tsx

  

```

### Primary Button (CTA)

High-contrast white button on dark background:

```tsx

  Sign in with GitHub

```

### Status Badge

Pill-shaped badge with animated indicators:

```tsx

  
    
    
    
  
  Status text

```

### Gradient Divider

Horizontal divider that fades at edges:

```tsx

```

## Typography

### Headings

- **H1**: `text-4xl sm:text-5xl font-semibold tracking-tight text-white`
- **H2**: `text-lg font-medium tracking-tight text-white`

### Body Text

- **Primary**: `text-base leading-relaxed text-white/50`
- **Secondary**: `text-sm leading-relaxed text-white/40`

### Monospace

- **Code/Terminal**: `font-mono text-sm text-white/60`
- **Labels**: `font-mono text-xs text-white/30`

## Spacing

- Card padding: `p-5` or `p-6`
- Section gaps: `gap-4` to `gap-6`
- Element margins: `mb-2` to `mb-6`

## Shadows

- Card shadow: `shadow-2xl shadow-black/20`
- Deep shadow: `shadow-2xl shadow-black/50`

## Animations

- **Pulse**: `animate-pulse` for cursor blink, status indicators
- **Transitions**: `transition-all` or `transition-colors` for hover states

## Icons

Use inline SVGs for consistency. Common icons:

### Terminal Icon
```tsx
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
  
  <line x1="12" y1="19" x2="20" y2="19" />
</svg>
```

### Lock Icon
```tsx
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
  <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
  
</svg>
```

### Shield Icon
```tsx
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
  
</svg>
```

### Cloud Icon
```tsx
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
  
</svg>
```

## Usage Guidelines

1. **Consistency**: Use the terminal window card for all prominent UI containers on marketing pages
2. **Hierarchy**: Use ambient glows sparingly - one primary (emerald), one or two secondary (blue/violet)
3. **Text contrast**: Ensure sufficient contrast - primary text at `white`, body at `white/50` minimum
4. **Interactivity**: All interactive elements should have visible hover states
5. **Responsiveness**: Use `sm:` breakpoint modifiers for mobile adaptations

---

## File: apps/web/docs/diff-viewer-plan.md

# Diff Viewer Implementation Plan

## Overview

The diff viewer shows users all changes made by the agent in real-time, comparing the current sandbox state against HEAD (essentially all staged + unstaged changes).

**Data Flow:**
```
Agent modifies files -> Tool completes -> refreshKey++ -> DiffViewer fetches -> Git commands run -> UI updates
```

The key insight is that it's not polling continuously - it only refreshes when the agent actually modifies something, keeping it efficient while staying up-to-date.

---

## 1. API Route: `/api/tasks/[taskId]/diff`

**Location:** `apps/web/app/api/tasks/[id]/diff/route.ts`

### Responsibilities

- Validate sandbox is active via `sandboxId` query param
- Run `git diff HEAD` for full unified diff
- Run `git diff HEAD --stat` for file statistics
- Run `git diff HEAD --name-status` for file status (A/M/D/R)
- Parse output into structured response

### Response Schema

```typescript
type DiffFile = {
  path: string;
  status: "added" | "modified" | "deleted" | "renamed";
  additions: number;
  deletions: number;
  diff: string; // Raw unified diff for this file
  oldPath?: string; // For renamed files
};

type DiffResponse = {
  files: DiffFile[];
  summary: {
    totalFiles: number;
    totalAdditions: number;
    totalDeletions: number;
  };
};
```

### Implementation Notes

- Follow pattern from `generate-pr/route.ts` - use `connectVercelSandbox({ sandboxId })`
- Parse `--stat` output for per-file stats (format: `filename | 10 ++++------`)
- Parse `--name-status` for file status:
  - `A` = added
  - `M` = modified
  - `D` = deleted
  - `R` = renamed (includes old path)
- Split full diff by file headers (`diff --git a/... b/...`)

### Example Git Commands

```bash
# Get file status
git diff HEAD --name-status

# Get stats per file
git diff HEAD --stat

# Get full unified diff
git diff HEAD
```

---

## 2. UI Component: `diff-viewer.tsx`

**Location:** `apps/web/app/tasks/[id]/diff-viewer.tsx`

### Component Structure

```
DiffViewer
├── Header
│   ├── Tabs (Diff | Logs)
│   ├── Window controls (split, close)
│   └── Total stats badge (+51 -0)
├── FileList
│   └── FileEntry (collapsible)
│       ├── Chevron
│       ├── File path
│       ├── Status badge (New/Modified/Deleted/Renamed)
│       ├── Per-file stats
│       └── Actions menu (optional)
└── ExpandedDiff (when file is expanded)
    ├── Line numbers gutter
    ├── +/- prefix column
    └── Code content with syntax highlighting
```

### Props Interface

```typescript
type DiffViewerProps = {
  sandboxId: string;
  refreshKey: number;
  onClose: () => void;
};
```

### Styling (based on reference image)

- Width: `w-[500px]` (matches existing placeholder)
- Dark theme with:
  - Green additions: `bg-green-950/50`, `text-green-500`
  - Red deletions: `bg-red-950/50`, `text-red-400`
- Monospace font for code: `font-mono text-xs`
- Border styling: `border-border`, `bg-card`
- Line numbers: gray gutter, right-aligned

### Existing Patterns to Reuse

- `createEditDiffLines` from `@open-agents/shared/lib/diff` - adapt for unified diff parsing
- Diff line rendering from `edit-renderer.tsx` (lines 101-144)
- Collapsible pattern from `edit-renderer.tsx`
- Shiki themes from `task-detail-content.tsx` (lines 49-52)

### States to Handle

1. **Loading** - Show skeleton/spinner while fetching
2. **Empty** - "No changes" message when working directory is clean
3. **Error** - Show error message (e.g., sandbox expired)
4. **Success** - Show file list and diffs

---

## 3. TaskContext Enhancement

**Location:** `apps/web/app/tasks/[id]/task-context.tsx`

### Changes Required

Add to `TaskChatContextValue`:

```typescript
type TaskChatContextValue = {
  // ... existing fields
  diffRefreshKey: number;
  triggerDiffRefresh: () => void;
};
```

### Implementation

```typescript
const [diffRefreshKey, setDiffRefreshKey] = useState(0);

const triggerDiffRefresh = useCallback(() => {
  setDiffRefreshKey((prev) => prev + 1);
}, []);
```

---

## 4. Smart Refresh Trigger

**Location:** `apps/web/app/tasks/[id]/task-detail-content.tsx`

### Tool Types to Watch

- `write` - file creation
- `edit` - file modification  
- `bash` - could modify files (git commands, mv, rm, etc.)

### Implementation

```typescript
// Track completed tool operations to trigger diff refresh
const prevToolStatesRef = useRef<Map<string, string>>(new Map());

useEffect(() => {
  const currentToolStates = new Map<string, string>();
  let shouldRefresh = false;

  for (const message of messages) {
    if (message.role !== "assistant") continue;
    
    for (const part of message.parts) {
      if (!isToolUIPart(part)) continue;
      
      const toolId = part.id;
      const toolState = part.state;
      currentToolStates.set(toolId, toolState);
      
      // Check if this tool just completed
      const prevState = prevToolStatesRef.current.get(toolId);
      const isFileModifyingTool = ["write", "edit", "bash"].includes(part.toolName);
      const justCompleted = 
        toolState === "output-available" && 
        prevState !== "output-available";
      
      if (isFileModifyingTool && justCompleted) {
        shouldRefresh = true;
      }
    }
  }

  prevToolStatesRef.current = currentToolStates;
  
  if (shouldRefresh) {
    triggerDiffRefresh();
  }
}, [messages, triggerDiffRefresh]);
```

---

## 5. Integration Points

### task-detail-content.tsx

Replace placeholder (lines 564-594) with:

```tsx
{showDiffPanel && sandboxInfo && (
  <DiffViewer
    sandboxId={sandboxInfo.sandboxId}
    refreshKey={diffRefreshKey}
    onClose={() => setShowDiffPanel(false)}
  />
)}
```

### Add button to open diff panel

Add to header actions (around line 296):

```tsx
 setShowDiffPanel(!showDiffPanel)}
>
  <GitCompare className="mr-2 h-4 w-4" />
  Diff

```

---

## 6. File Structure

```
apps/web/
├── app/
│   ├── api/
│   │   └── tasks/
│   │       └── [id]/
│   │           └── diff/
│   │               └── route.ts          # NEW: Diff API endpoint
│   └── tasks/
│       └── [id]/
│           ├── task-context.tsx          # MODIFY: Add diffRefreshKey
│           ├── task-detail-content.tsx   # MODIFY: Integrate DiffViewer
│           └── diff-viewer.tsx           # NEW: Main diff viewer component
```

---

## 7. Open Questions

These should be answered before implementation:

1. **Logs Tab** - Should the "Logs" tab be implemented now, or left as a placeholder? What logs would it show - agent execution logs, sandbox shell output?

2. **Syntax Highlighting** - The existing codebase uses Shiki via Streamdown. Should we reuse that setup, or is a simpler approach preferred? Full syntax highlighting adds complexity.

3. **File Actions Menu** - The reference image shows a three-dot menu on each file. What actions should be available? (e.g., "Copy path", "View file", "Revert changes"?)

4. **Empty State** - What should display when there are no changes (clean working directory)?

5. **Error Handling** - If the sandbox expires mid-session, should the diff panel show a "Sandbox expired" message with a reconnect option?

6. **Split/Full-screen Toggle** - The reference image shows window control icons. Should the panel support full-screen mode or split-view mode?

---

## 8. Implementation Order

1. **API Route** - Create `/api/tasks/[id]/diff/route.ts` with git commands and parsing
2. **TaskContext** - Add `diffRefreshKey` and `triggerDiffRefresh`
3. **DiffViewer Component** - Build the UI component with file list and diff rendering
4. **Integration** - Wire up in `task-detail-content.tsx`
5. **Smart Refresh** - Implement tool completion watching
6. **Polish** - Error states, loading states, empty states

---

## 9. Dependencies

No new dependencies required. Uses existing:

- `@open-agents/sandbox` for `connectVercelSandbox`
- `@open-agents/shared/lib/diff` for diff utilities
- `lucide-react` for icons
- Existing UI components (Button, etc.)

---

## 10. Testing Considerations

- Test with various diff scenarios:
  - New files only
  - Modified files only
  - Deleted files
  - Renamed files
  - Large diffs (100+ lines)
  - Binary files (should show placeholder)
- Test refresh mechanism with rapid tool executions
- Test sandbox expiry edge case
- Test with no changes (empty state)

---

## File: apps/web/docs/sandbox-state-persistence-plan.md

# Sandbox State Persistence via Git Diffs

## Overview

Persist sandbox working state using Git diffs so that when a sandbox times out and a new one is created, the user's work is automatically restored. This creates a seamless experience where users don't notice sandbox recreation.

## Scope

- **Web app only** (not CLI/TUI)
- Capture untracked files with **1MB size limit** per file
- Show **subtle "Restoring workspace..." indicator** during restoration
- Use **`beforeStop` hook** to capture diff (assumes hook fires on timeout)

---

## Implementation Plan

### Phase 1: Database Schema

**File:** `apps/web/lib/db/schema.ts`

Add a new `taskDiffs` table to store captured diffs:

```typescript
export const taskDiffs = pgTable("task_diffs", {
  id: text("id").primaryKey(),
  taskId: text("task_id")
    .notNull()
    .references(() => tasks.id, { onDelete: "cascade" }),
  diffContent: text("diff_content").notNull(),        // git diff HEAD output
  untrackedFiles: jsonb("untracked_files"),           // Array<{path, content (base64)}>
  baseCommit: text("base_commit"),                    // SHA for validation
  createdAt: timestamp("created_at").defaultNow().notNull(),
});
```

**New migration required** after schema update.

---

### Phase 2: Diff Capture Logic

**New file:** `apps/web/lib/sandbox/capture-state.ts`

```typescript
export async function captureSandboxState(sandbox: Sandbox): Promise<{
  diffContent: string;
  untrackedFiles: Array<{ path: string; content: string }>;
  baseCommit: string;
}>
```

**Captures:**
1. `git rev-parse HEAD` - base commit SHA
2. `git diff HEAD` - all tracked file changes (staged + unstaged)
3. `git ls-files --others --exclude-standard` - untracked file paths
4. Read each untracked file (skip if >1MB), base64 encode content

---

### Phase 3: Diff Storage Operations

**New file:** `apps/web/lib/db/task-diffs.ts`

```typescript
// Create new diff, keep only latest 3 per task
export async function createTaskDiff(data: NewTaskDiff): Promise<TaskDiff>

// Get latest diff for restoration
export async function getLatestTaskDiff(taskId: string): Promise<TaskDiff | null>
```

---

### Phase 4: Hook into Sandbox beforeStop

**Modify:** `apps/web/app/api/sandbox/route.ts`

When creating a sandbox, pass a `beforeStop` hook that captures and saves the diff:

```typescript
const sandbox = await connectVercelSandbox({
  timeout: DEFAULT_TIMEOUT,
  source: { ... },
  hooks: {
    beforeStop: async (sandbox) => {
      if (taskId) {
        try {
          const state = await captureSandboxState(sandbox);
          if (state.diffContent || state.untrackedFiles.length > 0) {
            await createTaskDiff({
              id: nanoid(),
              taskId,
              diffContent: state.diffContent,
              untrackedFiles: state.untrackedFiles,
              baseCommit: state.baseCommit,
            });
          }
        } catch (error) {
          console.error("Failed to capture sandbox state in beforeStop:", error);
        }
      }
    },
  },
});
```

**Note:** This requires `taskId` to be available in the hook context. We'll need to capture it in a closure.

---

### Phase 5: Diff Restoration Logic

**New file:** `apps/web/lib/sandbox/restore-state.ts`

```typescript
export async function restoreSandboxState(
  sandbox: Sandbox,
  diff: TaskDiff
): Promise<{ success: boolean; error?: string }>
```

**Steps:**
1. Optionally verify/checkout base commit
2. Write diff to temp file, run `git apply --3way /tmp/restore.patch`
3. Restore untracked files by writing base64-decoded content
4. Clean up temp file
5. Return success/failure status

**Error handling:**
- If `git apply` fails, try `git apply --reject` for partial restoration
- Log failures but don't block sandbox creation
- Skip untracked files that already exist

---

### Phase 6: Integrate Restoration into Sandbox Creation

**Modify:** `apps/web/app/api/sandbox/route.ts`

After creating the sandbox, restore state if a diff exists:

```typescript
const sandbox = await connectVercelSandbox({
  // ... config with beforeStop hook
});

// Restore state if this task has a stored diff
let stateRestored = false;
if (taskId) {
  const latestDiff = await getLatestTaskDiff(taskId);
  if (latestDiff) {
    const result = await restoreSandboxState(sandbox, latestDiff);
    stateRestored = result.success;
    if (!result.success) {
      console.warn("Partial state restoration:", result.error);
    }
  }
}

return Response.json({
  sandboxId: sandbox.id,
  createdAt: Date.now(),
  timeout: DEFAULT_TIMEOUT,
  currentBranch: sandbox.currentBranch,
  stateRestored,  // inform client
});
```

---

### Phase 7: Client-Side UI Feedback

**Modify:** `apps/web/app/tasks/[id]/task-detail-content.tsx`

When `createSandbox` is called:
1. Show "Restoring workspace..." text during sandbox creation (if task has messages)
2. If `stateRestored: true` in response, continue normally
3. If restoration failed, show subtle warning but continue

---

## File Changes Summary

| File | Action |
|------|--------|
| `apps/web/lib/db/schema.ts` | Add `taskDiffs` table |
| `apps/web/lib/db/task-diffs.ts` | **New** - CRUD operations |
| `apps/web/lib/sandbox/capture-state.ts` | **New** - diff capture logic |
| `apps/web/lib/sandbox/restore-state.ts` | **New** - diff restoration logic |
| `apps/web/app/api/sandbox/route.ts` | Add `beforeStop` hook + restoration after creation |
| `apps/web/app/tasks/[id]/task-detail-content.tsx` | Add "Restoring workspace..." UI |

---

## Key Design Points

### Why beforeStop Hook

Using `beforeStop` is more efficient than capturing after every message:
- Only runs once when sandbox is stopping
- No per-message overhead
- Git diff operation is slightly slow (~100-500ms), so doing it once is better

**Assumption:** The `beforeStop` hook will be called even when the sandbox times out (not just on explicit `stop()` calls). If this isn't the case, we may need to add periodic capture as a fallback.

### Diff Retention

Keep only the latest 3 diffs per task to prevent unbounded storage growth. Older diffs are automatically deleted when a new one is created.

### Untracked File Handling

- Files >1MB are skipped (likely generated files)
- Binary files are base64 encoded (safe for any content)
- Existing files are not overwritten during restoration

---

## Verification Plan

1. **Manual testing:**
   - Create a task, make file changes via the agent
   - Wait for sandbox to expire (or manually trigger `stop()`)
   - Create a new sandbox for the same task
   - Verify files are restored correctly

2. **Edge cases to test:**
   - Large untracked file (>1MB) - should be skipped
   - Binary files - should be handled gracefully
   - Conflicting changes (rare) - should partially restore with warnings
   - Empty diff (no changes) - should not create a diff record

3. **Database verification:**
   - Check `task_diffs` table is populated after sandbox stop
   - Verify old diffs are cleaned up (only latest 3 kept)

---

## File: apps/web/README.md

This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).

## Environment Variables

The following environment variables are required:

| Variable | Description |
|----------|-------------|
| `POSTGRES_URL` | PostgreSQL connection string. Required for builds — migrations run automatically during `next build` via `lib/db/migrate.ts`. Preview deployments use a Neon branch database; production uses the main database. |

## Getting Started

First, run the development server:

```bash
pnpm dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

---

## File: docs/plans/lazy-sandbox-session-creation.md

# Lazy sandbox session creation

Summary: Allow sessions to be created without an active sandbox, and make sandbox provisioning a lazy first step of chat execution or explicit resume. This keeps session creation lightweight, removes the up-front wait before the first message, and makes programmatic session creation simpler.

## Context

- `apps/web/app/api/sessions/route.ts` already creates a session and initial chat without a live `sandboxId`; it stores desired sandbox configuration in `sandboxState: { type: "vercel" }`.
- `apps/web/app/sessions/[sessionId]/chats/[chatId]/session-chat-content.tsx` currently auto-creates a sandbox on page entry and blocks sending until the sandbox is active.
- `apps/web/app/api/chat/route.ts` currently requires an active sandbox before a chat request can proceed.
- `apps/web/app/api/sandbox/route.ts` contains the create/reconnect logic that provisions a sandbox and persists the resulting runtime state.
- `apps/web/app/workflows/chat.ts` assumes the workflow is started with an already-usable sandbox.

## Approach

Treat session creation and sandbox creation as separate concerns:

1. Session creation creates durable app state only.
2. The first message (or an explicit resume action) ensures a sandbox exists.
3. Once a sandbox is ready, the normal agent workflow proceeds unchanged.
4. Eager sandbox creation can remain as an optimization, not a requirement.

This makes the default contract:

- `POST /api/sessions` returns `sessionId` and `chatId` even if no runtime sandbox exists yet.
- `POST /api/chat` does not require a caller-provided `sandboxId` for normal usage.
- Sandbox creation/reconnect/restore becomes an internal “ensure runtime” step.

## Recommended workflow

1. Create a session.
2. Navigate to the chat immediately.
3. Let the user send the first message without waiting for sandbox creation.
4. On first send:
   - reuse an already-active sandbox if one exists
   - otherwise reconnect to an existing runtime if possible
   - otherwise restore from snapshot if appropriate
   - otherwise create a new sandbox
5. Persist the resulting runtime state (`sandboxId`, expiry, branch, lifecycle data).
6. Start the agent turn.
7. Reuse that sandbox for later messages.

## API shape

### Session creation

```ts
POST /api/sessions
-> {
  session: { id: string, ... },
  chat: { id: string, ... },
  sandbox: { state: "pending" }
}
```

### Chat execution

```ts
POST /api/chat
{
  sessionId: string,
  chatId: string,
  messages: [...],
  sandboxId?: string
}
```

Notes:
- `sandboxId` stays optional for reconnect or advanced callers.
- Normal callers should not need to know whether a sandbox already exists.

## Changes

- `apps/web/app/api/sessions/route.ts`
  - Keep session creation lightweight.
  - Consider renaming lifecycle semantics so a newly created session is not described as actively provisioning when no sandbox boot has started yet.

- `apps/web/app/api/sandbox/route.ts`
  - Extract the create/reconnect logic into a shared `ensureSessionSandbox(...)` helper so manual sandbox actions and chat startup use the same code path.

- `apps/web/app/api/chat/route.ts`
  - Remove the hard precondition that the sandbox must already be active.
  - Ensure the session sandbox before creating the chat runtime.

- `apps/web/app/api/chat/_lib/runtime.ts`
  - Accept the ensured runtime state rather than assuming `sessionRecord.sandboxState` is already active.

- `apps/web/app/workflows/chat.ts`
  - Option A: keep sandbox ensuring in the API route before starting the workflow.
  - Option B: make sandbox ensuring the formal first workflow step.
  - Recommendation: start with Option A for the smallest change.

- `apps/web/app/sessions/[sessionId]/chats/[chatId]/session-chat-content.tsx`
  - Stop blocking the first send purely because no sandbox is active yet.
  - Replace that with a visible “Preparing sandbox…” state after send.
  - Keep explicit create/resume controls for manual recovery cases.

- `apps/web/lib/sandbox/utils.ts`
  - Clarify the distinction between “desired sandbox config exists” and “runtime sandbox is active”.

- `apps/web/lib/db/schema.ts`
  - Consider adding a clearer lifecycle state such as `pending` / `awaiting_start` if the current `provisioning` label becomes misleading.

## Tradeoffs

### Benefits

- Better UX: users can send immediately.
- Lower cost: abandoned sessions never allocate a sandbox.
- Cleaner API: sessions can be created programmatically without infra setup.
- Better separation of concerns: a session is durable state; a sandbox is attachable runtime.

### Risks

- The first-send path now needs a session-level lock to avoid double sandbox creation.
- Sandbox boot latency moves into the first message path, so the UI must explain what is happening.
- Sandbox failures become chat-start failures and need retryable UX.
- Existing lifecycle labels may no longer accurately describe state.

## Recommendation

Adopt lazy sandbox creation as the default contract.

Specifically:
- keep `POST /api/sessions` sandbox-agnostic
- make `sandboxId` optional in the mental model and API contract
- introduce a shared `ensureSessionSandbox(...)` helper
- call that helper from chat startup
- preserve eager prewarming only as an optional optimization

## Verification

- Create an empty session and immediately send a first message; sandbox should provision and the turn should run.
- Create a repo-backed session and immediately send a first message; clone/branch setup should happen exactly once.
- Send the first message from two tabs concurrently; only one sandbox should be created.
- Create a session programmatically through `POST /api/sessions`; later chat execution should still work.
- Manual sandbox create/resume flows should continue to work.
- Snapshot restore should still bypass stale pre-created runtime state.

---

## File: docs/agents/architecture.md

# Architecture

This is a Turborepo monorepo for "Open Agents" - an AI coding agent built with AI SDK.

## Core Flow

```
Web -> Agent (packages/agent) -> Sandbox (packages/sandbox)
```

1. **Web** handles authentication, session management, and the primary user interface
2. **Agent** (`deepAgent`) is a `ToolLoopAgent` with tools for file ops, bash, and task delegation
3. **Sandbox** abstracts file system and shell operations for cloud execution backends

## Key Packages

- **packages/agent/** - Core agent implementation with tools, subagents, and context management
- **packages/sandbox/** - Execution environment abstraction for cloud sandboxes
- **packages/shared/** - Shared utilities across packages

## Subagent Pattern

The `task` tool delegates to specialized subagents:
- **explorer**: Read-only, for codebase research (grep, glob, read, safe bash)
- **executor**: Full access, for implementation tasks (all tools)

## Workspace Structure

```
apps/
  web/           # Web interface
packages/
  agent/         # Core agent logic (@open-agents/agent)
  sandbox/       # Sandbox abstraction (@open-agents/sandbox)
  shared/        # Shared utilities (@open-agents/shared)
  tsconfig/      # Shared TypeScript configs
```

---

## File: docs/agents/code-style.md

# Code Style & Patterns

Detailed coding conventions, tool implementation patterns, and common patterns for the Open Agents codebase.

## Package Manager

- Use **pnpm exclusively** for dependency management
- The monorepo uses `pnpm@11.5.1` through Corepack
- Use Node 24's built-in TypeScript support for utility scripts
- Use Bun only as the test runner

## TypeScript Configuration

- Strict mode enabled
- Target: ESNext with module "Preserve"
- `noUncheckedIndexedAccess: true` - always check indexed access
- `verbatimModuleSyntax: true` - use explicit type imports

## Formatting (Ultracite — oxfmt)

- Indent: 2 spaces
- Quote style: double quotes for JavaScript/TypeScript
- Run `pnpm fix` before committing

## Naming Conventions

- **Files**: kebab-case (e.g., `deep-agent.ts`, `paste-blocks.ts`)
- **Types/Interfaces**: PascalCase (e.g., `TodoItem`, `AgentContext`)
- **Functions/Variables**: camelCase (e.g., `getSandbox`, `workingDirectory`)
- **Constants**: UPPER_SNAKE_CASE for true constants (e.g., `TIMEOUT_MS`, `SAFE_COMMAND_PREFIXES`)

## Imports

- **Do NOT use `.js` extensions** in imports (e.g., `import { foo } from "./utils"` not `"./utils.js"`)
  - The `.js` extension causes module resolution issues with Next.js/Turbopack
  - This applies to all packages and apps in the monorepo
- Use explicit `.ts` extensions in modules loaded directly by Node 24 utility scripts
- Prefer named exports over default exports
- Group imports: external packages first, then internal packages, then relative imports
- Use type imports when importing only types: `import type { Foo } from "./types"`

## Types

- **Never use `any`** - use `unknown` and narrow with type guards
- Define schemas with Zod, then derive types: `type Foo = z.infer<typeof fooSchema>`
- Prefer interfaces for object shapes, types for unions/intersections
- Export types alongside their related functions

## Error Handling

- Return structured error objects rather than throwing when possible:
  ```typescript
  return { success: false, error: `Failed to read file: ${message}` };
  ```
- When catching errors, extract message safely:
  ```typescript
  const message = error instanceof Error ? error.message : String(error);
  ```
- Use descriptive error messages that include context (tool name, file path, etc.)

## Testing

- Use Bun's test runner: `import { test, expect } from "bun:test"`
- Test files use `.test.ts` suffix
- Colocate tests with source files

## Runtime APIs

- Use Node APIs for utility scripts
- Use Bun APIs only in tests when the Bun test runner requires them

## AI SDK Patterns

- Tools are defined with Zod schemas for input validation
- Use `ToolLoopAgent` for agent implementations
- Tools receive context via `experimental_context` parameter
- Implement `needsApproval` as boolean or function for tool approval logic

## Tool Implementation Patterns

When creating tools in `packages/agent/tools/`:

```typescript
import { tool } from "ai";
import { z } from "zod";
import { getSandbox, getApprovalContext } from "./utils";

const inputSchema = z.object({
  param: z.string().describe("Description for the agent"),
});

export const myTool = (options?: { needsApproval?: boolean }) =>
  tool({
    needsApproval: (args, { experimental_context }) => {
      const ctx = getApprovalContext(experimental_context, "myTool");
      // Return true if approval needed, false otherwise
      return options?.needsApproval ?? true;
    },
    inputSchema,
    execute: async (args, { experimental_context }) => {
      const sandbox = getSandbox(experimental_context, "myTool");
      // Implementation using sandbox methods
      return { success: true, result: "..." };
    },
  });
```

## Common Patterns

### Large UI files

- In already-large React view/page/client components, do **not** add new feature-specific state, effects, network calls, and JSX inline by default.
- Extract feature logic into a colocated hook (for example `use-dev-server.ts`) and extract self-contained UI regions into a colocated component (for example `dev-server-menu-items.tsx`).
- Keep the parent view responsible for shared capability flags and long-lived page state; pass the extracted feature controls down as props.
- If the feature state must survive menu/popover/dialog open-state changes, mount the hook in the parent view and pass its controls into the extracted child component instead of mounting the hook inside ephemeral UI content.

### Workspace Dependencies

Use `workspace:*` for internal packages:
```json
{
  "dependencies": {
    "@open-agents/sandbox": "workspace:*"
  }
}
```

### Catalog Dependencies

Use `catalog:` for shared external versions:
```json
{
  "dependencies": {
    "ai": "catalog:",
    "zod": "catalog:"
  }
}
```

---

## File: docs/agents/lessons-learned.md

# Lessons Learned

Hard-won knowledge from building this codebase. When you make a mistake or discover a non-obvious behavior, add it here.

## General / Tooling

- Skill discovery de-duplicates by first-seen name, so project skill directories must be scanned before user-level directories to allow project overrides.
- The system prompt should list all model-invocable skills (including non-user-invocable ones), and reserve user-invocable filtering for the slash-command UI.
- Glob patterns ending in `**` (for example `"**"` or `"src/**"`) should be treated as recursive, even when `**` is the final segment.
- In shell tools, avoid piping primary command output directly to `head` when exit-code handling matters; pipeline semantics can mask real failures from the primary command.
- Bash approval heuristics should reserve prompts for clearly destructive commands (for example `rm -rf`, `sudo`, or mutating git/package-manager operations); treating pipes/chaining and common filesystem reads as dangerous creates too many false-positive approvals for normal inspection commands.
- Verification instructions must tell the agent to consult AGENTS.md / `package.json` scripts **before** listing generic steps like "typecheck -> lint -> build"; otherwise models default to raw commands (`npx tsc`, `eslint .`) which bypass project-specific tool config (turbo pipelines, tsconfig references, ultracite, etc.) and produce incorrect or incomplete results.
- Tool renderer `part.output` values may be `unknown`; when accessing fields like `files` or `matches`, add runtime narrowing/type guards first (in both TUI and web renderers) to satisfy strict typecheck.
- AI SDK stream handles may return `PromiseLike` values (not full `Promise`), so avoid methods like `.finally()` and use `then`/`catch` patterns that work with `PromiseLike`.
- After schema edits, review generated Drizzle migrations for unrelated schema drift changes before committing (for example defaults on untouched columns), since `drizzle-kit generate` can include those alongside intended changes.
- pnpm 11 requires an explicit `allowBuilds` map in `pnpm-workspace.yaml`; approve required native/tooling builds deliberately and keep non-functional lifecycle scripts disabled.
- Keep pnpm release-age policy explicit in `pnpm-workspace.yaml`: enforce a strict one-day `minimumReleaseAge` and fail closed when publish timestamps are missing.
- Use `pnpm run ci` for the repository verification script. `pnpm ci` invokes pnpm's built-in clean-install command instead of the package script.
- Keep Kysely pinned to `0.28.x` until Better Auth's bundled Kysely adapter stops importing migration constants from Kysely's root entrypoint; Kysely `0.29.x` removed those root exports and breaks the Next production bundle.
- Node 24's built-in TypeScript support uses native ESM resolution and ignores tsconfig path aliases, so utility-script dependency chains need explicit `.ts` extensions and relative imports.
- `bunx @vercel/config validate` executes the CLI under Node via its shebang and cannot parse TypeScript-style `vercel.ts` imports; use `bunx --bun @vercel/config validate` (or `bun node_modules/@vercel/config/dist/cli.js validate`) for reliable local validation.
- Successful Vercel CLI auth (`vercel whoami`, team/project REST APIs, `.vercel` linking) does **not** guarantee Workflow observability access. `workflow inspect ... --backend vercel` can still fail with `401 {"error":{"code":"unauthorized","message":"You are not allowed to access this endpoint."}}` when the user/token lacks the Vercel product permission documented as `Vercel Workflow` (and possibly related Observability access), even if `WORKFLOW_VERCEL_AUTH_TOKEN` is passed explicitly from the Vercel CLI auth file.

## Next.js

- In Next.js App Router, dynamic route param names must match the folder segment exactly (e.g. `[sessionId]` requires `params.sessionId`, not `params.id`), or DB queries can receive `undefined` and fail at runtime.
- Some planning docs still reference legacy `apps/web/app/tasks/[id]/...` paths; current UI/API code is centered on `apps/web/app/sessions/[sessionId]/chats/[chatId]/...`, so verify file paths before implementing plan items.
- Next.js `after()` defers callbacks until the response is fully sent; for streaming endpoints this means `after()` runs after the entire stream completes, not at call time. Use fire-and-forget (`void run()`) for lifecycle kicks that must happen at request start.
- In Next.js Route Handlers, `cookies()` from `next/headers` combined with `Response.redirect()` silently drops Set-Cookie headers from the redirect response. Use `NextResponse.redirect()` with `response.cookies.set()` instead to ensure cookies are included in redirect responses.
- In this codebase's Next.js version, `revalidateTag` must be called with a second argument (for example `{ expire: 0 }`); single-argument calls fail typecheck.
- For Workflow SDK discovery in Next.js, ensure workflow files live in scanned directories (for this app, `app/`), otherwise manifests can show steps but `0 workflows` and `start()` will not run durable workflows.
- Server-side optimistic chat route lookup must allow realistic persistence latency (multi-second retry window), otherwise `/sessions/[sessionId]/chats/[chatId]` can redirect away before chat creation finishes.

## Sandbox Lifecycle

- Detached/background bash results may have `exitCode: null` for both successful starts and explicit tool failures; bash renderer error state must also honor `output.success === false` (not only numeric non-zero exit codes), and detached quick-failure probing should prefer a timer-vs-wait race branch over matching SDK-specific error names.
- Creating a sandbox snapshot automatically shuts down that sandbox; lifecycle plans and implementations must treat snapshotting as a stop/hibernate transition, not a non-disruptive backup.
- Vercel `sdk.domain(port)` throws when a sandbox has no route for that port (common on some restored/reconnected sandboxes); environment/prompt metadata should guard per-port URL generation instead of assuming every configured port is routable.
- Vercel sandbox creation has a hard timeout limit of `18_000_000ms`; if you add an internal timeout buffer before calling the SDK, clamp proactive timeout so `timeout + buffer` never exceeds that API limit.
- In serverless environments, lifecycle checks that only run inline during request handlers are not durable; long-gap sandbox lifecycle actions must be scheduled with a durable workflow run (`start(...)` + `sleep(...)`) so they execute without a connected client.
- Vercel `snapshot()` may return `422 sandbox_snapshotting` when another snapshot is already in progress; lifecycle code should treat this as an idempotent/in-progress condition and reconcile state instead of marking lifecycle as failed.
- The reconnect API can return `expired` when a sandbox has already stopped; client reconnection state should treat `expired` like `no_sandbox` so restore UX does not get stuck in a generic failure path.
- For workflow-managed sandbox lifecycle, avoid client-side timeout auto-stop logic in the chat UI; it can race with workflow hibernate and produce confusing paused overlays while the tab remains open.
- Snapshot restore should be idempotent when a sandbox is already running: return success with an `alreadyRunning` signal instead of a 400, and let the client reconnect/sync rather than surfacing a hard error.
- For lifecycle workflow kicks in request handlers, call `kickSandboxLifecycleWorkflow(...)` directly instead of wrapping it in `after(...)`; delayed/deferred scheduling can miss the initial hibernation timer for idle sessions.
- For sandbox lifecycle kicks, do not persist `lifecycleRunId` before `start(...)`; start first and let the durable workflow claim/verify the lease so canceled fire-and-forget kicks cannot strand a stale lease.
- Lifecycle workflow must retry after a `skipped/not-due-yet` evaluation; without retry the sandbox never hibernates unless a new event kicks a fresh workflow.
- When the lifecycle workflow inline fallback runs (SDK unavailable), it evaluates immediately and skips because the sandbox isn't due yet; the status endpoint should detect overdue `hibernateAfter` and kick the lifecycle as a safety net.

## Sandbox UI State

- Status chips that derive from time-based sandbox validity should not rely on memoization without a time dependency; otherwise header state can drift from overlay/input state as `Date.now()` changes.
- Keep sandbox status UI elements (chip, overlay, and indicator dot) on a shared `isSandboxActive` source; mixed heuristics (e.g., one using grace-window validity and another using raw countdown) can show contradictory states like `Paused` with a green dot.
- Treat `/api/sandbox/reconnect` as a read-only status probe; reconnect polling should never refresh lifecycle activity timestamps or kick lifecycle workflows, or idle sessions can fail to hibernate correctly.
- For paused sessions, auto-resume on entry should trigger only after reconnect confirms `no_sandbox`; do not auto-restore on generic reconnect failures.
- Do not use `snapshotUrl` alone to infer paused/hibernating UI state; active sessions may retain a snapshot reference. Require absence of runtime sandbox state (`sandboxId`/`files`) before labeling hibernation.
- Keep sandbox mode details out of page/presentation components: expose capability flags (for example `supportsDiff`, `supportsRepoCreation`, `hasRuntimeSandboxState`) from shared context and branch UI on capabilities, not raw `sandboxState.type`.
- Auto-resume-on-entry for paused sessions must not require a prior `no_sandbox` reconnect result when there is no runtime sandbox state in DB; snapshot-only sessions can otherwise get stuck in `idle` and never restore.
- For predictive lifecycle UI countdowns, use server-provided timestamps (`hibernateAfter`, `sandboxExpiresAt`) plus a server-time offset from reconnect responses; do not rely on client clock alone for transition timing.
- Auto-resume for paused sessions must run only on initial session entry; once a tab has had an active sandbox, do not auto-resume after a later inactivity hibernate in that same tab.
- Keep the sandbox indicator dot on the same derived lifecycle state machine as the status chip; during inactivity countdown it should show a pausing state, and during server `hibernating` it should not remain green.
- Split lifecycle UI polling from connectivity probing: poll a lightweight DB-backed sandbox status endpoint for timing/state, and reserve reconnect/connect checks for entry/resume or explicit recovery paths.
- Prefer event-first lifecycle sync in the chat UI (chat completion, visibility return, window focus, network online), with sparse status polling (about 60s baseline, tighter only near transitions) instead of frequent fixed-interval polling.
- When syncing status timestamps, avoid rewriting sandbox connection state on every response; only update if expiry materially changes, or UI effects can enter rapid request loops.
- Resume/paused UI must not rely only on `session.snapshotUrl` from initial page props; keep a live `hasSnapshot` signal from reconnect/status responses, or the UI can incorrectly show `No sandbox` and hide resume actions.
- `/api/sandbox/reconnect` should treat DB runtime state (`sandboxId`/`files`) as the source of reconnect eligibility; using `isSandboxActive` (which includes expiry heuristics) can misclassify recoverable sessions as `no_sandbox` and break restore/reconnect flows.
- When `/api/sandbox/reconnect` reports `connected`, it must persist refreshed sandbox runtime state/expiry (`sandboxState`, `sandboxExpiresAt`) back to DB; otherwise `/files` and `/diff` can still fail with `Sandbox not initialized` against stale expired state while UI thinks reconnect succeeded.
- For sandbox lifecycle UI, keep the client simple and server-authoritative: poll `/api/sandbox/status` on a fixed cadence (currently 15s) instead of combining multiple client-side event/predictive sync paths, which can drift or loop under reconnect/hibernation edge cases.
- Reconnect liveness probes can time out right after snapshot restore while the sandbox is still starting; treat probe timeouts as transient (non-terminal) and clear runtime state only for hard unavailability signals (stopped/not found/stream unavailable).
- Keep `/api/sandbox/status` as a DB-backed read-only view; do not mutate/clear sandbox runtime state from status polling, or active sessions can be downgraded to `no_sandbox` and later restore from stale snapshots.
- On Vercel reconnect (`state.sandboxId`), do not pass `remainingTimeout=0` from stale `state.expiresAt`; that creates an immediately-expired local wrapper and can make the header/API checks flip to `No sandbox` even while the VM is reachable.
- Reconnect success should refresh full active lifecycle timestamps (`lastActivityAt`, `hibernateAfter`, `sandboxExpiresAt`) before responding; otherwise UI status chips can stay stuck in `Pausing` from stale lifecycle fields.
- Lifecycle countdown UI windows should scale with configured inactivity timeout; fixed windows (for example 2 minutes) can make short test timeouts (for example 1 minute) appear to be perpetually pausing.
- Reconnect can return a sandbox handle whose command stream is unusable (`Expected a stream of command data`); reconnect should probe command execution before declaring `connected`, and file/diff routes should treat that error as sandbox-unavailable (hibernated) rather than a git-repo error.
- Archive uses a deferred background snapshot; if unarchive runs before `snapshotUrl` is persisted, resume/restore can race with `no_snapshot`, so unarchive/restore flows must gate on snapshot readiness (or surface a clear snapshot-in-progress state).
- Client UI `sandboxUiStatus` must check server `lifecycleTiming.state` (from status poll) as primary source, not only local `sandboxInfo`; otherwise UI stays "Active" after server-side hibernation until the local timeout expires or user refreshes.
- The `isSandboxActive` client flag must incorporate `lifecycleTiming.state`; local `isSandboxValid(sandboxInfo)` alone is insufficient because the server can hibernate the sandbox while the local timeout is still valid.
- In the sandbox lifecycle evaluator, treat any non-null chat `activeStreamId` as an authoritative no-hibernate signal; do not inspect workflow status or clear stream ids from the lifecycle path, and recheck immediately before snapshotting to avoid racing a newly-started stream.

## Chat / Streaming UI

- In large chat/page client components, extract new feature-specific UI flows into colocated hooks and child components instead of adding more state/effects/handlers inline; if the feature state must survive dropdown/popover/dialog toggles, mount the hook in the parent view and pass its controls down.
- In the web chat UI, do not keep `@ai-sdk/react` Chat instances alive after route transitions while they are still streaming; abort local stream processing and remove the instance on teardown, then rely on resumable stream reconnect when revisiting that chat.
- For client-side tool flows (`ask_user_question`), `onFinish`-only assistant persistence is insufficient across route switches: persist the latest incoming message snapshot at API request start (upsert by message id) so answered/declined tool state survives teardown/resume and does not rehydrate stale `input-available` UI.
- Request-start assistant snapshot persistence must be scoped and ownership-guarded: only upsert assistant messages when the request still owns the chat stream token, and refuse upserts on message-id scope conflict (different chat/role) to prevent stale writes and cross-chat overwrites.
- Keep `activeStreamId` resumable at all times: do not publish pre-registration ownership placeholders to `activeStreamId` (resume probes can clear them as stale), and gate `onFinish` writes on the atomic compare-and-set result that clears the currently owned token.
- Usage analytics `messageCount` must represent assistant turns, not raw `usage_events` rows; when subagent/model breakdown rows are recorded, count only canonical main-agent rows in additive rollups to avoid inflated totals and heatmaps.
- Unread correctness depends on visibility-aware read receipts and insert-only assistant activity updates: block read receipts for hidden tabs, but allow forced read marks on visible tabs without waiting for focus; only advance `lastAssistantMessageAt` when an assistant message upsert actually inserts a new row (not snapshot/tool-result updates).
- Post-turn automations that must happen even after the user leaves the chat (for example auto-commit/push) should be scheduled from the server chat completion path, not only from client `status === "ready"` effects; client-only hooks can miss turns that finish while the page is closed and can lag behind background completion.
- Chat list streaming indicators should poll more frequently while any chat is actively streaming (for example ~1s) and fall back to a slower cadence when idle, to avoid delayed white-to-complete indicator transitions after chat switches.
- Sidebar chat lists should hydrate from server-fetched initial chat summaries (layout props) in addition to SWR fetches, so transient `/api/sessions/[sessionId]/chats` failures do not render an empty list on hard refresh.
- For hydration-sensitive SWR endpoints (notably sidebar chat lists), use a dedicated `no-store` fetcher instead of changing the shared SWR fetcher globally; otherwise browser HTTP caching gets disabled across unrelated `/api` hooks (models, branches, repos, settings).
- Optimistic chat-title previews for `"New chat"` must have an explicit rollback on send failures; otherwise the sidebar can keep a title that was never persisted if the first request errors.
- `hadInitialMessages` is an initial-load snapshot, not a live "first turn" signal; guard one-time optimistic UI (like first-message title previews) with a dedicated runtime ref/state that resets on send failure.
- When session overlay maps are deleted after becoming empty, any later overlay writes in the same hook instance must re-register the map in the global registry, or optimistic overlays will not survive route transitions.
- For resumed chat streams, `chat.stop()` alone is insufficient because reconnect fetches are not wired to the active abort signal; always pair stop with aborting the managed transport tied to that chat instance.
- Automatic stream retries should use soft reconnect semantics and single-flight guards; overlapping hard retries can replay resumable chunks and cause visible reasoning/tool UI flicker.
- In chat UI rendering, treat both `submitted` and `streaming` as in-flight. If only `streaming` is considered active, task/tool parts can be marked interrupted too early and stale `Thinking...` indicators can linger until a full page refresh.
- In Streamdown, `plugins.code.getThemes()` overrides the `shikiTheme` prop; configure code themes in `createCodePlugin(...)` and pass actual custom theme objects for non-bundled themes (for example `vercelLight`/`vercelDark`) or highlighting can fall back to unstyled/plain tokens.
- Shiki dual-theme `TokensResult` can encode dark variants inside semicolon-delimited `fg`/`bg` values and token `htmlStyle` fields (for example `color` + `--shiki-dark`); normalize these into Streamdown's `color`/`bgColor` fields plus root CSS vars, or inline light colors can override dark-mode classes and keep code blocks stuck in light theme.

## GitHub App / PR Flows

- GitHub App install flow uses a three-path strategy: (1) no linked account -- OAuth authorize URL with explicit `redirect_uri`, callback chains to install with `target_id`; (2) linked account but no installations -- `installations/new/permissions?target_id={githubId}` directly; (3) linked account with installations -- `select_target` for the account/org picker. Disable "Request user authorization (OAuth) during installation" on the GitHub App -- it causes auto-redirect loops for already-authorized users on both `select_target` and `installations/new/permissions`.
- GitHub App must be made **public** for the org picker to appear during installation. While the app is private, `/installations/select_target` only shows the owner's personal account -- users cannot install on organizations. Use "Make public" in the GitHub App's Danger Zone when ready.
- Use `/installations/select_target` instead of `/installations/new` for the GitHub App install URL; the latter silently redirects to an existing personal installation's settings page instead of showing the account/org picker.
- GitHub App callbacks that process OAuth `code` or `installation_id` must validate a server-stored `state` nonce before linking accounts or syncing installations; never trust callback query params without CSRF/state verification.
- Installation sync that prunes DB records must fetch all GitHub API pages first (`per_page=100` + pagination); pruning from a partial page can silently remove valid installations.
- In the GitHub App install flow, do a user-token installation sync before redirecting after OAuth-only callbacks or treating zero local installation rows as "not installed"; GitHub can skip callback emissions for pre-existing installs.
- Public upstream repositories may reject direct branch pushes; PR generation should fall back to creating/pushing to the user's fork and PR creation must use a qualified head ref (`forkOwner:branch`).
- GitHub fork creation can take longer than a few seconds to become pushable; PR fallback should retry fork push on transient `repository not found` errors instead of failing immediately.
- Git push failures from Vercel sandboxes can return empty output even when auth/write is denied; PR fallback logic should not rely only on matching "permission" text before attempting fork fallback.
- When the GitHub App lacks push access (e.g. repo removed from installation scope), fail fast with a 403 directing users to /settings/connections rather than silently forking.

---

## File: docs/agents/react-best-practices-audit.md

# React Best Practices Audit: `apps/web`

Audited against the Vercel React Best Practices (57 rules, 8 categories). Findings are ordered by impact.

---

## 1. Eliminating Waterfalls -- CRITICAL

### ~~1a. Sequential `await` chains in Server Components~~ FIXED

**Rule violated:** `async-parallel` (Promise.all for independent operations)

`app/sessions/[sessionId]/chats/[chatId]/page.tsx:67-93` -- Four sequential `await` calls where some are independent:

```typescript
const session = await getServerSession();     // 1. auth
const sessionRecord = await getSessionById(sessionId); // 2. depends on nothing
const chat = await getChatByIdWithRetry(chatId, sessionId); // 3. depends on sessionId only
const dbMessages = await getChatMessages(chatId); // 4. depends on chatId only
```

`getServerSession()` and `getSessionById()` are independent and could run in parallel. After the ownership check, `getChatByIdWithRetry` and `getChatMessages` could also overlap (chatId is known from params, not from the session fetch).

**Fix:** Start independent promises early, await late:

```typescript
const sessionPromise = getServerSession();
const sessionRecordPromise = getSessionById(sessionId);
const session = await sessionPromise;
if (!session?.user) redirect("/");
const sessionRecord = await sessionRecordPromise;
// ... ownership check ...
const [chat, dbMessages] = await Promise.all([
  getChatByIdWithRetry(chatId, sessionId),
  getChatMessages(chatId),
]);
```

### ~~1b. Missing Suspense boundaries~~ FIXED

**Rule violated:** `async-suspense-boundaries`

There are **zero** `loading.tsx` files and **zero** `error.tsx` files in the entire app. Only one `<Suspense>` boundary exists (`app/settings/accounts/page.tsx:8`). The root layout (`app/layout.tsx:24-39`) has no Suspense wrapping `{children}`.

This means:

- No streaming SSR for any page
- The entire page tree (including the retry loop at `page.tsx:31-48` that can block for up to 5 seconds) must resolve before anything paints

**Fix:** Added `loading.tsx` files for key route segments (`sessions/[sessionId]/chats/[chatId]`, `sessions/[sessionId]`, `shared/[shareId]`). Next.js automatically wraps these in `<Suspense>` boundaries.

### ~~1c. Blocking retry loop~~ FIXED

`app/sessions/[sessionId]/chats/[chatId]/page.tsx:31-48` -- `getChatByIdWithRetry` can do 50 retries x 100ms = 5 seconds of blocking in the server component render. This blocks the entire page with no streaming or fallback UI.

**Fix:** The `loading.tsx` boundary now provides a loading indicator while the retry loop runs, instead of a blank page.

---

## 2. Bundle Size Optimization -- CRITICAL

### ~~2a. No `next/dynamic` usage anywhere~~ FIXED

**Rule violated:** `bundle-dynamic-imports`

The entire codebase has **zero** `next/dynamic` or `import()` calls for lazy-loading components. Every component is statically imported. Heavy, on-demand-only components are eagerly bundled:

| Component | File | Lines | When Used |
|-----------|------|-------|-----------|
| `DiffViewer` | `diff-viewer.tsx` | ~308 | Modal opened on button click |
| `CreatePRDialog` | `create-pr-dialog.tsx` | ~686 | Modal opened on button click |
| `CreateRepoDialog` | `create-repo-dialog.tsx` | ~348 | Modal opened on button click |
| `Streamdown` (shiki) | External dependency | Large | Only during message rendering |

All are imported at `session-chat-content.tsx:43-52,83`.

**Fix:** Use `next/dynamic` with `{ ssr: false }` for dialog/modal components:

```typescript
const DiffViewer = dynamic(
  () => import("./diff-viewer").then(m => m.DiffViewer),
  { ssr: false }
);
const CreatePRDialog = dynamic(
  () => import("@/components/create-pr-dialog").then(m => m.CreatePRDialog),
  { ssr: false }
);
```

### ~~2b. Barrel imports from `lucide-react`~~ FIXED

**Rule violated:** `bundle-barrel-imports`

`session-chat-content.tsx:6-29` imports **22 icons** via barrel export:

```typescript
import {
  Archive, ArchiveRestore, ArrowDown, ArrowLeft, ArrowUp,
  Check, Copy, ExternalLink, FolderGit2, GitCompare,
  GitPullRequest, Link2, Loader2, Menu, MessageSquare,
  Mic, Paperclip, Pencil, Plus, Share2, Square, Trash2, X,
} from "lucide-react"
```

This loads the entire `lucide-react` module graph (~1,583 modules).

**Fix:** Add `lucide-react` to `optimizePackageImports` in `next.config.ts`:

```typescript
const nextConfig: NextConfig = {
  experimental: {
    optimizePackageImports: ["lucide-react"],
  },
  // ...
};
```

---

## 3. Server-Side Performance -- HIGH

### ~~3a. Context provider value not memoized~~ FIXED

**Rule violated:** `server-serialization` + `rerender-memo`

`session-chat-context.tsx:853-892` -- The `SessionChatContext.Provider` value is an inline object literal with ~40 properties, recreated every render. While individual callbacks inside are memoized with `useCallback`, the value object itself is not wrapped in `useMemo`. This causes all consumers to re-render whenever the provider re-renders.

**Fix:** Wrap the value in `useMemo`:

```typescript
const contextValue = useMemo(() => ({
  session: sessionRecord,
  chatInfo,
  chat,
  // ... all 40 properties
}), [sessionRecord, chatInfo, chat, /* ... relevant deps */]);

return (
  <SessionChatContext.Provider value={contextValue}>
    {children}
  </SessionChatContext.Provider>
);
```

### ~~3b. Full session record passed to client component~~ INVESTIGATED

**Rule violated:** `server-serialization` (minimize data at RSC boundaries)

`page.tsx:98` passes the entire `sessionRecord` object to the client provider. Only a subset of fields are actually used by the client components. This increases serialization cost at the RSC boundary.

**Result:** After investigation, nearly every field of the `Session` object is read or mutated by `SessionChatProvider` and `SessionChatContent`. The provider reads `id`, `sandboxState`, `snapshotUrl`, `lifecycleState`, `lastActivityAt`, `hibernateAfter`, `sandboxExpiresAt`, `cachedDiff`, `cachedDiffUpdatedAt` for initialization, and mutates 14+ fields through `setSessionRecord`. The serialization cost for a single DB row is minimal. Not worth refactoring.

---

## 4. Re-render Optimization -- MEDIUM

### ~~4a. Derived state not memoized~~ FIXED

**Rule violated:** `rerender-derived-state-no-effect`

`session-chat-content.tsx:689-697` -- Several derived values are recomputed on every render without memoization:

```typescript
const renderMessages = hasMounted ? messages : initialMessages;  // line 689
const lastMessage = renderMessages[renderMessages.length - 1];    // line 690
const showThinkingIndicator = status === "submitted" || ...       // line 691-697
```

During streaming, `messages` changes on every token, so these are recalculated very frequently. `renderMessages` is used in 3+ `useMemo` hooks downstream, so not memoizing it means those downstream memos also re-run.

### ~~4b. `sandboxUiStatus` IIFE on every render~~ FIXED

`session-chat-content.tsx:1476-1521` -- A complex IIFE computing an object literal runs on every render. This should be a `useMemo`:

```typescript
const sandboxUiStatus = useMemo(() => {
  if (isArchived) return { label: "Archived", ... };
  // ...
}, [isArchived, isCreatingSandbox, isRestoringSnapshot, /* ... */]);
```

### ~~4c. `hasMounted` effect pattern~~ FIXED

**Rule violated:** `rerender-derived-state-no-effect`

`session-chat-content.tsx:575-577` -- Uses a `useEffect` just to set `hasMounted`:

```typescript
useEffect(() => { setHasMounted(true); }, []);
```

This causes an extra render on mount. A ref would avoid the extra render if the only purpose is guarding hydration.

**Fix:** Replaced with `useSyncExternalStore` which returns `false` during SSR and `true` on the client without triggering an extra render cycle:

```typescript
const emptySubscribe = () => () => {};
function useHasMounted() {
  return useSyncExternalStore(emptySubscribe, () => true, () => false);
}
```

### ~~4d. ~25 useState calls in one component~~ IMPROVED

`session-chat-content.tsx:553-565,698-703` -- The `SessionChatContent` function has approximately 25 `useState` calls and ~15 `useEffect` hooks. This monolithic component means any state change triggers re-evaluation of the entire 2200-line render function. Extracting sub-trees into memoized child components (`memo()`) would significantly reduce work per render.

**Fix:** Extracted the sidebar into a `ChatSidebar` component (`chat-sidebar.tsx`) with its own local state for title editing and chat renaming. This moves 6 `useState` calls and 2 `useEffect` hooks out of the parent, so sidebar editing no longer triggers re-evaluation of the message list and input areas. Further extraction of the header, message list, and input area into memoized components would provide additional wins.

### ~~4e. Scroll effect triggered too frequently~~ FIXED

**Rule violated:** `rerender-dependencies`

`session-chat-content.tsx:1108-1112`:

```typescript
useEffect(() => {
  if (isAtBottom) { scrollToBottom(); }
}, [messages, isAtBottom, scrollToBottom]);
```

`messages` changes on every streaming token, causing this effect to fire on every token. A more targeted dependency (e.g., `messages.length`) or debouncing would reduce overhead.

---

## 5. Client-Side Data Fetching -- MEDIUM-HIGH

### ~~5a. Direct `fetch()` bypasses SWR cache~~ FIXED

**Rule violated:** `client-swr-dedup`

Multiple components make direct `fetch()` calls for data that SWR already caches:

- `CreateRepoDialog` fetches `GET /api/github/installations` with raw `fetch()`, but `RepoSelectorCompact` fetches the same endpoint via SWR. The direct `fetch()` misses the SWR cache and deduplication.

**Fix:** Replaced `CreateRepoDialog`'s raw `fetch()` + `useEffect` with `useSWR` using the same `"github-installations"` cache key as `RepoSelectorCompact`, ensuring shared cache and deduplication.

---

## 6. Rendering Performance -- MEDIUM

### ~~6a. Conditional rendering with `&&`~~ FIXED

**Rule violated:** `rendering-conditional-render`

There are likely instances of `{condition && <Component />}` patterns throughout the codebase. When `condition` is `0` or `""`, React renders those falsy values. Prefer explicit ternaries: `{condition ? <Component /> : null}`.

**Fix:** Converted 5 instances across `usage-section.tsx`, `question-panel.tsx`, `diff-viewer.tsx`, and `task-renderer.tsx` to explicit ternary patterns. All conditions were boolean or string values (none could produce `0`), but converted for consistency.

---

## 7. Unused Code / Dead Weight

### ~~7a. Unused dependencies~~ FIXED

- `react-hook-form` and `@hookform/resolvers` are in `package.json` but never imported in any application component.
- `app/chat-context.tsx` exports `ChatProvider`/`useChatContext` but no route imports it. This is likely legacy code.

**Fix:** Removed `react-hook-form` and `@hookform/resolvers` from `package.json`. Deleted the unused `components/ui/form.tsx` (only consumer of react-hook-form) and `app/chat-context.tsx` (dead legacy code replaced by `SessionChatProvider`).

### ~~7b. No error boundaries~~ FIXED

No `error.tsx` files exist in any route segment and no `<ErrorBoundary>` components are used. If any server component throws, users see the default Next.js error page.

**Fix:** Added `error.tsx` files for key route segments (`sessions/[sessionId]/chats/[chatId]`, `shared/[shareId]`) providing user-friendly error UI with retry functionality.

---

## Summary by Priority

| Priority | Issue | Rule | Impact | Status |
|----------|-------|------|--------|--------|
| **CRITICAL** | Sequential awaits in page.tsx | `async-parallel` | 2-3x slower page load | FIXED |
| **CRITICAL** | No Suspense boundaries | `async-suspense-boundaries` | No streaming, 5s blocking possible | FIXED |
| **CRITICAL** | No dynamic imports for modals | `bundle-dynamic-imports` | Inflated initial bundle | FIXED |
| **CRITICAL** | Barrel imports from lucide-react | `bundle-barrel-imports` | +200-800ms cold start | FIXED |
| **HIGH** | Context value not memoized (40 props) | `rerender-memo` | All context consumers re-render | FIXED |
| **HIGH** | Full session record to client | `server-serialization` | Serialization overhead | INVESTIGATED (full record needed) |
| **HIGH** | Monolithic 2200-line component | `rerender-memo` | No render bailouts possible | IMPROVED (sidebar extracted) |
| **MEDIUM** | Derived state not memoized | `rerender-derived-state` | Unnecessary per-token work | FIXED |
| **MEDIUM** | Scroll effect on every token | `rerender-dependencies` | Excessive effect runs | FIXED |
| **MEDIUM** | sandboxUiStatus IIFE | `rerender-memo` | Unnecessary per-render work | FIXED |
| **MEDIUM** | hasMounted effect pattern | `rerender-derived-state` | Extra render on mount | FIXED |
| **MEDIUM** | Direct fetch bypasses SWR | `client-swr-dedup` | Duplicate network requests | FIXED |
| **MEDIUM** | Conditional rendering with && | `rendering-conditional-render` | Potential falsy renders | FIXED |
| **LOW** | Unused dependencies | `bundle-*` | Unnecessary package weight | FIXED |
| **LOW** | No error boundaries | N/A | Poor error UX | FIXED |

All audit items have been addressed.