Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8291967
feat: load sandbox from Recoup API snapshot
sweetmantech Feb 9, 2026
16069ce
refactor: extract sandbox creation into lib/sandbox/createNewSandbox.ts
sweetmantech Feb 9, 2026
7e4dea3
feat: delegate sandbox creation to Recoup API
sweetmantech Feb 9, 2026
248b54b
fix: use env-based API URL to match Recoup-Chat pattern
sweetmantech Feb 9, 2026
26f7783
chore: add timing logs to identify latency bottleneck
sweetmantech Feb 9, 2026
d1ed53a
perf: skip tool discovery in createBashTool with static prompt
sweetmantech Feb 9, 2026
9ec3236
fix: always upload source files regardless of sandbox source
sweetmantech Feb 9, 2026
99d0560
revert: skip file upload when using snapshot sandbox
sweetmantech Feb 9, 2026
3686ebf
perf: warm up sandbox after snapshot restore
sweetmantech Feb 9, 2026
32c6dd4
perf: create snapshot sandbox locally instead of via Sandbox.get
sweetmantech Feb 9, 2026
0f76137
chore: remove dev timing logs and unused createSandbox
sweetmantech Feb 9, 2026
f9d0d83
feat: add /new page with fresh sandbox for performance comparison
sweetmantech Feb 9, 2026
b7b2983
fix: add git to static tool prompt
sweetmantech Feb 9, 2026
15201ac
refactor: extract shared agent logic into lib to follow DRY
sweetmantech Feb 9, 2026
adca78a
refactor: extract shared TerminalPage component for auth boilerplate
sweetmantech Feb 9, 2026
8ea819c
refactor: extract handleAgentRequest to DRY route boilerplate
sweetmantech Feb 9, 2026
31ce711
refactor: centralize AGENT_DATA_DIR in constants
sweetmantech Feb 9, 2026
73cc60d
refactor: rename createNewSandbox to createSnapshotSandbox
sweetmantech Feb 9, 2026
f321c8a
refactor: extract readSourceFiles into its own lib file
sweetmantech Feb 9, 2026
24feb3a
refactor: use POST /api/sandboxes + Sandbox.get for snapshot sandbox
sweetmantech Feb 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/api/agent/new/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createFreshSandbox } from "@/lib/sandbox/createFreshSandbox";
import { handleAgentRequest } from "@/lib/agent/createAgentResponse";
import { AGENT_DATA_DIR } from "@/lib/agent/constants";

export async function POST(req: Request) {
return handleAgentRequest(req, () => createFreshSandbox(AGENT_DATA_DIR));
}
136 changes: 6 additions & 130 deletions app/api/agent/route.ts
Original file line number Diff line number Diff line change
@@ -1,133 +1,9 @@
import { ToolLoopAgent, createAgentUIStreamResponse, stepCountIs } from "ai";
import { createBashTool } from "bash-tool";
import { Sandbox } from "@vercel/sandbox";
import { readdirSync, readFileSync } from "fs";
import { dirname, join, relative } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const AGENT_DATA_DIR = join(__dirname, "./_agent-data");
const SANDBOX_CWD = "/vercel/sandbox";

const SYSTEM_INSTRUCTIONS = `You are an expert on just-bash, a TypeScript bash interpreter with an in-memory virtual filesystem.

You have access to a real bash sandbox with the full source code of:
- just-bash/ - The main bash interpreter
- bash-tool/ - AI SDK tool for bash

The source files are located at ${SANDBOX_CWD}.

Refer to the README.md of the projects to answer questions about just-bash and bash-tool
themselves which is your main focus. Never talk about this demo implementation unless asked explicitly.

Use the sandbox to explore the source code, demonstrate commands, and help users understand:
- How to use just-bash and bash-tool
- Bash scripting in general
- The implementation details of just-bash

Key features of just-bash:
- Pure TypeScript implementation (no WASM dependencies)
- In-memory virtual filesystem
- Supports common bash commands: ls, cat, grep, awk, sed, jq, etc.
- Custom command support via defineCommand
- Network access control with URL allowlists

Use cat to read files. Use head, tail to read parts of large files.

Keep responses concise. You have access to a full Linux environment with standard tools.`;

