@papercraneai/sandbox-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1152 @@
1
+ #!/usr/bin/env node
2
+ import express from "express";
3
+ import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
4
+ import { readdir, stat, mkdir, readFile, writeFile, access } from "fs/promises";
5
+ import { join, dirname, resolve } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { spawn, execSync } from "child_process";
8
+ import { homedir } from "os";
9
+ import { z } from "zod";
10
+ import multer from "multer";
11
+ import * as log from "./logger.js";
12
+ // Get the directory of this file (works for both source and compiled)
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = dirname(__filename);
15
+ // Read package version at startup
16
+ const pkg = JSON.parse(await readFile(join(__dirname, "..", "package.json"), "utf-8"));
17
+ // Parse CLI arguments
18
+ function parseArgs() {
19
+ const args = process.argv.slice(2);
20
+ const result = {
21
+ register: false,
22
+ token: null,
23
+ papercraneUrl: process.env.PAPERCRANE_URL || "https://fly.papercrane.ai",
24
+ agentPort: parseInt(process.env.PORT || "3001"),
25
+ devPort: parseInt(process.env.DEV_PORT || "3000"),
26
+ workdir: join(homedir(), ".papercrane", "sandbox"),
27
+ agentOnly: false
28
+ };
29
+ for (let i = 0; i < args.length; i++) {
30
+ const arg = args[i];
31
+ if (arg === "--register") {
32
+ result.register = true;
33
+ }
34
+ else if (arg === "--token" && args[i + 1]) {
35
+ result.token = args[++i];
36
+ }
37
+ else if (arg === "--papercrane-url" && args[i + 1]) {
38
+ result.papercraneUrl = args[++i];
39
+ }
40
+ else if (arg === "--port" && args[i + 1]) {
41
+ result.agentPort = parseInt(args[++i]);
42
+ }
43
+ else if (arg === "--dev-port" && args[i + 1]) {
44
+ result.devPort = parseInt(args[++i]);
45
+ }
46
+ else if (arg === "--workdir" && args[i + 1]) {
47
+ result.workdir = args[++i];
48
+ }
49
+ else if (arg === "--agent-only") {
50
+ result.agentOnly = true;
51
+ }
52
+ else if (arg === "--help" || arg === "-h") {
53
+ console.log(`
54
+ sandbox-agent - Claude Agent SDK server for environments
55
+
56
+ Usage:
57
+ sandbox-agent [options]
58
+
59
+ Modes:
60
+ Standalone (default): Full setup - copies template, installs deps, starts dev server
61
+ Agent-only: Just runs the agent API (use with --agent-only)
62
+
63
+ Options:
64
+ --workdir <path> Project directory (default: ~/.papercrane/sandbox)
65
+ --agent-only Only run agent API (no template setup, no dev server)
66
+ --port <port> Agent API port (default: 3001)
67
+ --dev-port <port> Dev server port, standalone only (default: 3000)
68
+ --register Register with Papercrane server (requires --token)
69
+ --token <token> Connection token from Papercrane UI
70
+ --papercrane-url <url> Papercrane server URL (default: https://fly.papercrane.ai)
71
+ --help, -h Show this help message
72
+
73
+ Environment Variables:
74
+ PORT Agent API port (same as --port)
75
+ DEV_PORT Dev server port (same as --dev-port)
76
+ PAPERCRANE_URL Papercrane server URL (same as --papercrane-url)
77
+
78
+ Examples:
79
+ sandbox-agent # Standalone: full setup
80
+ sandbox-agent --workdir ~/my-project # Standalone: custom directory
81
+ sandbox-agent --agent-only --workdir /tmp/app # Agent-only: just the API
82
+ `);
83
+ process.exit(0);
84
+ }
85
+ }
86
+ return result;
87
+ }
88
+ const cliArgs = parseArgs();
89
+ // =============================================================================
90
+ // Template Management
91
+ // =============================================================================
92
+ const TEMPLATE_REPO_URL = process.env.TEMPLATE_REPO_URL || "https://github.com/papercraneai/sandbox-template.git";
93
+ // Get the working directory for the project (Next.js root)
94
+ function getWorkingDirectory() {
95
+ return cliArgs.workdir;
96
+ }
97
+ // Get the PROJECT_DIR (src/app within the working directory)
98
+ // In agent-only mode, workdir IS the project dir (e.g., /tmp/project/src/app)
99
+ function getProjectDir() {
100
+ if (cliArgs.agentOnly) {
101
+ return cliArgs.workdir;
102
+ }
103
+ return join(cliArgs.workdir, "src", "app");
104
+ }
105
+ // Check if directory exists
106
+ async function directoryExists(path) {
107
+ try {
108
+ const stats = await stat(path);
109
+ return stats.isDirectory();
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ }
115
+ // Check if file exists
116
+ async function fileExists(path) {
117
+ try {
118
+ await access(path);
119
+ return true;
120
+ }
121
+ catch {
122
+ return false;
123
+ }
124
+ }
125
+ // Run a shell command and return stdout
126
+ function runCommand(command, cwd) {
127
+ return execSync(command, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
128
+ }
129
+ // Run npm install in the working directory
130
+ async function runNpmInstall(workdir) {
131
+ console.log("Installing dependencies (this may take a minute)...");
132
+ return new Promise((resolve, reject) => {
133
+ const npm = spawn("npm", ["install"], {
134
+ cwd: workdir,
135
+ stdio: "inherit",
136
+ shell: true
137
+ });
138
+ npm.on("close", (code) => {
139
+ if (code === 0) {
140
+ console.log("✓ Dependencies installed");
141
+ resolve();
142
+ }
143
+ else {
144
+ reject(new Error(`npm install exited with code ${code}`));
145
+ }
146
+ });
147
+ npm.on("error", reject);
148
+ });
149
+ }
150
+ // Clone template repo to working directory
151
+ async function cloneTemplate(workdir) {
152
+ console.log(`Cloning template from ${TEMPLATE_REPO_URL} to ${workdir}...`);
153
+ // Create parent directory if needed
154
+ await mkdir(dirname(workdir), { recursive: true });
155
+ return new Promise((resolve, reject) => {
156
+ const git = spawn("git", ["clone", TEMPLATE_REPO_URL, workdir], {
157
+ stdio: "inherit",
158
+ shell: true
159
+ });
160
+ git.on("close", (code) => {
161
+ if (code === 0) {
162
+ console.log("✓ Template cloned");
163
+ resolve();
164
+ }
165
+ else {
166
+ reject(new Error(`git clone exited with code ${code}`));
167
+ }
168
+ });
169
+ git.on("error", reject);
170
+ });
171
+ }
172
+ // Create initial src/app/ scaffold files if they don't exist
173
+ async function ensureAppScaffold(workdir) {
174
+ const appDir = join(workdir, "src", "app");
175
+ await mkdir(appDir, { recursive: true });
176
+ const layoutPath = join(appDir, "layout.tsx");
177
+ if (!(await fileExists(layoutPath))) {
178
+ await writeFile(layoutPath, `import type { Metadata } from "next"
179
+ import "./globals.css"
180
+
181
+ export const metadata: Metadata = {
182
+ title: "Papercrane App",
183
+ description: "Built with Papercrane",
184
+ }
185
+
186
+ export default function RootLayout({
187
+ children,
188
+ }: Readonly<{
189
+ children: React.ReactNode
190
+ }>) {
191
+ return (
192
+ <html lang="en" suppressHydrationWarning>
193
+ <body>{children}</body>
194
+ </html>
195
+ )
196
+ }
197
+ `);
198
+ console.log("✓ Created scaffold layout.tsx");
199
+ }
200
+ const pagePath = join(appDir, "page.tsx");
201
+ if (!(await fileExists(pagePath))) {
202
+ await writeFile(pagePath, `export default function Home() {
203
+ return (
204
+ <div className="flex min-h-screen items-center justify-center">
205
+ <h1 className="text-4xl font-bold">Welcome to Papercrane</h1>
206
+ </div>
207
+ )
208
+ }
209
+ `);
210
+ console.log("✓ Created scaffold page.tsx");
211
+ }
212
+ const globalsCssPath = join(appDir, "globals.css");
213
+ if (!(await fileExists(globalsCssPath))) {
214
+ await writeFile(globalsCssPath, `@import "tailwindcss";
215
+ `);
216
+ console.log("✓ Created scaffold globals.css");
217
+ }
218
+ }
219
+ // Next.js dev server process
220
+ let nextDevProcess = null;
221
+ // Start Next.js dev server
222
+ function startNextDevServer(workdir, port) {
223
+ return new Promise((resolve, reject) => {
224
+ console.log(`Starting Next.js dev server on port ${port}...`);
225
+ nextDevProcess = spawn("npm", ["run", "dev", "--", "-p", String(port)], {
226
+ cwd: workdir,
227
+ stdio: ["ignore", "pipe", "pipe"],
228
+ shell: true
229
+ });
230
+ let resolved = false;
231
+ // Listen for "Ready" message from Next.js
232
+ nextDevProcess.stdout?.on("data", (data) => {
233
+ const output = data.toString();
234
+ process.stdout.write(`[next] ${output}`);
235
+ if (!resolved && (output.includes("Ready") || output.includes("started server"))) {
236
+ resolved = true;
237
+ console.log("✓ Next.js dev server started");
238
+ resolve();
239
+ }
240
+ });
241
+ nextDevProcess.stderr?.on("data", (data) => {
242
+ process.stderr.write(`[next] ${data}`);
243
+ });
244
+ nextDevProcess.on("error", (err) => {
245
+ if (!resolved) {
246
+ reject(err);
247
+ }
248
+ });
249
+ nextDevProcess.on("close", (code) => {
250
+ if (!resolved) {
251
+ reject(new Error(`Next.js dev server exited with code ${code}`));
252
+ }
253
+ nextDevProcess = null;
254
+ });
255
+ // Timeout after 60 seconds
256
+ setTimeout(() => {
257
+ if (!resolved) {
258
+ resolved = true;
259
+ // Assume it started if we haven't seen an error
260
+ console.log("✓ Next.js dev server started (assumed)");
261
+ resolve();
262
+ }
263
+ }, 60000);
264
+ });
265
+ }
266
+ // Setup template and start Next.js (standalone mode only)
267
+ async function setupTemplate() {
268
+ const workdir = getWorkingDirectory();
269
+ // Check if workdir already has a git repo (already cloned)
270
+ const isGitRepo = await directoryExists(join(workdir, ".git"));
271
+ if (!isGitRepo) {
272
+ // First run: clone the template repo
273
+ await cloneTemplate(workdir);
274
+ await ensureAppScaffold(workdir);
275
+ await runNpmInstall(workdir);
276
+ }
277
+ else {
278
+ console.log(`Using existing template at ${workdir}`);
279
+ // Ensure scaffold files exist (in case they were removed)
280
+ await ensureAppScaffold(workdir);
281
+ // Check if node_modules exists
282
+ if (!(await directoryExists(join(workdir, "node_modules")))) {
283
+ await runNpmInstall(workdir);
284
+ }
285
+ }
286
+ // Start Next.js dev server
287
+ await startNextDevServer(workdir, cliArgs.devPort);
288
+ return getProjectDir();
289
+ }
290
+ // =============================================================================
291
+ // Express App Setup
292
+ // =============================================================================
293
+ const app = express();
294
+ app.use(express.json());
295
+ const PORT = cliArgs.agentPort;
296
+ let PROJECT_DIR = getProjectDir(); // Will be updated after template setup
297
+ // Registration state
298
+ let environmentId = null;
299
+ let connectionToken = null;
300
+ let heartbeatInterval = null;
301
+ // Client-side tool: ShowPreview
302
+ const showPreviewTool = tool("ShowPreview", "Shows the preview iframe for a specific route. Call this after creating or modifying a visualization so the user can see the result immediately. The preview will appear in the user's browser.", {
303
+ path: z.string().describe("The route path to preview, e.g., '/insights' or '/dashboard'. Must start with '/'")
304
+ }, async (args) => {
305
+ return {
306
+ content: [{
307
+ type: "text",
308
+ text: `Preview activated for ${args.path}. The user can now see the visualization.`
309
+ }]
310
+ };
311
+ });
312
+ // Create MCP server for client-side tools
313
+ const clientToolsServer = createSdkMcpServer({
314
+ name: "client-tools",
315
+ tools: [showPreviewTool]
316
+ });
317
+ // Recursively build file tree
318
+ async function buildFileTree(dirPath) {
319
+ const entries = await readdir(dirPath, { withFileTypes: true });
320
+ const nodes = [];
321
+ entries.sort((a, b) => {
322
+ if (a.isDirectory() && !b.isDirectory())
323
+ return -1;
324
+ if (!a.isDirectory() && b.isDirectory())
325
+ return 1;
326
+ return a.name.localeCompare(b.name);
327
+ });
328
+ for (const entry of entries) {
329
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) {
330
+ continue;
331
+ }
332
+ if (entry.isDirectory()) {
333
+ const children = await buildFileTree(join(dirPath, entry.name));
334
+ nodes.push({
335
+ name: entry.name,
336
+ type: "folder",
337
+ children
338
+ });
339
+ }
340
+ else {
341
+ nodes.push({
342
+ name: entry.name,
343
+ type: "file"
344
+ });
345
+ }
346
+ }
347
+ return nodes;
348
+ }
349
+ // Health check
350
+ app.get("/health", (_req, res) => {
351
+ res.json({ status: "healthy", projectDir: PROJECT_DIR, version: pkg.version });
352
+ });
353
+ // Pull template updates (git pull + conditional npm install)
354
+ app.post("/pull-template", async (_req, res) => {
355
+ const workdir = getWorkingDirectory();
356
+ try {
357
+ // Check if workdir is a git repo
358
+ if (!(await directoryExists(join(workdir, ".git")))) {
359
+ res.status(400).json({ error: "Working directory is not a git repository" });
360
+ return;
361
+ }
362
+ // Run git pull
363
+ const pullOutput = runCommand("git pull", workdir);
364
+ console.log(`git pull output: ${pullOutput}`);
365
+ // Check if already up to date
366
+ if (pullOutput.includes("Already up to date")) {
367
+ res.json({ updated: false, files_changed: [], npm_installed: false });
368
+ return;
369
+ }
370
+ // Get list of changed files from the pull
371
+ // git diff HEAD@{1} --name-only shows what changed in the last pull
372
+ let filesChanged = [];
373
+ try {
374
+ const diffOutput = runCommand("git diff HEAD@{1} --name-only", workdir);
375
+ filesChanged = diffOutput.split("\n").filter(f => f.trim());
376
+ }
377
+ catch {
378
+ // If HEAD@{1} doesn't exist, just report the pull succeeded
379
+ filesChanged = ["(unable to determine changed files)"];
380
+ }
381
+ // Check if package.json changed — if so, run npm install
382
+ const packageJsonChanged = filesChanged.some(f => f === "package.json" || f === "package-lock.json");
383
+ let npmInstalled = false;
384
+ if (packageJsonChanged) {
385
+ console.log("package.json changed, running npm install...");
386
+ await runNpmInstall(workdir);
387
+ npmInstalled = true;
388
+ }
389
+ // Ensure scaffold files still exist after pull
390
+ await ensureAppScaffold(workdir);
391
+ res.json({ updated: true, files_changed: filesChanged, npm_installed: npmInstalled });
392
+ }
393
+ catch (error) {
394
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "Pull template failed");
395
+ res.status(500).json({
396
+ error: error instanceof Error ? error.message : "Failed to pull template"
397
+ });
398
+ }
399
+ });
400
+ // File tree endpoint
401
+ app.get("/files/tree", async (req, res) => {
402
+ const subPath = req.query.path || "";
403
+ const fullPath = subPath ? join(PROJECT_DIR, subPath) : PROJECT_DIR;
404
+ try {
405
+ const dirStat = await stat(fullPath);
406
+ if (!dirStat.isDirectory()) {
407
+ res.status(400).json({ error: "Path is not a directory" });
408
+ return;
409
+ }
410
+ const tree = await buildFileTree(fullPath);
411
+ res.json({
412
+ path: fullPath,
413
+ tree
414
+ });
415
+ }
416
+ catch (error) {
417
+ console.error("File tree error for path:", fullPath);
418
+ console.error("Error details:", error);
419
+ if (error.code === "ENOENT") {
420
+ res.status(404).json({ error: "Directory not found", path: fullPath });
421
+ }
422
+ else {
423
+ res.status(500).json({
424
+ error: error instanceof Error ? error.message : "Unknown error",
425
+ path: fullPath
426
+ });
427
+ }
428
+ }
429
+ });
430
+ // File read endpoint - returns file contents with metadata
431
+ app.get("/files/read", async (req, res) => {
432
+ const filePath = req.query.path;
433
+ if (!filePath) {
434
+ res.status(400).json({ error: "Path query parameter is required" });
435
+ return;
436
+ }
437
+ // Resolve path relative to PROJECT_DIR
438
+ const fullPath = filePath.startsWith("/") ? filePath : join(PROJECT_DIR, filePath);
439
+ // Security: ensure path is within PROJECT_DIR or a parent (for standalone mode)
440
+ const normalizedPath = join(fullPath);
441
+ if (!normalizedPath.startsWith(PROJECT_DIR) && !PROJECT_DIR.startsWith(normalizedPath.split("/").slice(0, -1).join("/"))) {
442
+ res.status(403).json({ error: "Access denied: path outside project directory" });
443
+ return;
444
+ }
445
+ try {
446
+ const stats = await stat(fullPath);
447
+ if (stats.isDirectory()) {
448
+ res.status(400).json({ error: "Path is a directory, not a file" });
449
+ return;
450
+ }
451
+ // Limit file size to 1MB for reading
452
+ const MAX_FILE_SIZE = 1 * 1024 * 1024;
453
+ if (stats.size > MAX_FILE_SIZE) {
454
+ res.status(413).json({
455
+ error: "File too large to read",
456
+ size: stats.size,
457
+ maxSize: MAX_FILE_SIZE
458
+ });
459
+ return;
460
+ }
461
+ const content = await readFile(fullPath, "utf-8");
462
+ const ext = fullPath.split(".").pop()?.toLowerCase() || "";
463
+ // Determine mime type
464
+ const mimeTypes = {
465
+ ts: "text/typescript",
466
+ tsx: "text/typescript",
467
+ js: "text/javascript",
468
+ jsx: "text/javascript",
469
+ json: "application/json",
470
+ css: "text/css",
471
+ html: "text/html",
472
+ md: "text/markdown",
473
+ txt: "text/plain",
474
+ svg: "image/svg+xml",
475
+ png: "image/png",
476
+ jpg: "image/jpeg",
477
+ jpeg: "image/jpeg",
478
+ gif: "image/gif",
479
+ webp: "image/webp"
480
+ };
481
+ res.json({
482
+ path: fullPath,
483
+ name: fullPath.split("/").pop(),
484
+ content,
485
+ size: stats.size,
486
+ mimeType: mimeTypes[ext] || "text/plain",
487
+ extension: ext
488
+ });
489
+ }
490
+ catch (error) {
491
+ if (error.code === "ENOENT") {
492
+ res.status(404).json({ error: "File not found", path: fullPath });
493
+ }
494
+ else {
495
+ log.error({ error: error instanceof Error ? error.message : String(error), path: fullPath }, "File read error");
496
+ res.status(500).json({ error: error instanceof Error ? error.message : "Unknown error" });
497
+ }
498
+ }
499
+ });
500
+ // File download endpoint - returns file as attachment
501
+ app.get("/files/download", async (req, res) => {
502
+ const filePath = req.query.path;
503
+ if (!filePath) {
504
+ res.status(400).json({ error: "Path query parameter is required" });
505
+ return;
506
+ }
507
+ const fullPath = filePath.startsWith("/") ? filePath : join(PROJECT_DIR, filePath);
508
+ // Security check
509
+ const normalizedPath = join(fullPath);
510
+ if (!normalizedPath.startsWith(PROJECT_DIR) && !PROJECT_DIR.startsWith(normalizedPath.split("/").slice(0, -1).join("/"))) {
511
+ res.status(403).json({ error: "Access denied: path outside project directory" });
512
+ return;
513
+ }
514
+ try {
515
+ const stats = await stat(fullPath);
516
+ if (stats.isDirectory()) {
517
+ res.status(400).json({ error: "Cannot download a directory" });
518
+ return;
519
+ }
520
+ const fileName = fullPath.split("/").pop() || "file";
521
+ const content = await readFile(fullPath);
522
+ res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
523
+ res.setHeader("Content-Type", "application/octet-stream");
524
+ res.setHeader("Content-Length", stats.size);
525
+ res.send(content);
526
+ }
527
+ catch (error) {
528
+ if (error.code === "ENOENT") {
529
+ res.status(404).json({ error: "File not found", path: fullPath });
530
+ }
531
+ else {
532
+ log.error({ error: error instanceof Error ? error.message : String(error), path: fullPath }, "File download error");
533
+ res.status(500).json({ error: error instanceof Error ? error.message : "Unknown error" });
534
+ }
535
+ }
536
+ });
537
+ // File upload endpoint
538
+ const upload = multer({
539
+ storage: multer.memoryStorage(),
540
+ limits: { fileSize: 10 * 1024 * 1024 } // 10MB limit per file
541
+ });
542
+ app.post("/files/upload", upload.array("files", 10), async (req, res) => {
543
+ try {
544
+ const files = req.files;
545
+ if (!files || files.length === 0) {
546
+ res.status(400).json({ error: "No files provided" });
547
+ return;
548
+ }
549
+ // Get target path from query param or form field (defaults to "uploads")
550
+ const targetPath = req.query.targetPath || req.body?.targetPath || "uploads";
551
+ // Sanitize target path - remove leading/trailing slashes and prevent traversal
552
+ const sanitizedTargetPath = targetPath
553
+ .replace(/^\/+|\/+$/g, "") // Remove leading/trailing slashes
554
+ .split("/")
555
+ .filter(part => part !== ".." && part !== ".") // Remove traversal attempts
556
+ .join("/");
557
+ // Build the full target directory path
558
+ const targetDir = join(PROJECT_DIR, sanitizedTargetPath);
559
+ // Ensure target is within PROJECT_DIR
560
+ const resolvedTarget = resolve(targetDir);
561
+ const resolvedProject = resolve(PROJECT_DIR);
562
+ if (!resolvedTarget.startsWith(resolvedProject)) {
563
+ res.status(403).json({ error: "Invalid target path" });
564
+ return;
565
+ }
566
+ // Create target directory if it doesn't exist
567
+ await mkdir(targetDir, { recursive: true });
568
+ const uploadedFiles = [];
569
+ for (const file of files) {
570
+ // Sanitize filename - remove path traversal attempts
571
+ const safeName = file.originalname.replace(/[\/\\]/g, "_");
572
+ const filePath = join(targetDir, safeName);
573
+ const relativePath = sanitizedTargetPath ? `${sanitizedTargetPath}/${safeName}` : safeName;
574
+ await writeFile(filePath, file.buffer);
575
+ uploadedFiles.push({
576
+ name: safeName,
577
+ path: relativePath,
578
+ size: file.size
579
+ });
580
+ log.info({ fileName: safeName, size: file.size, targetPath: sanitizedTargetPath }, "File uploaded");
581
+ }
582
+ res.json({ files: uploadedFiles });
583
+ }
584
+ catch (error) {
585
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "File upload failed");
586
+ res.status(500).json({
587
+ error: error instanceof Error ? error.message : "Upload failed"
588
+ });
589
+ }
590
+ });
591
+ // Helper to encode path for Claude session storage (slashes and dots become dashes)
592
+ function encodeSessionPath(path) {
593
+ return path.replace(/[\/\.]/g, "-");
594
+ }
595
+ // Get the directory where Claude stores sessions for this project
596
+ function getSessionsDir() {
597
+ const claudeConfigDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
598
+ const encodedPath = encodeSessionPath(PROJECT_DIR);
599
+ return join(claudeConfigDir, "projects", encodedPath);
600
+ }
601
+ // Parse a JSONL file to extract session metadata
602
+ async function getSessionMetadata(sessionPath, sessionId) {
603
+ try {
604
+ const content = await readFile(sessionPath, "utf-8");
605
+ const lines = content.trim().split("\n").filter(line => line.trim());
606
+ let firstMessage = null;
607
+ let lastMessageAt = null;
608
+ let createdAt = null;
609
+ let messageCount = 0;
610
+ for (const line of lines) {
611
+ try {
612
+ const entry = JSON.parse(line);
613
+ // Track timestamps
614
+ if (entry.timestamp) {
615
+ if (!createdAt)
616
+ createdAt = entry.timestamp;
617
+ lastMessageAt = entry.timestamp;
618
+ }
619
+ // Count user/assistant messages and get first user message
620
+ if (entry.type === "user" || entry.type === "assistant") {
621
+ messageCount++;
622
+ if (entry.type === "user" && !firstMessage && entry.message?.content) {
623
+ // Handle both string content and array content
624
+ const content = entry.message.content;
625
+ if (typeof content === "string") {
626
+ firstMessage = content.slice(0, 100);
627
+ }
628
+ else if (Array.isArray(content)) {
629
+ const textBlock = content.find((b) => b.type === "text");
630
+ if (textBlock?.text) {
631
+ firstMessage = textBlock.text.slice(0, 100);
632
+ }
633
+ }
634
+ }
635
+ }
636
+ }
637
+ catch {
638
+ // Skip malformed lines
639
+ }
640
+ }
641
+ return { id: sessionId, firstMessage, lastMessageAt, createdAt, messageCount };
642
+ }
643
+ catch (error) {
644
+ return { id: sessionId, firstMessage: null, lastMessageAt: null, createdAt: null, messageCount: 0 };
645
+ }
646
+ }
647
+ // List all chat sessions
648
+ app.get("/sessions", async (_req, res) => {
649
+ try {
650
+ const sessionsDir = getSessionsDir();
651
+ log.info({ sessionsDir, projectDir: PROJECT_DIR }, "Listing sessions");
652
+ // Check if sessions directory exists
653
+ if (!(await directoryExists(sessionsDir))) {
654
+ log.info({ sessionsDir }, "Sessions directory does not exist");
655
+ res.json({ sessions: [] });
656
+ return;
657
+ }
658
+ const files = await readdir(sessionsDir);
659
+ log.info({ sessionsDir, fileCount: files.length, files }, "Found files in sessions directory");
660
+ const jsonlFiles = files.filter(f => f.endsWith(".jsonl"));
661
+ log.info({ jsonlFileCount: jsonlFiles.length, jsonlFiles }, "Found JSONL session files");
662
+ // Get metadata for each session
663
+ const sessions = [];
664
+ for (const file of jsonlFiles) {
665
+ const sessionId = file.replace(".jsonl", "");
666
+ const sessionPath = join(sessionsDir, file);
667
+ const metadata = await getSessionMetadata(sessionPath, sessionId);
668
+ sessions.push(metadata);
669
+ }
670
+ // Sort by lastMessageAt descending (most recent first)
671
+ sessions.sort((a, b) => {
672
+ if (!a.lastMessageAt)
673
+ return 1;
674
+ if (!b.lastMessageAt)
675
+ return -1;
676
+ return new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime();
677
+ });
678
+ log.info({ sessionCount: sessions.length }, "Returning sessions");
679
+ res.json({ sessions });
680
+ }
681
+ catch (error) {
682
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "Failed to list sessions");
683
+ res.status(500).json({ error: "Failed to list sessions" });
684
+ }
685
+ });
686
+ // Get a single session's messages
687
+ app.get("/sessions/:id", async (req, res) => {
688
+ try {
689
+ const { id } = req.params;
690
+ const sessionsDir = getSessionsDir();
691
+ const sessionPath = join(sessionsDir, `${id}.jsonl`);
692
+ // Check if session file exists
693
+ if (!(await fileExists(sessionPath))) {
694
+ res.status(404).json({ error: "Session not found" });
695
+ return;
696
+ }
697
+ const content = await readFile(sessionPath, "utf-8");
698
+ const lines = content.trim().split("\n").filter(line => line.trim());
699
+ // Parse messages, transforming to chat UI format
700
+ const messages = [];
701
+ for (const line of lines) {
702
+ try {
703
+ const entry = JSON.parse(line);
704
+ // Only include user and assistant messages
705
+ if (entry.type === "user" && entry.message) {
706
+ const content = entry.message.content;
707
+ let blocks;
708
+ if (typeof content === "string") {
709
+ blocks = [{ type: "text", text: content }];
710
+ }
711
+ else if (Array.isArray(content)) {
712
+ blocks = content;
713
+ }
714
+ else {
715
+ continue;
716
+ }
717
+ messages.push({
718
+ role: "user",
719
+ blocks,
720
+ timestamp: entry.timestamp || new Date().toISOString()
721
+ });
722
+ }
723
+ else if (entry.type === "assistant" && entry.message?.content) {
724
+ messages.push({
725
+ role: "assistant",
726
+ blocks: entry.message.content,
727
+ timestamp: entry.timestamp || new Date().toISOString()
728
+ });
729
+ }
730
+ }
731
+ catch {
732
+ // Skip malformed lines
733
+ }
734
+ }
735
+ res.json({ sessionId: id, messages });
736
+ }
737
+ catch (error) {
738
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "Failed to get session");
739
+ res.status(500).json({ error: "Failed to get session" });
740
+ }
741
+ });
742
+ // Helper to build hooks for streaming
743
+ const buildHooks = (res, verbose, requestId, requestStartTime) => {
744
+ const toolTimings = new Map();
745
+ const ctx = { requestId };
746
+ const hooks = {
747
+ PreToolUse: [
748
+ {
749
+ // Log timing for all tools
750
+ hooks: [async (input, toolUseID) => {
751
+ const toolName = String(input.tool_name);
752
+ if (toolUseID) {
753
+ toolTimings.set(toolUseID, { startTime: Date.now(), toolName });
754
+ }
755
+ log.debug({ ...ctx, toolName, toolUseId: toolUseID, elapsed: Date.now() - requestStartTime }, "Tool START");
756
+ return { continue: true };
757
+ }]
758
+ },
759
+ {
760
+ matcher: /^mcp__client-tools__/,
761
+ hooks: [async (input, toolUseID) => {
762
+ res.write(`data: ${JSON.stringify({
763
+ type: "client_tool",
764
+ tool_use_id: toolUseID,
765
+ tool_name: String(input.tool_name).replace("mcp__client-tools__", ""),
766
+ input: input.tool_input
767
+ })}\n\n`);
768
+ return {
769
+ hookSpecificOutput: {
770
+ hookEventName: "PreToolUse",
771
+ permissionDecision: "allow"
772
+ }
773
+ };
774
+ }]
775
+ }
776
+ ],
777
+ PostToolUse: [{
778
+ hooks: [async (input, toolUseID) => {
779
+ const timing = toolUseID ? toolTimings.get(toolUseID) : undefined;
780
+ const duration = timing ? Date.now() - timing.startTime : 0;
781
+ log.debug({ ...ctx, toolName: String(input.tool_name), toolUseId: toolUseID, duration, elapsed: Date.now() - requestStartTime }, "Tool END");
782
+ if (toolUseID)
783
+ toolTimings.delete(toolUseID);
784
+ if (verbose) {
785
+ res.write(`data: ${JSON.stringify({
786
+ type: "tool_result",
787
+ tool_use_id: toolUseID,
788
+ tool_name: input.tool_name,
789
+ tool_response: input.tool_response
790
+ })}\n\n`);
791
+ }
792
+ return {
793
+ continue: true,
794
+ hookSpecificOutput: { hookEventName: "PostToolUse" }
795
+ };
796
+ }]
797
+ }],
798
+ PostToolUseFailure: [{
799
+ hooks: [async (input, toolUseID) => {
800
+ const timing = toolUseID ? toolTimings.get(toolUseID) : undefined;
801
+ const duration = timing ? Date.now() - timing.startTime : 0;
802
+ log.warn({ ...ctx, toolName: String(input.tool_name), toolUseId: toolUseID, duration, elapsed: Date.now() - requestStartTime, error: String(input.error) }, "Tool FAILED");
803
+ if (toolUseID)
804
+ toolTimings.delete(toolUseID);
805
+ if (verbose) {
806
+ res.write(`data: ${JSON.stringify({
807
+ type: "tool_error",
808
+ tool_use_id: toolUseID,
809
+ tool_name: input.tool_name,
810
+ error: input.error
811
+ })}\n\n`);
812
+ }
813
+ return {
814
+ continue: true,
815
+ hookSpecificOutput: { hookEventName: "PostToolUseFailure" }
816
+ };
817
+ }]
818
+ }]
819
+ };
820
+ return hooks;
821
+ };
822
+ // Chat endpoint - streaming SSE
823
+ app.post("/chat", async (req, res) => {
824
+ const requestStartTime = Date.now();
825
+ const requestId = Math.random().toString(36).substring(7);
826
+ const { message, sessionId, systemPrompt, verbose = false, subdir,
827
+ // Configurable agent options
828
+ maxTurns = 40, allowedTools, disallowedTools, model, maxBudgetUsd } = req.body;
829
+ const ctx = { requestId, sessionId };
830
+ log.info(ctx, "Agent received chat request");
831
+ if (!message) {
832
+ res.status(400).json({ error: "Message is required" });
833
+ return;
834
+ }
835
+ // Require systemPrompt for new sessions
836
+ if (!sessionId && !systemPrompt) {
837
+ res.status(400).json({ error: "systemPrompt is required for new sessions" });
838
+ return;
839
+ }
840
+ // Log the full system prompt for new sessions
841
+ if (!sessionId && systemPrompt) {
842
+ log.info(ctx, "=== NEW SESSION SYSTEM PROMPT ===");
843
+ console.log("\n" + "=".repeat(60));
844
+ console.log("SYSTEM PROMPT FOR NEW SESSION:");
845
+ console.log("=".repeat(60));
846
+ console.log(systemPrompt);
847
+ console.log("=".repeat(60) + "\n");
848
+ }
849
+ // Determine working directory - optionally use subdirectory
850
+ const cwd = subdir ? join(PROJECT_DIR, subdir) : PROJECT_DIR;
851
+ log.debug({ ...ctx, cwd }, "Working directory");
852
+ res.setHeader("Content-Type", "text/event-stream");
853
+ res.setHeader("Cache-Control", "no-cache");
854
+ res.setHeader("Connection", "keep-alive");
855
+ res.setHeader("X-Accel-Buffering", "no");
856
+ res.socket?.setNoDelay(true);
857
+ res.flushHeaders();
858
+ const abortController = new AbortController();
859
+ let isAborted = false;
860
+ res.on("close", () => {
861
+ if (!res.writableEnded) {
862
+ isAborted = true;
863
+ abortController.abort();
864
+ }
865
+ });
866
+ const hooks = buildHooks(res, verbose, requestId, requestStartTime);
867
+ async function* createPrompt() {
868
+ yield {
869
+ type: "user",
870
+ message: {
871
+ role: "user",
872
+ content: message
873
+ },
874
+ parent_tool_use_id: null
875
+ };
876
+ }
877
+ // Default tools if not specified
878
+ const defaultTools = [
879
+ "Read",
880
+ "Write",
881
+ "Edit",
882
+ "Glob",
883
+ "Grep",
884
+ "Bash",
885
+ "mcp__client-tools__ShowPreview"
886
+ ];
887
+ const options = {
888
+ maxTurns,
889
+ cwd,
890
+ permissionMode: "bypassPermissions",
891
+ allowDangerouslySkipPermissions: true,
892
+ mcpServers: {
893
+ "client-tools": clientToolsServer
894
+ },
895
+ allowedTools: allowedTools || defaultTools,
896
+ hooks,
897
+ abortController,
898
+ includePartialMessages: true
899
+ };
900
+ // System prompt must be passed on EVERY request (it's not stored with the session)
901
+ // resume only loads conversation history, not the system prompt
902
+ if (systemPrompt) {
903
+ options.systemPrompt = systemPrompt;
904
+ }
905
+ if (sessionId) {
906
+ options.resume = sessionId;
907
+ }
908
+ // Add optional parameters if provided
909
+ if (disallowedTools) {
910
+ options.disallowedTools = disallowedTools;
911
+ }
912
+ if (model) {
913
+ options.model = model;
914
+ }
915
+ if (maxBudgetUsd) {
916
+ options.maxBudgetUsd = maxBudgetUsd;
917
+ }
918
+ try {
919
+ let gotResult = false;
920
+ log.debug({ ...ctx, elapsed: Date.now() - requestStartTime }, "Starting Claude SDK query");
921
+ // Determine which model is being used (either provided or SDK default)
922
+ const usedModel = model || options.model || "claude-sonnet-4-5-20250929";
923
+ for await (const msg of query({
924
+ prompt: createPrompt(),
925
+ options
926
+ })) {
927
+ if (isAborted)
928
+ break;
929
+ // Add model to result message so it can be logged for billing
930
+ if (msg.type === "result") {
931
+ const resultWithModel = { ...msg, model: usedModel };
932
+ res.write(`data: ${JSON.stringify(resultWithModel)}\n\n`);
933
+ gotResult = true;
934
+ log.info({ ...ctx, elapsed: Date.now() - requestStartTime }, "Query complete");
935
+ res.write("data: [DONE]\n\n");
936
+ res.end();
937
+ return;
938
+ }
939
+ res.write(`data: ${JSON.stringify(msg)}\n\n`);
940
+ }
941
+ if (!gotResult && !res.writableEnded) {
942
+ log.warn({ ...ctx, elapsed: Date.now() - requestStartTime, isAborted }, "Query ended without result");
943
+ if (isAborted) {
944
+ res.write(`data: ${JSON.stringify({ type: "aborted" })}\n\n`);
945
+ }
946
+ else {
947
+ res.write(`data: ${JSON.stringify({ type: "error", error: "Stream ended unexpectedly" })}\n\n`);
948
+ }
949
+ res.write("data: [DONE]\n\n");
950
+ res.end();
951
+ }
952
+ }
953
+ catch (error) {
954
+ const isAbortError = error instanceof Error &&
955
+ (error.name === "AbortError" || error.constructor.name === "AbortError");
956
+ if (isAbortError || isAborted) {
957
+ if (!res.writableEnded) {
958
+ res.write(`data: ${JSON.stringify({ type: "aborted" })}\n\n`);
959
+ res.write("data: [DONE]\n\n");
960
+ res.end();
961
+ }
962
+ return;
963
+ }
964
+ log.error({ ...ctx, error: error instanceof Error ? error.message : String(error) }, "Agent error");
965
+ if (!res.writableEnded) {
966
+ res.write(`data: ${JSON.stringify({
967
+ type: "error",
968
+ error: error instanceof Error ? error.message : "Unknown error"
969
+ })}\n\n`);
970
+ res.end();
971
+ }
972
+ }
973
+ });
974
+ // =============================================================================
975
+ // Registration & Heartbeat
976
+ // =============================================================================
977
+ async function registerWithPapercrane() {
978
+ if (!cliArgs.token) {
979
+ console.error("Error: --token is required when using --register");
980
+ return false;
981
+ }
982
+ console.log(`Registering with Papercrane server at ${cliArgs.papercraneUrl}...`);
983
+ try {
984
+ const agentEndpoint = `http://localhost:${cliArgs.agentPort}`;
985
+ const previewEndpoint = `http://localhost:${cliArgs.devPort}`;
986
+ const res = await fetch(`${cliArgs.papercraneUrl}/api/environments/register`, {
987
+ method: "POST",
988
+ headers: { "Content-Type": "application/json" },
989
+ body: JSON.stringify({
990
+ connectionToken: cliArgs.token,
991
+ agentEndpoint,
992
+ previewEndpoint
993
+ })
994
+ });
995
+ if (!res.ok) {
996
+ const data = await res.json().catch(() => ({ error: "Unknown error" }));
997
+ console.error(`Registration failed: ${data.error}`);
998
+ return false;
999
+ }
1000
+ const data = await res.json();
1001
+ environmentId = data.environmentId;
1002
+ connectionToken = cliArgs.token;
1003
+ console.log(`✓ Successfully registered as environment #${environmentId}`);
1004
+ console.log(` Agent endpoint: ${agentEndpoint}`);
1005
+ console.log(` Preview endpoint: ${previewEndpoint}`);
1006
+ startHeartbeat();
1007
+ return true;
1008
+ }
1009
+ catch (error) {
1010
+ console.error("Registration error:", error instanceof Error ? error.message : error);
1011
+ return false;
1012
+ }
1013
+ }
1014
+ async function sendHeartbeat() {
1015
+ if (!environmentId || !connectionToken)
1016
+ return;
1017
+ try {
1018
+ const res = await fetch(`${cliArgs.papercraneUrl}/api/environments/${environmentId}/heartbeat`, {
1019
+ method: "POST",
1020
+ headers: {
1021
+ "Content-Type": "application/json",
1022
+ "Authorization": `Bearer ${connectionToken}`
1023
+ }
1024
+ });
1025
+ if (!res.ok) {
1026
+ const data = await res.json().catch(() => ({ error: "Unknown error" }));
1027
+ console.error(`Heartbeat failed: ${data.error}`);
1028
+ }
1029
+ }
1030
+ catch (error) {
1031
+ console.error("Heartbeat error:", error instanceof Error ? error.message : error);
1032
+ }
1033
+ }
1034
+ function startHeartbeat() {
1035
+ if (heartbeatInterval) {
1036
+ clearInterval(heartbeatInterval);
1037
+ }
1038
+ heartbeatInterval = setInterval(sendHeartbeat, 30000);
1039
+ sendHeartbeat();
1040
+ }
1041
+ // =============================================================================
1042
+ // Shutdown Handling
1043
+ // =============================================================================
1044
+ let isShuttingDown = false;
1045
+ function killChildProcess() {
1046
+ if (nextDevProcess) {
1047
+ console.log("Stopping Next.js dev server...");
1048
+ nextDevProcess.kill("SIGTERM");
1049
+ nextDevProcess = null;
1050
+ }
1051
+ }
1052
+ function handleShutdown(exitCode = 0) {
1053
+ if (isShuttingDown)
1054
+ return;
1055
+ isShuttingDown = true;
1056
+ console.log("\nShutting down...");
1057
+ // Stop heartbeat
1058
+ if (heartbeatInterval) {
1059
+ clearInterval(heartbeatInterval);
1060
+ heartbeatInterval = null;
1061
+ }
1062
+ // Kill Next.js dev server
1063
+ killChildProcess();
1064
+ // Notify Papercrane of disconnect
1065
+ if (environmentId && connectionToken) {
1066
+ console.log("Disconnecting from Papercrane...");
1067
+ fetch(`${cliArgs.papercraneUrl}/api/environments/${environmentId}/disconnect`, {
1068
+ method: "POST",
1069
+ headers: {
1070
+ "Content-Type": "application/json",
1071
+ "Authorization": `Bearer ${connectionToken}`
1072
+ }
1073
+ }).catch(() => { });
1074
+ }
1075
+ process.exit(exitCode);
1076
+ }
1077
+ // Handle various exit scenarios
1078
+ process.on("SIGINT", () => handleShutdown(0));
1079
+ process.on("SIGTERM", () => handleShutdown(0));
1080
+ // Handle uncaught errors - ensure child process is killed
1081
+ process.on("uncaughtException", (err) => {
1082
+ console.error("Uncaught exception:", err);
1083
+ handleShutdown(1);
1084
+ });
1085
+ process.on("unhandledRejection", (reason) => {
1086
+ console.error("Unhandled rejection:", reason);
1087
+ handleShutdown(1);
1088
+ });
1089
+ // Handle normal exit - last-resort cleanup (only synchronous operations work here)
1090
+ process.on("exit", () => {
1091
+ if (nextDevProcess) {
1092
+ try {
1093
+ nextDevProcess.kill("SIGTERM");
1094
+ }
1095
+ catch {
1096
+ // Process may already be dead
1097
+ }
1098
+ }
1099
+ });
1100
+ // =============================================================================
1101
+ // Main Entry Point
1102
+ // =============================================================================
1103
+ async function start() {
1104
+ try {
1105
+ const mode = cliArgs.agentOnly ? "agent-only" : "standalone";
1106
+ if (cliArgs.agentOnly) {
1107
+ // Agent-only mode: just set PROJECT_DIR and start agent
1108
+ PROJECT_DIR = cliArgs.workdir;
1109
+ log.info({ mode, projectDir: PROJECT_DIR }, "Agent-only mode starting");
1110
+ }
1111
+ else {
1112
+ // Standalone mode: full setup with template and dev server
1113
+ log.info({ mode }, "Standalone mode: setting up template and dev server");
1114
+ PROJECT_DIR = await setupTemplate();
1115
+ }
1116
+ // Register with Papercrane if requested
1117
+ if (cliArgs.register) {
1118
+ const success = await registerWithPapercrane();
1119
+ if (!success) {
1120
+ process.exit(1);
1121
+ }
1122
+ }
1123
+ // Start the agent API server
1124
+ app.listen(PORT, () => {
1125
+ log.info({
1126
+ port: PORT,
1127
+ mode,
1128
+ projectDir: PROJECT_DIR,
1129
+ devPort: cliArgs.agentOnly ? undefined : cliArgs.devPort,
1130
+ papercraneUrl: cliArgs.register ? cliArgs.papercraneUrl : undefined,
1131
+ logLevel: log.getLogLevel()
1132
+ }, "Sandbox agent ready");
1133
+ // Also print human-readable startup message
1134
+ console.log(`\n✓ Sandbox agent running on port ${PORT}`);
1135
+ console.log(` Mode: ${mode}`);
1136
+ console.log(` Log level: ${log.getLogLevel()}`);
1137
+ console.log(` Project directory: ${PROJECT_DIR}`);
1138
+ if (!cliArgs.agentOnly) {
1139
+ console.log(` Dev server: http://localhost:${cliArgs.devPort}`);
1140
+ }
1141
+ if (cliArgs.register) {
1142
+ console.log(` Connected to Papercrane at ${cliArgs.papercraneUrl}`);
1143
+ }
1144
+ console.log(`\nReady to accept connections.`);
1145
+ });
1146
+ }
1147
+ catch (error) {
1148
+ log.error({ error: error instanceof Error ? error.message : String(error) }, "Failed to start");
1149
+ process.exit(1);
1150
+ }
1151
+ }
1152
+ start();