@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/README.md +134 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1152 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.js +62 -0
- package/package.json +37 -0
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();
|