/**
* Recursively read all files from a directory, returning them in the format
* expected by Sandbox.writeFiles().
*/
function readSourceFiles(
dir: string,
baseDir?: string
): Array<{ path: string; content: Buffer }> {
const base = baseDir ?? dir;
const files: Array<{ path: string; content: Buffer }> = [];

for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
// Skip node_modules and other large/irrelevant dirs
if (entry.name === "node_modules" || entry.name === ".git") continue;
files.push(...readSourceFiles(fullPath, base));
} else {
const relPath = relative(base, fullPath);
files.push({
path: join(SANDBOX_CWD, relPath),
content: readFileSync(fullPath),
});
}
}

return files;
}
import { createSnapshotSandbox } from "@/lib/sandbox/createSnapshotSandbox";
import { handleAgentRequest } from "@/lib/agent/createAgentResponse";
import { AGENT_DATA_DIR } from "@/lib/agent/constants";

export async function POST(req: Request) {
const authHeader = req.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return Response.json(
{ error: "Unauthorized" },
{ status: 401 },
);
}

const { messages } = await req.json();
const lastUserMessage = messages
.filter((m: { role: string }) => m.role === "user")
.pop();
console.log("Prompt:", lastUserMessage?.parts?.[0]?.text);

const sandbox = await Sandbox.create();

