@papercraneai/sandbox-agent 0.1.2 → 0.1.4
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 +337 -16
- package/package.json +6 -1
package/dist/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import express from "express";
|
|
3
|
+
import { createServer } from "http";
|
|
4
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
5
|
+
import { Tail } from "tail";
|
|
3
6
|
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
-
import { readdir, stat, mkdir, readFile, writeFile, access } from "fs/promises";
|
|
7
|
+
import { readdir, stat, mkdir, readFile, writeFile, access, unlink } from "fs/promises";
|
|
5
8
|
import { join, dirname, resolve } from "path";
|
|
6
9
|
import { fileURLToPath } from "url";
|
|
7
10
|
import { spawn, execSync } from "child_process";
|
|
8
|
-
import { homedir } from "os";
|
|
11
|
+
import { homedir, networkInterfaces } from "os";
|
|
9
12
|
import { z } from "zod";
|
|
10
13
|
import multer from "multer";
|
|
11
14
|
import * as log from "./logger.js";
|
|
@@ -14,6 +17,21 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
14
17
|
const __dirname = dirname(__filename);
|
|
15
18
|
// Read package version at startup
|
|
16
19
|
const pkg = JSON.parse(await readFile(join(__dirname, "..", "package.json"), "utf-8"));
|
|
20
|
+
// Get local network IP address (first non-internal IPv4 address)
|
|
21
|
+
function getLocalNetworkIP() {
|
|
22
|
+
const nets = networkInterfaces();
|
|
23
|
+
for (const name of Object.keys(nets)) {
|
|
24
|
+
for (const net of nets[name] || []) {
|
|
25
|
+
// Skip internal (loopback) and non-IPv4 addresses
|
|
26
|
+
if (net.family === "IPv4" && !net.internal) {
|
|
27
|
+
return net.address;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Fallback to localhost if no network interface found
|
|
32
|
+
console.warn("Warning: Could not detect local network IP, falling back to localhost");
|
|
33
|
+
return "localhost";
|
|
34
|
+
}
|
|
17
35
|
// Parse CLI arguments
|
|
18
36
|
function parseArgs() {
|
|
19
37
|
const args = process.argv.slice(2);
|
|
@@ -24,7 +42,8 @@ function parseArgs() {
|
|
|
24
42
|
agentPort: parseInt(process.env.PORT || "3001"),
|
|
25
43
|
devPort: parseInt(process.env.DEV_PORT || "3000"),
|
|
26
44
|
workdir: join(homedir(), ".papercrane", "sandbox"),
|
|
27
|
-
agentOnly: false
|
|
45
|
+
agentOnly: false,
|
|
46
|
+
hostMode: (process.env.HOST_MODE || "localhost")
|
|
28
47
|
};
|
|
29
48
|
for (let i = 0; i < args.length; i++) {
|
|
30
49
|
const arg = args[i];
|
|
@@ -49,6 +68,14 @@ function parseArgs() {
|
|
|
49
68
|
else if (arg === "--agent-only") {
|
|
50
69
|
result.agentOnly = true;
|
|
51
70
|
}
|
|
71
|
+
else if (arg === "--host-mode" && args[i + 1]) {
|
|
72
|
+
const mode = args[++i];
|
|
73
|
+
if (mode !== "localhost" && mode !== "lan") {
|
|
74
|
+
console.error(`Error: --host-mode must be 'localhost' or 'lan', got '${mode}'`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
result.hostMode = mode;
|
|
78
|
+
}
|
|
52
79
|
else if (arg === "--help" || arg === "-h") {
|
|
53
80
|
console.log(`
|
|
54
81
|
sandbox-agent - Claude Agent SDK server for environments
|
|
@@ -65,6 +92,7 @@ Options:
|
|
|
65
92
|
--agent-only Only run agent API (no template setup, no dev server)
|
|
66
93
|
--port <port> Agent API port (default: 3001)
|
|
67
94
|
--dev-port <port> Dev server port, standalone only (default: 3000)
|
|
95
|
+
--host-mode <mode> Host for advertised URLs: localhost or lan (default: localhost)
|
|
68
96
|
--register Register with Papercrane server (requires --token)
|
|
69
97
|
--token <token> Connection token from Papercrane UI
|
|
70
98
|
--papercrane-url <url> Papercrane server URL (default: https://fly.papercrane.ai)
|
|
@@ -73,6 +101,7 @@ Options:
|
|
|
73
101
|
Environment Variables:
|
|
74
102
|
PORT Agent API port (same as --port)
|
|
75
103
|
DEV_PORT Dev server port (same as --dev-port)
|
|
104
|
+
HOST_MODE Host mode: localhost or lan (same as --host-mode)
|
|
76
105
|
PAPERCRANE_URL Papercrane server URL (same as --papercrane-url)
|
|
77
106
|
|
|
78
107
|
Examples:
|
|
@@ -218,6 +247,26 @@ export default function RootLayout({
|
|
|
218
247
|
}
|
|
219
248
|
// Next.js dev server process
|
|
220
249
|
let nextDevProcess = null;
|
|
250
|
+
// WebSocket server for log streaming (set up later in start())
|
|
251
|
+
let logWebSocketServer = null;
|
|
252
|
+
// Strip ANSI escape codes from strings
|
|
253
|
+
const stripAnsi = (str) => str.replace(/\u001b\[[0-9;]*m/g, "");
|
|
254
|
+
// Broadcast a log message to all connected WebSocket clients
|
|
255
|
+
function broadcastLog(level, message, time) {
|
|
256
|
+
if (!logWebSocketServer)
|
|
257
|
+
return;
|
|
258
|
+
const payload = JSON.stringify({
|
|
259
|
+
type: "log",
|
|
260
|
+
level,
|
|
261
|
+
message: stripAnsi(message),
|
|
262
|
+
time: time || Date.now()
|
|
263
|
+
});
|
|
264
|
+
logWebSocketServer.clients.forEach((client) => {
|
|
265
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
266
|
+
client.send(payload);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
221
270
|
// Start Next.js dev server
|
|
222
271
|
function startNextDevServer(workdir, port) {
|
|
223
272
|
return new Promise((resolve, reject) => {
|
|
@@ -232,6 +281,28 @@ function startNextDevServer(workdir, port) {
|
|
|
232
281
|
nextDevProcess.stdout?.on("data", (data) => {
|
|
233
282
|
const output = data.toString();
|
|
234
283
|
process.stdout.write(`[next] ${output}`);
|
|
284
|
+
// Broadcast JSON log lines to WebSocket clients
|
|
285
|
+
for (const line of output.split("\n")) {
|
|
286
|
+
// Check for compilation success (plain text from Turbopack)
|
|
287
|
+
if (line.includes("Compiled") && (line.includes("✓") || line.includes("successfully"))) {
|
|
288
|
+
broadcastLog(30, line); // info level
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (line.includes('{"level":')) {
|
|
292
|
+
try {
|
|
293
|
+
const jsonMatch = line.match(/\{.*\}$/);
|
|
294
|
+
if (jsonMatch) {
|
|
295
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
296
|
+
if (parsed.level >= 40 && parsed.msg) {
|
|
297
|
+
broadcastLog(parsed.level, parsed.msg, parsed.time);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// Ignore JSON parse errors
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
235
306
|
if (!resolved && (output.includes("Ready") || output.includes("started server"))) {
|
|
236
307
|
resolved = true;
|
|
237
308
|
console.log("✓ Next.js dev server started");
|
|
@@ -239,7 +310,10 @@ function startNextDevServer(workdir, port) {
|
|
|
239
310
|
}
|
|
240
311
|
});
|
|
241
312
|
nextDevProcess.stderr?.on("data", (data) => {
|
|
242
|
-
|
|
313
|
+
const output = data.toString();
|
|
314
|
+
process.stderr.write(`[next] ${output}`);
|
|
315
|
+
// Broadcast stderr as errors
|
|
316
|
+
broadcastLog(50, output);
|
|
243
317
|
});
|
|
244
318
|
nextDevProcess.on("error", (err) => {
|
|
245
319
|
if (!resolved) {
|
|
@@ -350,17 +424,31 @@ async function buildFileTree(dirPath) {
|
|
|
350
424
|
app.get("/health", (_req, res) => {
|
|
351
425
|
res.json({ status: "healthy", projectDir: PROJECT_DIR, version: pkg.version });
|
|
352
426
|
});
|
|
427
|
+
// Find the git repo root by traversing up from a starting directory
|
|
428
|
+
async function findGitRoot(startDir) {
|
|
429
|
+
let dir = resolve(startDir);
|
|
430
|
+
while (true) {
|
|
431
|
+
if (await directoryExists(join(dir, ".git"))) {
|
|
432
|
+
return dir;
|
|
433
|
+
}
|
|
434
|
+
const parent = dirname(dir);
|
|
435
|
+
if (parent === dir)
|
|
436
|
+
return null; // reached filesystem root
|
|
437
|
+
dir = parent;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
353
440
|
// Pull template updates (git pull + conditional npm install)
|
|
354
441
|
app.post("/pull-template", async (_req, res) => {
|
|
355
442
|
const workdir = getWorkingDirectory();
|
|
356
443
|
try {
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
444
|
+
// Find the git repo root (may be a parent of the working directory)
|
|
445
|
+
const gitRoot = await findGitRoot(workdir);
|
|
446
|
+
if (!gitRoot) {
|
|
447
|
+
res.status(400).json({ error: "Working directory is not inside a git repository" });
|
|
360
448
|
return;
|
|
361
449
|
}
|
|
362
|
-
// Run git pull
|
|
363
|
-
const pullOutput = runCommand("git pull",
|
|
450
|
+
// Run git pull from the repo root
|
|
451
|
+
const pullOutput = runCommand("git pull", gitRoot);
|
|
364
452
|
console.log(`git pull output: ${pullOutput}`);
|
|
365
453
|
// Check if already up to date
|
|
366
454
|
if (pullOutput.includes("Already up to date")) {
|
|
@@ -371,7 +459,7 @@ app.post("/pull-template", async (_req, res) => {
|
|
|
371
459
|
// git diff HEAD@{1} --name-only shows what changed in the last pull
|
|
372
460
|
let filesChanged = [];
|
|
373
461
|
try {
|
|
374
|
-
const diffOutput = runCommand("git diff HEAD@{1} --name-only",
|
|
462
|
+
const diffOutput = runCommand("git diff HEAD@{1} --name-only", gitRoot);
|
|
375
463
|
filesChanged = diffOutput.split("\n").filter(f => f.trim());
|
|
376
464
|
}
|
|
377
465
|
catch {
|
|
@@ -383,11 +471,11 @@ app.post("/pull-template", async (_req, res) => {
|
|
|
383
471
|
let npmInstalled = false;
|
|
384
472
|
if (packageJsonChanged) {
|
|
385
473
|
console.log("package.json changed, running npm install...");
|
|
386
|
-
await runNpmInstall(
|
|
474
|
+
await runNpmInstall(gitRoot);
|
|
387
475
|
npmInstalled = true;
|
|
388
476
|
}
|
|
389
477
|
// Ensure scaffold files still exist after pull
|
|
390
|
-
await ensureAppScaffold(
|
|
478
|
+
await ensureAppScaffold(gitRoot);
|
|
391
479
|
res.json({ updated: true, files_changed: filesChanged, npm_installed: npmInstalled });
|
|
392
480
|
}
|
|
393
481
|
catch (error) {
|
|
@@ -588,6 +676,186 @@ app.post("/files/upload", upload.array("files", 10), async (req, res) => {
|
|
|
588
676
|
});
|
|
589
677
|
}
|
|
590
678
|
});
|
|
679
|
+
// File write endpoint - create or overwrite a file with content
|
|
680
|
+
app.post("/files/write", async (req, res) => {
|
|
681
|
+
try {
|
|
682
|
+
const { path: filePath, content } = req.body;
|
|
683
|
+
if (!filePath) {
|
|
684
|
+
res.status(400).json({ error: "path is required" });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (content === undefined || content === null) {
|
|
688
|
+
res.status(400).json({ error: "content is required" });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
// Sanitize path - remove leading slashes and prevent traversal
|
|
692
|
+
const sanitizedPath = filePath
|
|
693
|
+
.replace(/^\/+/, "")
|
|
694
|
+
.split("/")
|
|
695
|
+
.filter((part) => part !== ".." && part !== ".")
|
|
696
|
+
.join("/");
|
|
697
|
+
const fullPath = join(PROJECT_DIR, sanitizedPath);
|
|
698
|
+
// Security: ensure path is within PROJECT_DIR
|
|
699
|
+
const resolvedPath = resolve(fullPath);
|
|
700
|
+
const resolvedProject = resolve(PROJECT_DIR);
|
|
701
|
+
if (!resolvedPath.startsWith(resolvedProject)) {
|
|
702
|
+
res.status(403).json({ error: "Access denied: path outside project directory" });
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
// Auto-create parent directories
|
|
706
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
707
|
+
// Write file
|
|
708
|
+
await writeFile(fullPath, content, "utf-8");
|
|
709
|
+
log.info({ path: sanitizedPath, size: content.length }, "File written");
|
|
710
|
+
res.json({
|
|
711
|
+
success: true,
|
|
712
|
+
path: sanitizedPath,
|
|
713
|
+
fullPath,
|
|
714
|
+
size: content.length
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
log.error({ error: error instanceof Error ? error.message : String(error) }, "File write failed");
|
|
719
|
+
res.status(500).json({
|
|
720
|
+
error: error instanceof Error ? error.message : "Write failed"
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
// File edit endpoint - find and replace text in a file
|
|
725
|
+
app.post("/files/edit", async (req, res) => {
|
|
726
|
+
try {
|
|
727
|
+
const { path: filePath, old_string, new_string, replace_all = false } = req.body;
|
|
728
|
+
if (!filePath) {
|
|
729
|
+
res.status(400).json({ error: "path is required" });
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (!old_string) {
|
|
733
|
+
res.status(400).json({ error: "old_string is required" });
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (new_string === undefined || new_string === null) {
|
|
737
|
+
res.status(400).json({ error: "new_string is required" });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
// Sanitize path
|
|
741
|
+
const sanitizedPath = filePath
|
|
742
|
+
.replace(/^\/+/, "")
|
|
743
|
+
.split("/")
|
|
744
|
+
.filter((part) => part !== ".." && part !== ".")
|
|
745
|
+
.join("/");
|
|
746
|
+
const fullPath = join(PROJECT_DIR, sanitizedPath);
|
|
747
|
+
// Security: ensure path is within PROJECT_DIR
|
|
748
|
+
const resolvedPath = resolve(fullPath);
|
|
749
|
+
const resolvedProject = resolve(PROJECT_DIR);
|
|
750
|
+
if (!resolvedPath.startsWith(resolvedProject)) {
|
|
751
|
+
res.status(403).json({ error: "Access denied: path outside project directory" });
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
// Read existing file
|
|
755
|
+
let content;
|
|
756
|
+
try {
|
|
757
|
+
content = await readFile(fullPath, "utf-8");
|
|
758
|
+
}
|
|
759
|
+
catch (error) {
|
|
760
|
+
if (error.code === "ENOENT") {
|
|
761
|
+
res.status(404).json({ error: "File not found", path: sanitizedPath });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
766
|
+
// Check if old_string exists
|
|
767
|
+
if (!content.includes(old_string)) {
|
|
768
|
+
res.status(400).json({
|
|
769
|
+
error: "old_string not found in file",
|
|
770
|
+
path: sanitizedPath
|
|
771
|
+
});
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
// Count occurrences
|
|
775
|
+
const occurrences = content.split(old_string).length - 1;
|
|
776
|
+
// If not replace_all and multiple occurrences, error
|
|
777
|
+
if (!replace_all && occurrences > 1) {
|
|
778
|
+
res.status(400).json({
|
|
779
|
+
error: `old_string is not unique (found ${occurrences} times). Provide more context or use replace_all: true`,
|
|
780
|
+
path: sanitizedPath,
|
|
781
|
+
occurrences
|
|
782
|
+
});
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
// Perform replacement
|
|
786
|
+
const newContent = replace_all
|
|
787
|
+
? content.split(old_string).join(new_string)
|
|
788
|
+
: content.replace(old_string, new_string);
|
|
789
|
+
// Write back
|
|
790
|
+
await writeFile(fullPath, newContent, "utf-8");
|
|
791
|
+
log.info({ path: sanitizedPath, replacements: replace_all ? occurrences : 1 }, "File edited");
|
|
792
|
+
res.json({
|
|
793
|
+
success: true,
|
|
794
|
+
path: sanitizedPath,
|
|
795
|
+
fullPath,
|
|
796
|
+
replacements: replace_all ? occurrences : 1
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
catch (error) {
|
|
800
|
+
log.error({ error: error instanceof Error ? error.message : String(error) }, "File edit failed");
|
|
801
|
+
res.status(500).json({
|
|
802
|
+
error: error instanceof Error ? error.message : "Edit failed"
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
// File delete endpoint
|
|
807
|
+
app.delete("/files/delete", async (req, res) => {
|
|
808
|
+
try {
|
|
809
|
+
const filePath = req.query.path || req.body?.path;
|
|
810
|
+
if (!filePath) {
|
|
811
|
+
res.status(400).json({ error: "path is required" });
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
// Sanitize path
|
|
815
|
+
const sanitizedPath = filePath
|
|
816
|
+
.replace(/^\/+/, "")
|
|
817
|
+
.split("/")
|
|
818
|
+
.filter((part) => part !== ".." && part !== ".")
|
|
819
|
+
.join("/");
|
|
820
|
+
const fullPath = join(PROJECT_DIR, sanitizedPath);
|
|
821
|
+
// Security: ensure path is within PROJECT_DIR
|
|
822
|
+
const resolvedPath = resolve(fullPath);
|
|
823
|
+
const resolvedProject = resolve(PROJECT_DIR);
|
|
824
|
+
if (!resolvedPath.startsWith(resolvedProject)) {
|
|
825
|
+
res.status(403).json({ error: "Access denied: path outside project directory" });
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
// Check if file exists and is not a directory
|
|
829
|
+
try {
|
|
830
|
+
const stats = await stat(fullPath);
|
|
831
|
+
if (stats.isDirectory()) {
|
|
832
|
+
res.status(400).json({ error: "Cannot delete a directory, only files", path: sanitizedPath });
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
catch (error) {
|
|
837
|
+
if (error.code === "ENOENT") {
|
|
838
|
+
res.status(404).json({ error: "File not found", path: sanitizedPath });
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
// Delete the file
|
|
844
|
+
await unlink(fullPath);
|
|
845
|
+
log.info({ path: sanitizedPath }, "File deleted");
|
|
846
|
+
res.json({
|
|
847
|
+
success: true,
|
|
848
|
+
path: sanitizedPath,
|
|
849
|
+
fullPath
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
catch (error) {
|
|
853
|
+
log.error({ error: error instanceof Error ? error.message : String(error) }, "File delete failed");
|
|
854
|
+
res.status(500).json({
|
|
855
|
+
error: error instanceof Error ? error.message : "Delete failed"
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
});
|
|
591
859
|
// Helper to encode path for Claude session storage (slashes and dots become dashes)
|
|
592
860
|
function encodeSessionPath(path) {
|
|
593
861
|
return path.replace(/[\/\.]/g, "-");
|
|
@@ -879,9 +1147,12 @@ app.post("/chat", async (req, res) => {
|
|
|
879
1147
|
"Read",
|
|
880
1148
|
"Write",
|
|
881
1149
|
"Edit",
|
|
882
|
-
"Glob",
|
|
883
1150
|
"Grep",
|
|
884
1151
|
"Bash",
|
|
1152
|
+
"WebFetch",
|
|
1153
|
+
"WebSearch",
|
|
1154
|
+
"AgentOutput",
|
|
1155
|
+
"KillShell",
|
|
885
1156
|
"mcp__client-tools__ShowPreview"
|
|
886
1157
|
];
|
|
887
1158
|
const options = {
|
|
@@ -981,8 +1252,9 @@ async function registerWithPapercrane() {
|
|
|
981
1252
|
}
|
|
982
1253
|
console.log(`Registering with Papercrane server at ${cliArgs.papercraneUrl}...`);
|
|
983
1254
|
try {
|
|
984
|
-
const
|
|
985
|
-
const
|
|
1255
|
+
const host = cliArgs.hostMode === "lan" ? getLocalNetworkIP() : "localhost";
|
|
1256
|
+
const agentEndpoint = `http://${host}:${cliArgs.agentPort}`;
|
|
1257
|
+
const previewEndpoint = `http://${host}:${cliArgs.devPort}`;
|
|
986
1258
|
const res = await fetch(`${cliArgs.papercraneUrl}/api/environments/register`, {
|
|
987
1259
|
method: "POST",
|
|
988
1260
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1122,8 +1394,56 @@ async function start() {
|
|
|
1122
1394
|
process.exit(1);
|
|
1123
1395
|
}
|
|
1124
1396
|
}
|
|
1397
|
+
// Create HTTP server and attach WebSocket
|
|
1398
|
+
const server = createServer(app);
|
|
1399
|
+
logWebSocketServer = new WebSocketServer({ server, path: "/ws/logs" });
|
|
1400
|
+
// Set up PM2 log tailing (only in agent-only mode where PM2 manages the dev server)
|
|
1401
|
+
if (cliArgs.agentOnly) {
|
|
1402
|
+
const pm2LogPath = join(homedir(), ".pm2", "pm2.log");
|
|
1403
|
+
let tail = null;
|
|
1404
|
+
try {
|
|
1405
|
+
tail = new Tail(pm2LogPath, { fromBeginning: false, follow: true });
|
|
1406
|
+
tail.on("line", (line) => {
|
|
1407
|
+
// Check for compilation success (plain text from Turbopack)
|
|
1408
|
+
if (line.includes("Compiled") && (line.includes("✓") || line.includes("successfully"))) {
|
|
1409
|
+
broadcastLog(30, line); // info level
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
// Only process JSON lines (from next-logger)
|
|
1413
|
+
if (!line.includes('{"level":'))
|
|
1414
|
+
return;
|
|
1415
|
+
try {
|
|
1416
|
+
// Extract JSON from the line (may have PM2 prefix)
|
|
1417
|
+
const jsonMatch = line.match(/\{.*\}$/);
|
|
1418
|
+
if (!jsonMatch)
|
|
1419
|
+
return;
|
|
1420
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
1421
|
+
// Only forward errors and warnings (level >= 40)
|
|
1422
|
+
if (parsed.level >= 40 && parsed.msg) {
|
|
1423
|
+
broadcastLog(parsed.level, parsed.msg, parsed.time);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
catch {
|
|
1427
|
+
// Ignore JSON parse errors
|
|
1428
|
+
}
|
|
1429
|
+
});
|
|
1430
|
+
tail.on("error", (err) => {
|
|
1431
|
+
log.warn({ error: err.message }, "PM2 log tail error");
|
|
1432
|
+
});
|
|
1433
|
+
log.info({ path: pm2LogPath }, "PM2 log tailing started");
|
|
1434
|
+
}
|
|
1435
|
+
catch (err) {
|
|
1436
|
+
log.warn({ error: err instanceof Error ? err.message : String(err), path: pm2LogPath }, "Failed to start PM2 log tailing");
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
logWebSocketServer.on("connection", (ws) => {
|
|
1440
|
+
log.debug({}, "WebSocket client connected to /ws/logs");
|
|
1441
|
+
ws.on("close", () => {
|
|
1442
|
+
log.debug({}, "WebSocket client disconnected from /ws/logs");
|
|
1443
|
+
});
|
|
1444
|
+
});
|
|
1125
1445
|
// Start the agent API server
|
|
1126
|
-
|
|
1446
|
+
server.listen(PORT, () => {
|
|
1127
1447
|
log.info({
|
|
1128
1448
|
port: PORT,
|
|
1129
1449
|
mode,
|
|
@@ -1137,6 +1457,7 @@ async function start() {
|
|
|
1137
1457
|
console.log(` Mode: ${mode}`);
|
|
1138
1458
|
console.log(` Log level: ${log.getLogLevel()}`);
|
|
1139
1459
|
console.log(` Project directory: ${PROJECT_DIR}`);
|
|
1460
|
+
console.log(` WebSocket logs: ws://localhost:${PORT}/ws/logs`);
|
|
1140
1461
|
if (!cliArgs.agentOnly) {
|
|
1141
1462
|
console.log(` Dev server: http://localhost:${cliArgs.devPort}`);
|
|
1142
1463
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@papercraneai/sandbox-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Claude Agent SDK server for sandbox environments",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"start": "node dist/index.js",
|
|
19
19
|
"dev": "tsx watch src/index.ts",
|
|
20
20
|
"dev:local": "PROJECT_DIR=./template/src/app tsx src/index.ts",
|
|
21
|
+
"prepublishOnly": "npm run build",
|
|
21
22
|
"build": "tsc",
|
|
22
23
|
"typecheck": "tsc --noEmit"
|
|
23
24
|
},
|
|
@@ -25,12 +26,16 @@
|
|
|
25
26
|
"@anthropic-ai/claude-agent-sdk": "^0.2.5",
|
|
26
27
|
"express": "^4.21.1",
|
|
27
28
|
"multer": "^2.0.2",
|
|
29
|
+
"tail": "^2.2.6",
|
|
30
|
+
"ws": "^8.19.0",
|
|
28
31
|
"zod": "^4.0.0"
|
|
29
32
|
},
|
|
30
33
|
"devDependencies": {
|
|
31
34
|
"@types/express": "^5.0.0",
|
|
32
35
|
"@types/multer": "^2.0.0",
|
|
33
36
|
"@types/node": "^22.14.0",
|
|
37
|
+
"@types/tail": "^2.2.3",
|
|
38
|
+
"@types/ws": "^8.18.1",
|
|
34
39
|
"tsx": "^4.20.6",
|
|
35
40
|
"typescript": "^5.8.3"
|
|
36
41
|
}
|