@papercraneai/sandbox-agent 0.1.3 → 0.1.5
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 +327 -11
- 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) {
|
|
@@ -329,18 +403,26 @@ async function buildFileTree(dirPath) {
|
|
|
329
403
|
if (entry.name === "node_modules" || entry.name.startsWith(".")) {
|
|
330
404
|
continue;
|
|
331
405
|
}
|
|
406
|
+
const entryPath = join(dirPath, entry.name);
|
|
407
|
+
const entryStat = await stat(entryPath);
|
|
408
|
+
const mtime = entryStat.mtimeMs;
|
|
332
409
|
if (entry.isDirectory()) {
|
|
333
|
-
const children = await buildFileTree(
|
|
410
|
+
const children = await buildFileTree(entryPath);
|
|
411
|
+
// For folders, use the most recent mtime of any child (or folder's own mtime)
|
|
412
|
+
const childMtimes = children.map(c => c.mtime || 0);
|
|
413
|
+
const maxChildMtime = childMtimes.length > 0 ? Math.max(...childMtimes) : 0;
|
|
334
414
|
nodes.push({
|
|
335
415
|
name: entry.name,
|
|
336
416
|
type: "folder",
|
|
417
|
+
mtime: Math.max(mtime, maxChildMtime),
|
|
337
418
|
children
|
|
338
419
|
});
|
|
339
420
|
}
|
|
340
421
|
else {
|
|
341
422
|
nodes.push({
|
|
342
423
|
name: entry.name,
|
|
343
|
-
type: "file"
|
|
424
|
+
type: "file",
|
|
425
|
+
mtime
|
|
344
426
|
});
|
|
345
427
|
}
|
|
346
428
|
}
|
|
@@ -602,6 +684,186 @@ app.post("/files/upload", upload.array("files", 10), async (req, res) => {
|
|
|
602
684
|
});
|
|
603
685
|
}
|
|
604
686
|
});
|
|
687
|
+
// File write endpoint - create or overwrite a file with content
|
|
688
|
+
app.post("/files/write", async (req, res) => {
|
|
689
|
+
try {
|
|
690
|
+
const { path: filePath, content } = req.body;
|
|
691
|
+
if (!filePath) {
|
|
692
|
+
res.status(400).json({ error: "path is required" });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (content === undefined || content === null) {
|
|
696
|
+
res.status(400).json({ error: "content is required" });
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
// Sanitize path - remove leading slashes and prevent traversal
|
|
700
|
+
const sanitizedPath = filePath
|
|
701
|
+
.replace(/^\/+/, "")
|
|
702
|
+
.split("/")
|
|
703
|
+
.filter((part) => part !== ".." && part !== ".")
|
|
704
|
+
.join("/");
|
|
705
|
+
const fullPath = join(PROJECT_DIR, sanitizedPath);
|
|
706
|
+
// Security: ensure path is within PROJECT_DIR
|
|
707
|
+
const resolvedPath = resolve(fullPath);
|
|
708
|
+
const resolvedProject = resolve(PROJECT_DIR);
|
|
709
|
+
if (!resolvedPath.startsWith(resolvedProject)) {
|
|
710
|
+
res.status(403).json({ error: "Access denied: path outside project directory" });
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
// Auto-create parent directories
|
|
714
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
715
|
+
// Write file
|
|
716
|
+
await writeFile(fullPath, content, "utf-8");
|
|
717
|
+
log.info({ path: sanitizedPath, size: content.length }, "File written");
|
|
718
|
+
res.json({
|
|
719
|
+
success: true,
|
|
720
|
+
path: sanitizedPath,
|
|
721
|
+
fullPath,
|
|
722
|
+
size: content.length
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
log.error({ error: error instanceof Error ? error.message : String(error) }, "File write failed");
|
|
727
|
+
res.status(500).json({
|
|
728
|
+
error: error instanceof Error ? error.message : "Write failed"
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
// File edit endpoint - find and replace text in a file
|
|
733
|
+
app.post("/files/edit", async (req, res) => {
|
|
734
|
+
try {
|
|
735
|
+
const { path: filePath, old_string, new_string, replace_all = false } = req.body;
|
|
736
|
+
if (!filePath) {
|
|
737
|
+
res.status(400).json({ error: "path is required" });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (!old_string) {
|
|
741
|
+
res.status(400).json({ error: "old_string is required" });
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (new_string === undefined || new_string === null) {
|
|
745
|
+
res.status(400).json({ error: "new_string is required" });
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// Sanitize path
|
|
749
|
+
const sanitizedPath = filePath
|
|
750
|
+
.replace(/^\/+/, "")
|
|
751
|
+
.split("/")
|
|
752
|
+
.filter((part) => part !== ".." && part !== ".")
|
|
753
|
+
.join("/");
|
|
754
|
+
const fullPath = join(PROJECT_DIR, sanitizedPath);
|
|
755
|
+
// Security: ensure path is within PROJECT_DIR
|
|
756
|
+
const resolvedPath = resolve(fullPath);
|
|
757
|
+
const resolvedProject = resolve(PROJECT_DIR);
|
|
758
|
+
if (!resolvedPath.startsWith(resolvedProject)) {
|
|
759
|
+
res.status(403).json({ error: "Access denied: path outside project directory" });
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
// Read existing file
|
|
763
|
+
let content;
|
|
764
|
+
try {
|
|
765
|
+
content = await readFile(fullPath, "utf-8");
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
if (error.code === "ENOENT") {
|
|
769
|
+
res.status(404).json({ error: "File not found", path: sanitizedPath });
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
throw error;
|
|
773
|
+
}
|
|
774
|
+
// Check if old_string exists
|
|
775
|
+
if (!content.includes(old_string)) {
|
|
776
|
+
res.status(400).json({
|
|
777
|
+
error: "old_string not found in file",
|
|
778
|
+
path: sanitizedPath
|
|
779
|
+
});
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
// Count occurrences
|
|
783
|
+
const occurrences = content.split(old_string).length - 1;
|
|
784
|
+
// If not replace_all and multiple occurrences, error
|
|
785
|
+
if (!replace_all && occurrences > 1) {
|
|
786
|
+
res.status(400).json({
|
|
787
|
+
error: `old_string is not unique (found ${occurrences} times). Provide more context or use replace_all: true`,
|
|
788
|
+
path: sanitizedPath,
|
|
789
|
+
occurrences
|
|
790
|
+
});
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
// Perform replacement
|
|
794
|
+
const newContent = replace_all
|
|
795
|
+
? content.split(old_string).join(new_string)
|
|
796
|
+
: content.replace(old_string, new_string);
|
|
797
|
+
// Write back
|
|
798
|
+
await writeFile(fullPath, newContent, "utf-8");
|
|
799
|
+
log.info({ path: sanitizedPath, replacements: replace_all ? occurrences : 1 }, "File edited");
|
|
800
|
+
res.json({
|
|
801
|
+
success: true,
|
|
802
|
+
path: sanitizedPath,
|
|
803
|
+
fullPath,
|
|
804
|
+
replacements: replace_all ? occurrences : 1
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
catch (error) {
|
|
808
|
+
log.error({ error: error instanceof Error ? error.message : String(error) }, "File edit failed");
|
|
809
|
+
res.status(500).json({
|
|
810
|
+
error: error instanceof Error ? error.message : "Edit failed"
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
});
|
|
814
|
+
// File delete endpoint
|
|
815
|
+
app.delete("/files/delete", async (req, res) => {
|
|
816
|
+
try {
|
|
817
|
+
const filePath = req.query.path || req.body?.path;
|
|
818
|
+
if (!filePath) {
|
|
819
|
+
res.status(400).json({ error: "path is required" });
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
// Sanitize path
|
|
823
|
+
const sanitizedPath = filePath
|
|
824
|
+
.replace(/^\/+/, "")
|
|
825
|
+
.split("/")
|
|
826
|
+
.filter((part) => part !== ".." && part !== ".")
|
|
827
|
+
.join("/");
|
|
828
|
+
const fullPath = join(PROJECT_DIR, sanitizedPath);
|
|
829
|
+
// Security: ensure path is within PROJECT_DIR
|
|
830
|
+
const resolvedPath = resolve(fullPath);
|
|
831
|
+
const resolvedProject = resolve(PROJECT_DIR);
|
|
832
|
+
if (!resolvedPath.startsWith(resolvedProject)) {
|
|
833
|
+
res.status(403).json({ error: "Access denied: path outside project directory" });
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
// Check if file exists and is not a directory
|
|
837
|
+
try {
|
|
838
|
+
const stats = await stat(fullPath);
|
|
839
|
+
if (stats.isDirectory()) {
|
|
840
|
+
res.status(400).json({ error: "Cannot delete a directory, only files", path: sanitizedPath });
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
catch (error) {
|
|
845
|
+
if (error.code === "ENOENT") {
|
|
846
|
+
res.status(404).json({ error: "File not found", path: sanitizedPath });
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
throw error;
|
|
850
|
+
}
|
|
851
|
+
// Delete the file
|
|
852
|
+
await unlink(fullPath);
|
|
853
|
+
log.info({ path: sanitizedPath }, "File deleted");
|
|
854
|
+
res.json({
|
|
855
|
+
success: true,
|
|
856
|
+
path: sanitizedPath,
|
|
857
|
+
fullPath
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
catch (error) {
|
|
861
|
+
log.error({ error: error instanceof Error ? error.message : String(error) }, "File delete failed");
|
|
862
|
+
res.status(500).json({
|
|
863
|
+
error: error instanceof Error ? error.message : "Delete failed"
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
});
|
|
605
867
|
// Helper to encode path for Claude session storage (slashes and dots become dashes)
|
|
606
868
|
function encodeSessionPath(path) {
|
|
607
869
|
return path.replace(/[\/\.]/g, "-");
|
|
@@ -893,9 +1155,12 @@ app.post("/chat", async (req, res) => {
|
|
|
893
1155
|
"Read",
|
|
894
1156
|
"Write",
|
|
895
1157
|
"Edit",
|
|
896
|
-
"Glob",
|
|
897
1158
|
"Grep",
|
|
898
1159
|
"Bash",
|
|
1160
|
+
"WebFetch",
|
|
1161
|
+
"WebSearch",
|
|
1162
|
+
"AgentOutput",
|
|
1163
|
+
"KillShell",
|
|
899
1164
|
"mcp__client-tools__ShowPreview"
|
|
900
1165
|
];
|
|
901
1166
|
const options = {
|
|
@@ -907,6 +1172,7 @@ app.post("/chat", async (req, res) => {
|
|
|
907
1172
|
"client-tools": clientToolsServer
|
|
908
1173
|
},
|
|
909
1174
|
allowedTools: allowedTools || defaultTools,
|
|
1175
|
+
settingSources: ["project"],
|
|
910
1176
|
hooks,
|
|
911
1177
|
abortController,
|
|
912
1178
|
includePartialMessages: true
|
|
@@ -933,7 +1199,7 @@ app.post("/chat", async (req, res) => {
|
|
|
933
1199
|
let gotResult = false;
|
|
934
1200
|
log.debug({ ...ctx, elapsed: Date.now() - requestStartTime }, "Starting Claude SDK query");
|
|
935
1201
|
// Determine which model is being used (either provided or SDK default)
|
|
936
|
-
const usedModel = model || options.model || "claude-sonnet-4-
|
|
1202
|
+
const usedModel = model || options.model || "claude-sonnet-4-6";
|
|
937
1203
|
for await (const msg of query({
|
|
938
1204
|
prompt: createPrompt(),
|
|
939
1205
|
options
|
|
@@ -995,8 +1261,9 @@ async function registerWithPapercrane() {
|
|
|
995
1261
|
}
|
|
996
1262
|
console.log(`Registering with Papercrane server at ${cliArgs.papercraneUrl}...`);
|
|
997
1263
|
try {
|
|
998
|
-
const
|
|
999
|
-
const
|
|
1264
|
+
const host = cliArgs.hostMode === "lan" ? getLocalNetworkIP() : "localhost";
|
|
1265
|
+
const agentEndpoint = `http://${host}:${cliArgs.agentPort}`;
|
|
1266
|
+
const previewEndpoint = `http://${host}:${cliArgs.devPort}`;
|
|
1000
1267
|
const res = await fetch(`${cliArgs.papercraneUrl}/api/environments/register`, {
|
|
1001
1268
|
method: "POST",
|
|
1002
1269
|
headers: { "Content-Type": "application/json" },
|
|
@@ -1136,8 +1403,56 @@ async function start() {
|
|
|
1136
1403
|
process.exit(1);
|
|
1137
1404
|
}
|
|
1138
1405
|
}
|
|
1406
|
+
// Create HTTP server and attach WebSocket
|
|
1407
|
+
const server = createServer(app);
|
|
1408
|
+
logWebSocketServer = new WebSocketServer({ server, path: "/ws/logs" });
|
|
1409
|
+
// Set up PM2 log tailing (only in agent-only mode where PM2 manages the dev server)
|
|
1410
|
+
if (cliArgs.agentOnly) {
|
|
1411
|
+
const pm2LogPath = join(homedir(), ".pm2", "pm2.log");
|
|
1412
|
+
let tail = null;
|
|
1413
|
+
try {
|
|
1414
|
+
tail = new Tail(pm2LogPath, { fromBeginning: false, follow: true });
|
|
1415
|
+
tail.on("line", (line) => {
|
|
1416
|
+
// Check for compilation success (plain text from Turbopack)
|
|
1417
|
+
if (line.includes("Compiled") && (line.includes("✓") || line.includes("successfully"))) {
|
|
1418
|
+
broadcastLog(30, line); // info level
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
// Only process JSON lines (from next-logger)
|
|
1422
|
+
if (!line.includes('{"level":'))
|
|
1423
|
+
return;
|
|
1424
|
+
try {
|
|
1425
|
+
// Extract JSON from the line (may have PM2 prefix)
|
|
1426
|
+
const jsonMatch = line.match(/\{.*\}$/);
|
|
1427
|
+
if (!jsonMatch)
|
|
1428
|
+
return;
|
|
1429
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
1430
|
+
// Only forward errors and warnings (level >= 40)
|
|
1431
|
+
if (parsed.level >= 40 && parsed.msg) {
|
|
1432
|
+
broadcastLog(parsed.level, parsed.msg, parsed.time);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
catch {
|
|
1436
|
+
// Ignore JSON parse errors
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
tail.on("error", (err) => {
|
|
1440
|
+
log.warn({ error: err.message }, "PM2 log tail error");
|
|
1441
|
+
});
|
|
1442
|
+
log.info({ path: pm2LogPath }, "PM2 log tailing started");
|
|
1443
|
+
}
|
|
1444
|
+
catch (err) {
|
|
1445
|
+
log.warn({ error: err instanceof Error ? err.message : String(err), path: pm2LogPath }, "Failed to start PM2 log tailing");
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
logWebSocketServer.on("connection", (ws) => {
|
|
1449
|
+
log.debug({}, "WebSocket client connected to /ws/logs");
|
|
1450
|
+
ws.on("close", () => {
|
|
1451
|
+
log.debug({}, "WebSocket client disconnected from /ws/logs");
|
|
1452
|
+
});
|
|
1453
|
+
});
|
|
1139
1454
|
// Start the agent API server
|
|
1140
|
-
|
|
1455
|
+
server.listen(PORT, () => {
|
|
1141
1456
|
log.info({
|
|
1142
1457
|
port: PORT,
|
|
1143
1458
|
mode,
|
|
@@ -1151,6 +1466,7 @@ async function start() {
|
|
|
1151
1466
|
console.log(` Mode: ${mode}`);
|
|
1152
1467
|
console.log(` Log level: ${log.getLogLevel()}`);
|
|
1153
1468
|
console.log(` Project directory: ${PROJECT_DIR}`);
|
|
1469
|
+
console.log(` WebSocket logs: ws://localhost:${PORT}/ws/logs`);
|
|
1154
1470
|
if (!cliArgs.agentOnly) {
|
|
1155
1471
|
console.log(` Dev server: http://localhost:${cliArgs.devPort}`);
|
|
1156
1472
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@papercraneai/sandbox-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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
|
}
|