try {
// Upload source files so the agent can explore them
const files = readSourceFiles(AGENT_DATA_DIR);
if (files.length > 0) {
await sandbox.writeFiles(files);
}

const bashToolkit = await createBashTool({
sandbox,
destination: SANDBOX_CWD,
});

// Create a fresh agent per request for proper streaming
const agent = new ToolLoopAgent({
model: "claude-haiku-4-5",
instructions: SYSTEM_INSTRUCTIONS,
tools: {
bash: bashToolkit.tools.bash,
},
stopWhen: stepCountIs(20),
});

const response = await createAgentUIStreamResponse({
agent,
uiMessages: messages,
});

// Clean up sandbox after the stream finishes (not before).
// The original `finally` block killed the sandbox immediately when
// createAgentUIStreamResponse returned, before any tool calls ran.
const body = response.body;
if (body) {
const transform = new TransformStream();
body.pipeTo(transform.writable).finally(() => {
sandbox.stop().catch(() => {});
});
return new Response(transform.readable, {
headers: response.headers,
status: response.status,
});
}

sandbox.stop().catch(() => {});
return response;
} catch (error) {
sandbox.stop().catch(() => {});
throw error;
}
return handleAgentRequest(req, (bearerToken) =>
createSnapshotSandbox(bearerToken, AGENT_DATA_DIR),
);
}
6 changes: 4 additions & 2 deletions app/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ function getTheme(isDark: boolean) {

export default function TerminalComponent({
getAccessToken,
agentEndpoint,
}: {
getAccessToken: () => Promise<string | null>;
agentEndpoint?: string;
}) {
const terminalRef = useRef<HTMLDivElement>(null);

Expand All @@ -51,7 +53,7 @@ export default function TerminalComponent({

// Create commands
const { aboutCmd, installCmd, githubCmd } = createStaticCommands();
const agentCmd = createAgentCommand(term, getAccessToken);
const agentCmd = createAgentCommand(term, getAccessToken, agentEndpoint);

// Files from DOM
const files = {
Expand Down Expand Up @@ -114,7 +116,7 @@ export default function TerminalComponent({
colorSchemeQuery.removeEventListener("change", onColorSchemeChange);
term.dispose();
};
}, [getAccessToken]);
}, [getAccessToken, agentEndpoint]);

return (
<div
Expand Down
74 changes: 74 additions & 0 deletions app/components/TerminalPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";

import { useEffect, useState, ReactNode } from "react";
import { usePrivy } from "@privy-io/react-auth";
import TerminalComponent from "./Terminal";
import { TerminalData } from "./TerminalData";

export default function TerminalPage({
agentEndpoint,
children,
}: {
agentEndpoint?: string;
children?: ReactNode;
}) {
const [mounted, setMounted] = useState(false);
const { ready, authenticated, login, getAccessToken } = usePrivy();

useEffect(() => {
setMounted(true);
}, []);

if (!mounted || !ready) {
return (
<>
{children}
<TerminalData />
</>
);
}

if (!authenticated) {
return (
<>
{children}
<TerminalData />
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
fontFamily: "var(--font-geist-mono), monospace",
}}
>
<button
onClick={login}
style={{
background: "none",
border: "1px solid currentColor",
color: "inherit",
padding: "12px 24px",
fontSize: "16px",
fontFamily: "inherit",
cursor: "pointer",
}}
>
Log in to continue
</button>
</div>
</>
);
}

return (
<>
{children}
<TerminalData />
<TerminalComponent
getAccessToken={getAccessToken}
agentEndpoint={agentEndpoint}
/>
</>
);
}
3 changes: 2 additions & 1 deletion app/components/terminal-parts/agent-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function formatForTerminal(text: string): string {
export function createAgentCommand(
term: TerminalWriter,
getAccessToken: () => Promise<string | null>,
agentEndpoint = "/api/agent",
) {
const agentMessages: UIMessage[] = [];
let messageIdCounter = 0;
Expand Down Expand Up @@ -62,7 +63,7 @@ export function createAgentCommand(
};
}

const response = await fetch("/api/agent", {
const response = await fetch(agentEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Expand Down
5 changes: 5 additions & 0 deletions app/new/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import TerminalPage from "../components/TerminalPage";

export default function NewPage() {
return <TerminalPage agentEndpoint="/api/agent/new" />;
}
63 changes: 3 additions & 60 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
"use client";

import { useEffect, useState } from "react";
import { usePrivy } from "@privy-io/react-auth";
import TerminalComponent from "./components/Terminal";
import { TerminalData } from "./components/TerminalData";
import TerminalPage from "./components/TerminalPage";

const NOSCRIPT_CONTENT = `
_ _ _ _
Expand Down Expand Up @@ -94,66 +89,14 @@ const NOSCRIPT_CONTENT = `
`;

export default function Home() {
const [mounted, setMounted] = useState(false);
const { ready, authenticated, login, getAccessToken } = usePrivy();

useEffect(() => {
setMounted(true);
}, []);

if (!mounted || !ready) {
return (
<>
<noscript>
<pre>{NOSCRIPT_CONTENT}</pre>
</noscript>
<TerminalData />
</>
);
}

if (!authenticated) {
return (
<>
<TerminalData />
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
fontFamily: "var(--font-geist-mono), monospace",
}}
>
<button
onClick={login}
style={{
background: "none",
border: "1px solid currentColor",
color: "inherit",
padding: "12px 24px",
fontSize: "16px",
fontFamily: "inherit",
cursor: "pointer",
}}
>
Log in to continue
</button>
</div>
</>
);
}

return (
<>
<TerminalPage>
<noscript>
<pre>{NOSCRIPT_CONTENT}</pre>
</noscript>
<TerminalData />
<TerminalComponent getAccessToken={getAccessToken} />
<a href="https://vercel.com" target="_blank" hidden id="credits">
Created by Vercel Labs
</a>
</>
</TerminalPage>
);
}
35 changes: 35 additions & 0 deletions lib/agent/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { join } from "path";

export const AGENT_DATA_DIR = join(process.cwd(), "app/api/agent/_agent-data");

export const SANDBOX_CWD = "/vercel/sandbox";

export const TOOL_PROMPT =
"Available tools: awk, cat, column, curl, cut, diff, find, git, grep, head, jq, join, nl, node, od, paste, printf, rev, sed, sort, split, strings, tail, tee, tr, uniq, wc, xargs, xxd, and more";

export const SYSTEM_INSTRUCTIONS = `You are an expert on just-bash, a TypeScript bash interpreter with an in-memory virtual filesystem.

You have access to a real bash sandbox with the full source code of:
- just-bash/ - The main bash interpreter
- bash-tool/ - AI SDK tool for bash

The source files are located at ${SANDBOX_CWD}.

Refer to the README.md of the projects to answer questions about just-bash and bash-tool
themselves which is your main focus. Never talk about this demo implementation unless asked explicitly.

Use the sandbox to explore the source code, demonstrate commands, and help users understand:
- How to use just-bash and bash-tool
- Bash scripting in general
- The implementation details of just-bash

Key features of just-bash:
- Pure TypeScript implementation (no WASM dependencies)
- In-memory virtual filesystem
- Supports common bash commands: ls, cat, grep, awk, sed, jq, etc.
- Custom command support via defineCommand
- Network access control with URL allowlists

Use cat to read files. Use head, tail to read parts of large files.

Keep responses concise. You have access to a full Linux environment with standard tools.`;
Loading