@co0ontty/wand 1.21.12 → 1.21.13
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/server.js +254 -31
- package/dist/structured-session-manager.d.ts +6 -2
- package/dist/structured-session-manager.js +170 -97
- package/dist/types.d.ts +24 -0
- package/dist/web-ui/content/scripts.js +709 -138
- package/dist/web-ui/content/styles.css +313 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import compression from "compression";
|
|
3
3
|
import express from "express";
|
|
4
4
|
import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { lstat, mkdir, readdir, readFile, stat } from "node:fs/promises";
|
|
6
6
|
import { createServer as createHttpServer } from "node:http";
|
|
7
7
|
import { createServer as createHttpsServer } from "node:https";
|
|
8
8
|
import { exec, spawn } from "node:child_process";
|
|
@@ -536,6 +536,106 @@ function getLanguageFromExt(ext, filePath) {
|
|
|
536
536
|
return "plaintext";
|
|
537
537
|
return map[ext] || "plaintext";
|
|
538
538
|
}
|
|
539
|
+
// ── File preview classification ──
|
|
540
|
+
const TEXT_PREVIEWABLE_EXTS = new Set([
|
|
541
|
+
".md", ".markdown", ".mdown", ".mkd", ".mkdn", ".mdx",
|
|
542
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
543
|
+
".json", ".jsonc", ".html", ".htm", ".css", ".scss", ".less",
|
|
544
|
+
".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
|
|
545
|
+
".cs", ".swift", ".kt", ".scala", ".php", ".sh", ".bash", ".zsh", ".fish",
|
|
546
|
+
".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
|
|
547
|
+
".xml", ".sql", ".graphql", ".proto",
|
|
548
|
+
".dockerfile", ".gitignore", ".editorconfig",
|
|
549
|
+
".vue", ".svelte",
|
|
550
|
+
".txt", ".log", ".diff", ".patch",
|
|
551
|
+
".lua", ".r", ".dart", ".pl", ".pm",
|
|
552
|
+
]);
|
|
553
|
+
const IMAGE_EXTS = new Set([
|
|
554
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif",
|
|
555
|
+
".bmp", ".ico", ".heic", ".heif",
|
|
556
|
+
]);
|
|
557
|
+
const VIDEO_EXTS = new Set([
|
|
558
|
+
".mp4", ".webm", ".mov", ".mkv", ".m4v", ".ogv",
|
|
559
|
+
]);
|
|
560
|
+
const AUDIO_EXTS = new Set([
|
|
561
|
+
".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".opus",
|
|
562
|
+
]);
|
|
563
|
+
const PDF_EXTS = new Set([".pdf"]);
|
|
564
|
+
const MIME_BY_EXT = {
|
|
565
|
+
".png": "image/png",
|
|
566
|
+
".jpg": "image/jpeg",
|
|
567
|
+
".jpeg": "image/jpeg",
|
|
568
|
+
".gif": "image/gif",
|
|
569
|
+
".webp": "image/webp",
|
|
570
|
+
".svg": "image/svg+xml",
|
|
571
|
+
".avif": "image/avif",
|
|
572
|
+
".bmp": "image/bmp",
|
|
573
|
+
".ico": "image/x-icon",
|
|
574
|
+
".heic": "image/heic",
|
|
575
|
+
".heif": "image/heif",
|
|
576
|
+
".pdf": "application/pdf",
|
|
577
|
+
".mp4": "video/mp4",
|
|
578
|
+
".webm": "video/webm",
|
|
579
|
+
".mov": "video/quicktime",
|
|
580
|
+
".mkv": "video/x-matroska",
|
|
581
|
+
".m4v": "video/x-m4v",
|
|
582
|
+
".ogv": "video/ogg",
|
|
583
|
+
".mp3": "audio/mpeg",
|
|
584
|
+
".wav": "audio/wav",
|
|
585
|
+
".ogg": "audio/ogg",
|
|
586
|
+
".m4a": "audio/mp4",
|
|
587
|
+
".flac": "audio/flac",
|
|
588
|
+
".aac": "audio/aac",
|
|
589
|
+
".opus": "audio/opus",
|
|
590
|
+
};
|
|
591
|
+
const TEXT_BASENAME_ALLOW = new Set([
|
|
592
|
+
"dockerfile", ".gitignore", ".dockerignore", ".env", ".env.local",
|
|
593
|
+
".env.development", ".env.production", ".env.test",
|
|
594
|
+
"makefile", "readme", "license", "changelog",
|
|
595
|
+
]);
|
|
596
|
+
function classifyFile(ext, baseName) {
|
|
597
|
+
const lowerExt = ext.toLowerCase();
|
|
598
|
+
const lowerBase = baseName.toLowerCase();
|
|
599
|
+
if (IMAGE_EXTS.has(lowerExt))
|
|
600
|
+
return "image";
|
|
601
|
+
if (PDF_EXTS.has(lowerExt))
|
|
602
|
+
return "pdf";
|
|
603
|
+
if (VIDEO_EXTS.has(lowerExt))
|
|
604
|
+
return "video";
|
|
605
|
+
if (AUDIO_EXTS.has(lowerExt))
|
|
606
|
+
return "audio";
|
|
607
|
+
if (TEXT_PREVIEWABLE_EXTS.has(lowerExt))
|
|
608
|
+
return "text";
|
|
609
|
+
if (TEXT_BASENAME_ALLOW.has(lowerBase))
|
|
610
|
+
return "text";
|
|
611
|
+
// Files with no extension that look like text-y dotfiles
|
|
612
|
+
if (lowerExt === "" && /^[a-z0-9._-]+$/i.test(lowerBase))
|
|
613
|
+
return "text";
|
|
614
|
+
return "binary";
|
|
615
|
+
}
|
|
616
|
+
function mimeForExt(ext) {
|
|
617
|
+
return MIME_BY_EXT[ext.toLowerCase()] || "application/octet-stream";
|
|
618
|
+
}
|
|
619
|
+
/** Hidden files that should still surface even when "show hidden" is off. */
|
|
620
|
+
const HIDDEN_ALLOWLIST = new Set([
|
|
621
|
+
".gitignore", ".gitattributes", ".gitmodules",
|
|
622
|
+
".env", ".env.local", ".env.example",
|
|
623
|
+
".editorconfig", ".prettierrc", ".eslintrc",
|
|
624
|
+
".dockerignore", ".npmrc", ".nvmrc",
|
|
625
|
+
".browserslistrc", ".babelrc",
|
|
626
|
+
]);
|
|
627
|
+
function isHiddenEntry(name) {
|
|
628
|
+
if (!name.startsWith("."))
|
|
629
|
+
return false;
|
|
630
|
+
if (HIDDEN_ALLOWLIST.has(name))
|
|
631
|
+
return false;
|
|
632
|
+
// Common patterns like `.env.production` are also kept visible
|
|
633
|
+
for (const allowed of HIDDEN_ALLOWLIST) {
|
|
634
|
+
if (name.startsWith(allowed + "."))
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
539
639
|
export async function startServer(config, configPath) {
|
|
540
640
|
const app = express();
|
|
541
641
|
const storage = new WandStorage(resolveDatabasePath(configPath));
|
|
@@ -1034,9 +1134,11 @@ export async function startServer(config, configPath) {
|
|
|
1034
1134
|
}
|
|
1035
1135
|
});
|
|
1036
1136
|
// ── File browsing ──
|
|
1137
|
+
const DIRECTORY_MAX_ITEMS = 200;
|
|
1037
1138
|
app.get("/api/directory", async (req, res) => {
|
|
1038
1139
|
const q = typeof req.query.q === "string" ? req.query.q : "";
|
|
1039
1140
|
const includeGitStatus = req.query.gitStatus === "true";
|
|
1141
|
+
const showHidden = req.query.showHidden === "true";
|
|
1040
1142
|
const targetPath = path.resolve(q || config.defaultCwd);
|
|
1041
1143
|
if (isBlockedFolderPath(targetPath)) {
|
|
1042
1144
|
res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
|
|
@@ -1044,30 +1146,49 @@ export async function startServer(config, configPath) {
|
|
|
1044
1146
|
}
|
|
1045
1147
|
try {
|
|
1046
1148
|
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
1047
|
-
|
|
1048
|
-
|
|
1149
|
+
const visible = showHidden ? entries : entries.filter((e) => !isHiddenEntry(e.name));
|
|
1150
|
+
const sorted = visible.sort((a, b) => {
|
|
1049
1151
|
if (a.isDirectory() && !b.isDirectory())
|
|
1050
1152
|
return -1;
|
|
1051
1153
|
if (!a.isDirectory() && b.isDirectory())
|
|
1052
1154
|
return 1;
|
|
1053
1155
|
return a.name.localeCompare(b.name);
|
|
1054
|
-
})
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1156
|
+
});
|
|
1157
|
+
const total = sorted.length;
|
|
1158
|
+
const truncated = total > DIRECTORY_MAX_ITEMS;
|
|
1159
|
+
const sliced = sorted.slice(0, DIRECTORY_MAX_ITEMS);
|
|
1160
|
+
// Fetch size/mtime in parallel; tolerate per-entry failures.
|
|
1161
|
+
let items = await Promise.all(sliced.map(async (entry) => {
|
|
1162
|
+
const fullPath = path.join(targetPath, entry.name);
|
|
1163
|
+
const isDir = entry.isDirectory();
|
|
1164
|
+
const base = {
|
|
1165
|
+
path: fullPath,
|
|
1166
|
+
name: entry.name,
|
|
1167
|
+
type: isDir ? "dir" : "file",
|
|
1168
|
+
};
|
|
1169
|
+
if (isDir)
|
|
1170
|
+
return base;
|
|
1171
|
+
try {
|
|
1172
|
+
const st = await lstat(fullPath);
|
|
1173
|
+
base.size = st.size;
|
|
1174
|
+
base.mtime = st.mtime.toISOString();
|
|
1175
|
+
}
|
|
1176
|
+
catch {
|
|
1177
|
+
// Permission errors etc — leave size/mtime undefined.
|
|
1178
|
+
}
|
|
1179
|
+
return base;
|
|
1060
1180
|
}));
|
|
1061
1181
|
if (includeGitStatus) {
|
|
1062
1182
|
items = await enrichWithGitStatus(items, targetPath);
|
|
1063
1183
|
}
|
|
1064
|
-
|
|
1184
|
+
const payload = { items, truncated, total };
|
|
1185
|
+
res.json(payload);
|
|
1065
1186
|
}
|
|
1066
1187
|
catch (error) {
|
|
1067
1188
|
res.status(400).json({ error: getErrorMessage(error, "无法读取目录。可能原因:路径不存在或权限不足。") });
|
|
1068
1189
|
}
|
|
1069
1190
|
});
|
|
1070
|
-
const
|
|
1191
|
+
const MAX_TEXT_PREVIEW_SIZE = 512 * 1024;
|
|
1071
1192
|
app.get("/api/file-preview", async (req, res) => {
|
|
1072
1193
|
const filePath = typeof req.query.path === "string" ? req.query.path : "";
|
|
1073
1194
|
if (!filePath) {
|
|
@@ -1085,32 +1206,134 @@ export async function startServer(config, configPath) {
|
|
|
1085
1206
|
res.status(400).json({ error: "Cannot preview a directory" });
|
|
1086
1207
|
return;
|
|
1087
1208
|
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1209
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1210
|
+
const baseName = path.basename(filePath);
|
|
1211
|
+
const kind = classifyFile(ext, baseName);
|
|
1212
|
+
const mime = mimeForExt(ext);
|
|
1213
|
+
// Non-text kinds: respond with metadata so the client can pick a renderer.
|
|
1214
|
+
if (kind !== "text") {
|
|
1215
|
+
const payload = {
|
|
1216
|
+
kind,
|
|
1217
|
+
path: resolvedPath,
|
|
1218
|
+
name: baseName,
|
|
1219
|
+
ext,
|
|
1220
|
+
size: fileStat.size,
|
|
1221
|
+
mime,
|
|
1222
|
+
};
|
|
1223
|
+
res.json(payload);
|
|
1090
1224
|
return;
|
|
1091
1225
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
".dockerfile", ".gitignore", ".env", ".editorconfig",
|
|
1101
|
-
".mdx", ".vue", ".svelte",
|
|
1102
|
-
".txt", ".log", ".diff", ".patch",
|
|
1103
|
-
];
|
|
1104
|
-
const isText = previewableExts.includes(ext) ||
|
|
1105
|
-
ext === "" ||
|
|
1106
|
-
[".gitignore", "dockerfile", ".env.local", ".env.development"].some((e) => filePath.toLowerCase().endsWith(e));
|
|
1107
|
-
if (!isText) {
|
|
1108
|
-
res.status(415).json({ error: "Unsupported file type", ext });
|
|
1226
|
+
// Text/code preview path — still subject to the 512 KB cap.
|
|
1227
|
+
if (fileStat.size > MAX_TEXT_PREVIEW_SIZE) {
|
|
1228
|
+
res.status(413).json({
|
|
1229
|
+
error: "文件太大,无法在线预览(限 512 KB)。",
|
|
1230
|
+
truncated: true,
|
|
1231
|
+
size: fileStat.size,
|
|
1232
|
+
maxSize: MAX_TEXT_PREVIEW_SIZE,
|
|
1233
|
+
});
|
|
1109
1234
|
return;
|
|
1110
1235
|
}
|
|
1111
1236
|
const content = await readFile(resolvedPath, "utf-8");
|
|
1112
1237
|
const lang = getLanguageFromExt(ext, filePath);
|
|
1113
|
-
|
|
1238
|
+
const payload = {
|
|
1239
|
+
kind: "text",
|
|
1240
|
+
path: resolvedPath,
|
|
1241
|
+
name: baseName,
|
|
1242
|
+
ext,
|
|
1243
|
+
size: fileStat.size,
|
|
1244
|
+
mime,
|
|
1245
|
+
lang,
|
|
1246
|
+
content,
|
|
1247
|
+
};
|
|
1248
|
+
res.json(payload);
|
|
1249
|
+
}
|
|
1250
|
+
catch (error) {
|
|
1251
|
+
res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
// Streams the raw bytes of a file for inline media previews (image/PDF/video/audio)
|
|
1255
|
+
// and downloads. Honors HTTP Range so video/audio scrubbing works.
|
|
1256
|
+
const RAW_MAX_BYTES_BY_KIND = {
|
|
1257
|
+
text: 5 * 1024 * 1024,
|
|
1258
|
+
image: 50 * 1024 * 1024,
|
|
1259
|
+
pdf: 50 * 1024 * 1024,
|
|
1260
|
+
video: 200 * 1024 * 1024,
|
|
1261
|
+
audio: 200 * 1024 * 1024,
|
|
1262
|
+
binary: 50 * 1024 * 1024,
|
|
1263
|
+
};
|
|
1264
|
+
app.get("/api/file-raw", async (req, res) => {
|
|
1265
|
+
const filePath = typeof req.query.path === "string" ? req.query.path : "";
|
|
1266
|
+
const asDownload = req.query.download === "1" || req.query.download === "true";
|
|
1267
|
+
if (!filePath) {
|
|
1268
|
+
res.status(400).json({ error: "Missing path parameter" });
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const resolvedPath = path.resolve(filePath);
|
|
1272
|
+
if (isBlockedFolderPath(resolvedPath)) {
|
|
1273
|
+
res.status(403).json({ error: "Access denied" });
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
try {
|
|
1277
|
+
const fileStat = await stat(resolvedPath);
|
|
1278
|
+
if (!fileStat.isFile()) {
|
|
1279
|
+
res.status(400).json({ error: "Not a regular file" });
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1283
|
+
const baseName = path.basename(filePath);
|
|
1284
|
+
const kind = classifyFile(ext, baseName);
|
|
1285
|
+
const cap = RAW_MAX_BYTES_BY_KIND[kind] ?? RAW_MAX_BYTES_BY_KIND.binary;
|
|
1286
|
+
if (fileStat.size > cap) {
|
|
1287
|
+
res.status(413).json({
|
|
1288
|
+
error: `文件超出可在线预览的上限(${Math.round(cap / 1024 / 1024)} MB)。`,
|
|
1289
|
+
size: fileStat.size,
|
|
1290
|
+
maxSize: cap,
|
|
1291
|
+
});
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
const mime = mimeForExt(ext);
|
|
1295
|
+
// SVG can be served with its proper type; binary fallback uses octet-stream.
|
|
1296
|
+
const contentType = kind === "binary" ? "application/octet-stream" : mime;
|
|
1297
|
+
// Encode the filename for Content-Disposition (RFC 5987).
|
|
1298
|
+
const encodedName = encodeURIComponent(baseName);
|
|
1299
|
+
const disposition = asDownload
|
|
1300
|
+
? `attachment; filename*=UTF-8''${encodedName}`
|
|
1301
|
+
: `inline; filename*=UTF-8''${encodedName}`;
|
|
1302
|
+
const total = fileStat.size;
|
|
1303
|
+
const range = req.headers.range;
|
|
1304
|
+
// Safe to cache raw bytes briefly inside the user's browser.
|
|
1305
|
+
res.setHeader("Cache-Control", "private, max-age=60");
|
|
1306
|
+
res.setHeader("Content-Type", contentType);
|
|
1307
|
+
res.setHeader("Content-Disposition", disposition);
|
|
1308
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
1309
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1310
|
+
if (range && /^bytes=/.test(range)) {
|
|
1311
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
1312
|
+
if (!match) {
|
|
1313
|
+
res.status(416).setHeader("Content-Range", `bytes */${total}`).end();
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
const startStr = match[1];
|
|
1317
|
+
const endStr = match[2];
|
|
1318
|
+
let start = startStr === "" ? 0 : parseInt(startStr, 10);
|
|
1319
|
+
let end = endStr === "" ? total - 1 : parseInt(endStr, 10);
|
|
1320
|
+
if (Number.isNaN(start) || Number.isNaN(end) || start > end || start < 0 || end >= total) {
|
|
1321
|
+
res.status(416).setHeader("Content-Range", `bytes */${total}`).end();
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
const chunkSize = end - start + 1;
|
|
1325
|
+
res.status(206);
|
|
1326
|
+
res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
|
|
1327
|
+
res.setHeader("Content-Length", String(chunkSize));
|
|
1328
|
+
const stream = createReadStream(resolvedPath, { start, end });
|
|
1329
|
+
stream.on("error", () => res.destroy());
|
|
1330
|
+
stream.pipe(res);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
res.setHeader("Content-Length", String(total));
|
|
1334
|
+
const stream = createReadStream(resolvedPath);
|
|
1335
|
+
stream.on("error", () => res.destroy());
|
|
1336
|
+
stream.pipe(res);
|
|
1114
1337
|
}
|
|
1115
1338
|
catch (error) {
|
|
1116
1339
|
res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
|
|
@@ -18,6 +18,12 @@ export declare class StructuredSessionManager {
|
|
|
18
18
|
private readonly sessions;
|
|
19
19
|
private readonly pendingChildren;
|
|
20
20
|
private readonly pendingSdkAbort;
|
|
21
|
+
/**
|
|
22
|
+
* Active SDK Query handle per session, kept around so we can call
|
|
23
|
+
* `query.interrupt()` for a graceful stop instead of aborting via signal.
|
|
24
|
+
* Only populated while an SDK call is in flight.
|
|
25
|
+
*/
|
|
26
|
+
private readonly pendingSdkQueries;
|
|
21
27
|
private readonly interruptedWith;
|
|
22
28
|
/** Last wall-clock time (ms) we did a full saveSession for a streaming session. */
|
|
23
29
|
private readonly lastStreamSaveAt;
|
|
@@ -70,7 +76,6 @@ export declare class StructuredSessionManager {
|
|
|
70
76
|
private emit;
|
|
71
77
|
private resolvePermission;
|
|
72
78
|
private incrementApprovalStats;
|
|
73
|
-
private buildPermissionArgs;
|
|
74
79
|
private buildCodexArgs;
|
|
75
80
|
private runCodexStreaming;
|
|
76
81
|
/**
|
|
@@ -96,7 +101,6 @@ export declare class StructuredSessionManager {
|
|
|
96
101
|
* SDKAssistantMessage with the authoritative complete content.
|
|
97
102
|
*/
|
|
98
103
|
private runClaudeSdkStreaming;
|
|
99
|
-
private _runClaudeSdkStreamingAsync;
|
|
100
104
|
private extractAssistantMessage;
|
|
101
105
|
private compactContentBlocks;
|
|
102
106
|
private normalizeToolInput;
|