@co0ontty/wand 0.3.0 → 0.4.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 +1 -1
- package/dist/avatar.d.ts +14 -0
- package/dist/avatar.js +110 -0
- package/dist/claude-pty-bridge.d.ts +0 -2
- package/dist/claude-pty-bridge.js +63 -93
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +10 -2
- package/dist/config.js +6 -2
- package/dist/message-parser.js +9 -89
- package/dist/middleware/path-safety.d.ts +6 -0
- package/dist/middleware/path-safety.js +19 -0
- package/dist/middleware/rate-limit.d.ts +8 -0
- package/dist/middleware/rate-limit.js +37 -0
- package/dist/process-manager.d.ts +52 -4
- package/dist/process-manager.js +1025 -125
- package/dist/pty-text-utils.d.ts +13 -0
- package/dist/pty-text-utils.js +84 -0
- package/dist/pwa.d.ts +5 -0
- package/dist/pwa.js +118 -0
- package/dist/server.js +346 -559
- package/dist/session-lifecycle.js +17 -12
- package/dist/session-logger.d.ts +13 -3
- package/dist/session-logger.js +56 -5
- package/dist/storage.d.ts +9 -0
- package/dist/storage.js +62 -7
- package/dist/types.d.ts +8 -2
- package/dist/web-ui/content/icon-192.png +0 -0
- package/dist/web-ui/content/icon-512.png +0 -0
- package/dist/web-ui/content/scripts.js +1571 -302
- package/dist/web-ui/content/styles.css +882 -669
- package/dist/web-ui/index.js +2 -2
- package/dist/ws-broadcast.d.ts +27 -0
- package/dist/ws-broadcast.js +160 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { createServer as createHttpServer } from "node:http";
|
|
4
5
|
import { createServer as createHttpsServer } from "node:https";
|
|
5
6
|
import { exec } from "node:child_process";
|
|
6
7
|
import { promisify } from "node:util";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import process from "node:process";
|
|
9
|
-
import { WebSocketServer
|
|
10
|
+
import { WebSocketServer } from "ws";
|
|
10
11
|
const execAsync = promisify(exec);
|
|
12
|
+
const SERVER_MODULE_DIR = path.dirname(new URL(import.meta.url).pathname);
|
|
13
|
+
const RUNTIME_ROOT_DIR = path.resolve(SERVER_MODULE_DIR, "..");
|
|
14
|
+
import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
|
|
11
15
|
import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
|
|
12
16
|
import { ensureCertificates } from "./cert.js";
|
|
13
17
|
import { isExecutionMode, resolveConfigDir } from "./config.js";
|
|
@@ -15,6 +19,11 @@ import { ProcessManager, SessionInputError } from "./process-manager.js";
|
|
|
15
19
|
import { resolveDatabasePath, WandStorage } from "./storage.js";
|
|
16
20
|
import { renderApp } from "./web-ui/index.js";
|
|
17
21
|
import { parseMessages } from "./message-parser.js";
|
|
22
|
+
import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
|
|
23
|
+
import { WsBroadcastManager } from "./ws-broadcast.js";
|
|
24
|
+
import { checkRateLimit, recordFailedLogin, resetRateLimit } from "./middleware/rate-limit.js";
|
|
25
|
+
import { isPathWithinBase, isBlockedFolderPath, normalizeFolderPath } from "./middleware/path-safety.js";
|
|
26
|
+
// ── Error helpers ──
|
|
18
27
|
function getErrorMessage(error, fallback) {
|
|
19
28
|
return error instanceof Error ? error.message : fallback;
|
|
20
29
|
}
|
|
@@ -27,8 +36,8 @@ function getInputErrorResponse(error, sessionId) {
|
|
|
27
36
|
error: error.message,
|
|
28
37
|
errorCode: error.code,
|
|
29
38
|
sessionId,
|
|
30
|
-
sessionStatus: error.sessionStatus ?? null
|
|
31
|
-
}
|
|
39
|
+
sessionStatus: error.sessionStatus ?? null,
|
|
40
|
+
},
|
|
32
41
|
};
|
|
33
42
|
}
|
|
34
43
|
return {
|
|
@@ -37,37 +46,17 @@ function getInputErrorResponse(error, sessionId) {
|
|
|
37
46
|
error: getErrorMessage(error, "会话已结束,请启动新会话。"),
|
|
38
47
|
errorCode: "INPUT_SEND_FAILED",
|
|
39
48
|
sessionId,
|
|
40
|
-
sessionStatus: null
|
|
41
|
-
}
|
|
49
|
+
sessionStatus: null,
|
|
50
|
+
},
|
|
42
51
|
};
|
|
43
52
|
}
|
|
44
53
|
function getInputDebugMeta(error) {
|
|
45
54
|
if (error instanceof Error) {
|
|
46
|
-
return {
|
|
47
|
-
name: error.name,
|
|
48
|
-
message: error.message,
|
|
49
|
-
stack: error.stack
|
|
50
|
-
};
|
|
55
|
+
return { name: error.name, message: error.message, stack: error.stack };
|
|
51
56
|
}
|
|
52
57
|
return { error };
|
|
53
58
|
}
|
|
54
|
-
|
|
55
|
-
const relativePath = path.relative(basePath, targetPath);
|
|
56
|
-
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
57
|
-
}
|
|
58
|
-
const BLOCKED_FOLDER_PATHS = ["/etc", "/root", "/boot"];
|
|
59
|
-
function isBlockedFolderPath(targetPath) {
|
|
60
|
-
return BLOCKED_FOLDER_PATHS.some((blockedPath) => {
|
|
61
|
-
const relativePath = path.relative(blockedPath, targetPath);
|
|
62
|
-
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
function normalizeFolderPath(inputPath) {
|
|
66
|
-
return path.resolve(inputPath);
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Check if a directory is inside a git repository
|
|
70
|
-
*/
|
|
59
|
+
// ── Git helpers ──
|
|
71
60
|
async function isGitRepo(dirPath) {
|
|
72
61
|
try {
|
|
73
62
|
await execAsync("git rev-parse --is-inside-work-tree", { cwd: dirPath });
|
|
@@ -77,9 +66,6 @@ async function isGitRepo(dirPath) {
|
|
|
77
66
|
return false;
|
|
78
67
|
}
|
|
79
68
|
}
|
|
80
|
-
/**
|
|
81
|
-
* Get the git repository root directory
|
|
82
|
-
*/
|
|
83
69
|
async function getGitRepoRoot(dirPath) {
|
|
84
70
|
try {
|
|
85
71
|
const { stdout } = await execAsync("git rev-parse --show-toplevel", { cwd: dirPath });
|
|
@@ -89,20 +75,12 @@ async function getGitRepoRoot(dirPath) {
|
|
|
89
75
|
return null;
|
|
90
76
|
}
|
|
91
77
|
}
|
|
92
|
-
/**
|
|
93
|
-
* Get git status for all files in a directory
|
|
94
|
-
* Returns a map of relative file paths to their git status
|
|
95
|
-
*/
|
|
96
78
|
async function getGitStatusMap(gitRoot) {
|
|
97
79
|
const statusMap = new Map();
|
|
98
80
|
try {
|
|
99
|
-
// Get git status in porcelain format (stable for parsing)
|
|
100
|
-
// -uno: don't list untracked files (we'll get them separately)
|
|
101
81
|
const { stdout: stagedStdout } = await execAsync("git status --porcelain -uno", { cwd: gitRoot });
|
|
102
|
-
// Get untracked files separately
|
|
103
82
|
const { stdout: untrackedStdout } = await execAsync("git ls-files --others --exclude-standard", { cwd: gitRoot });
|
|
104
|
-
|
|
105
|
-
const lines = stagedStdout.split("\n").filter(line => line.trim());
|
|
83
|
+
const lines = stagedStdout.split("\n").filter((line) => line.trim());
|
|
106
84
|
for (const line of lines) {
|
|
107
85
|
if (line.length < 4)
|
|
108
86
|
continue;
|
|
@@ -112,7 +90,6 @@ async function getGitStatusMap(gitRoot) {
|
|
|
112
90
|
if (!filePath)
|
|
113
91
|
continue;
|
|
114
92
|
const status = {};
|
|
115
|
-
// Parse staged status
|
|
116
93
|
if (stagedChar === "M")
|
|
117
94
|
status.staged = "modified";
|
|
118
95
|
else if (stagedChar === "A")
|
|
@@ -121,15 +98,13 @@ async function getGitStatusMap(gitRoot) {
|
|
|
121
98
|
status.staged = "deleted";
|
|
122
99
|
else if (stagedChar === "R")
|
|
123
100
|
status.staged = "renamed";
|
|
124
|
-
// Parse unstaged status
|
|
125
101
|
if (unstagedChar === "M")
|
|
126
102
|
status.unstaged = "modified";
|
|
127
103
|
else if (unstagedChar === "D")
|
|
128
104
|
status.unstaged = "deleted";
|
|
129
105
|
statusMap.set(filePath, status);
|
|
130
106
|
}
|
|
131
|
-
|
|
132
|
-
const untrackedFiles = untrackedStdout.split("\n").filter(line => line.trim());
|
|
107
|
+
const untrackedFiles = untrackedStdout.split("\n").filter((line) => line.trim());
|
|
133
108
|
for (const filePath of untrackedFiles) {
|
|
134
109
|
const existing = statusMap.get(filePath);
|
|
135
110
|
if (existing) {
|
|
@@ -141,72 +116,69 @@ async function getGitStatusMap(gitRoot) {
|
|
|
141
116
|
}
|
|
142
117
|
return statusMap;
|
|
143
118
|
}
|
|
144
|
-
catch
|
|
145
|
-
// Git command failed, return empty map
|
|
119
|
+
catch {
|
|
146
120
|
return statusMap;
|
|
147
121
|
}
|
|
148
122
|
}
|
|
149
|
-
/**
|
|
150
|
-
* Enrich file entries with git status
|
|
151
|
-
*/
|
|
152
123
|
async function enrichWithGitStatus(items, dirPath) {
|
|
153
124
|
try {
|
|
154
125
|
const gitRoot = await getGitRepoRoot(dirPath);
|
|
155
|
-
if (!gitRoot)
|
|
126
|
+
if (!gitRoot)
|
|
156
127
|
return items;
|
|
157
|
-
}
|
|
158
128
|
const gitStatusMap = await getGitStatusMap(gitRoot);
|
|
159
129
|
return items.map((item) => {
|
|
160
|
-
// Get path relative to git root
|
|
161
130
|
const relativePath = path.relative(gitRoot, item.path);
|
|
162
|
-
|
|
163
|
-
const normalizedPath = relativePath.replace(/\\/g, '/');
|
|
131
|
+
const normalizedPath = relativePath.replace(/\\/g, "/");
|
|
164
132
|
const gitStatus = gitStatusMap.get(normalizedPath);
|
|
165
|
-
return {
|
|
166
|
-
...item,
|
|
167
|
-
gitStatus: gitStatus || undefined
|
|
168
|
-
};
|
|
133
|
+
return { ...item, gitStatus: gitStatus || undefined };
|
|
169
134
|
});
|
|
170
135
|
}
|
|
171
136
|
catch {
|
|
172
137
|
return items;
|
|
173
138
|
}
|
|
174
139
|
}
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
function checkRateLimit(ip) {
|
|
180
|
-
const now = Date.now();
|
|
181
|
-
const record = loginAttempts.get(ip);
|
|
182
|
-
if (!record || now > record.resetAt) {
|
|
183
|
-
return true;
|
|
184
|
-
}
|
|
185
|
-
return record.count < RATE_LIMIT_MAX;
|
|
186
|
-
}
|
|
187
|
-
function recordFailedLogin(ip) {
|
|
188
|
-
const now = Date.now();
|
|
189
|
-
const record = loginAttempts.get(ip);
|
|
190
|
-
if (!record || now > record.resetAt) {
|
|
191
|
-
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
|
|
140
|
+
// ── Auth helpers ──
|
|
141
|
+
function requireAuth(req, res, next) {
|
|
142
|
+
if (!validateSession(readSessionCookie(req))) {
|
|
143
|
+
res.status(401).json({ error: "未授权,请先登录。" });
|
|
192
144
|
return;
|
|
193
145
|
}
|
|
194
|
-
|
|
146
|
+
next();
|
|
195
147
|
}
|
|
196
|
-
function
|
|
197
|
-
|
|
148
|
+
function readSessionCookie(req) {
|
|
149
|
+
const cookie = req.headers.cookie;
|
|
150
|
+
if (!cookie)
|
|
151
|
+
return undefined;
|
|
152
|
+
const match = cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith("wand_session="));
|
|
153
|
+
return match?.slice("wand_session=".length);
|
|
198
154
|
}
|
|
199
|
-
function
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
155
|
+
function normalizeMode(input, fallback) {
|
|
156
|
+
return isExecutionMode(input) ? input : fallback;
|
|
157
|
+
}
|
|
158
|
+
async function listPathSuggestions(input, fallbackCwd) {
|
|
159
|
+
const normalizedInput = input.trim();
|
|
160
|
+
const baseInput = normalizedInput || fallbackCwd;
|
|
161
|
+
const resolvedInput = path.resolve(process.cwd(), baseInput);
|
|
162
|
+
const endsWithSeparator = /[\\/]$/.test(normalizedInput);
|
|
163
|
+
let searchDir = resolvedInput;
|
|
164
|
+
let partialName = "";
|
|
165
|
+
if (!endsWithSeparator) {
|
|
166
|
+
searchDir = path.dirname(resolvedInput);
|
|
167
|
+
partialName = path.basename(resolvedInput);
|
|
205
168
|
}
|
|
169
|
+
const entries = await readdir(searchDir, { withFileTypes: true });
|
|
170
|
+
return entries
|
|
171
|
+
.filter((entry) => entry.isDirectory())
|
|
172
|
+
.filter((entry) => !partialName || entry.name.toLowerCase().startsWith(partialName.toLowerCase()))
|
|
173
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
174
|
+
.slice(0, 8)
|
|
175
|
+
.map((entry) => ({
|
|
176
|
+
path: path.join(searchDir, entry.name),
|
|
177
|
+
name: entry.name,
|
|
178
|
+
isDirectory: true,
|
|
179
|
+
}));
|
|
206
180
|
}
|
|
207
|
-
//
|
|
208
|
-
setInterval(cleanupRateLimiter, 5 * 60 * 1000);
|
|
209
|
-
// Catch-all for unexpected startup errors
|
|
181
|
+
// ── Startup error handling ──
|
|
210
182
|
process.on("uncaughtException", (err) => {
|
|
211
183
|
wandError("服务器异常", err.message, "请检查配置是否正确,或尝试重启服务。");
|
|
212
184
|
process.exit(1);
|
|
@@ -215,7 +187,6 @@ process.on("unhandledRejection", (reason) => {
|
|
|
215
187
|
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
216
188
|
wandError("未处理的异步错误", msg);
|
|
217
189
|
});
|
|
218
|
-
// ── Friendly error / warn / info helpers ──────────────────────────────────
|
|
219
190
|
function wandError(label, message, suggestion) {
|
|
220
191
|
process.stderr.write(`\n✗ [wand] ${label}:${message}\n`);
|
|
221
192
|
if (suggestion)
|
|
@@ -227,164 +198,96 @@ function wandWarn(message, hint) {
|
|
|
227
198
|
if (hint)
|
|
228
199
|
process.stderr.write(` 提示:${hint}\n`);
|
|
229
200
|
}
|
|
230
|
-
function
|
|
231
|
-
|
|
201
|
+
function parseStoredPathList(raw) {
|
|
202
|
+
if (!raw)
|
|
203
|
+
return [];
|
|
204
|
+
try {
|
|
205
|
+
const parsed = JSON.parse(raw);
|
|
206
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const HIDDEN_CLAUDE_SESSIONS_KEY = "hidden_claude_sessions";
|
|
213
|
+
function getHiddenClaudeSessionIds(storage) {
|
|
214
|
+
return new Set(parseStoredPathList(storage.getConfigValue(HIDDEN_CLAUDE_SESSIONS_KEY)));
|
|
215
|
+
}
|
|
216
|
+
function saveHiddenClaudeSessionIds(storage, hidden) {
|
|
217
|
+
storage.setConfigValue(HIDDEN_CLAUDE_SESSIONS_KEY, JSON.stringify(Array.from(hidden)));
|
|
232
218
|
}
|
|
219
|
+
const MAX_RECENT_PATHS = 10;
|
|
220
|
+
// ── File language detection ──
|
|
221
|
+
function getLanguageFromExt(ext, filePath) {
|
|
222
|
+
const map = {
|
|
223
|
+
".ts": "typescript", ".tsx": "tsx", ".js": "javascript", ".jsx": "jsx",
|
|
224
|
+
".json": "json", ".html": "html", ".htm": "html",
|
|
225
|
+
".css": "css", ".scss": "scss", ".less": "less",
|
|
226
|
+
".py": "python", ".rb": "ruby", ".go": "go", ".rs": "rust",
|
|
227
|
+
".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
|
|
228
|
+
".cs": "csharp", ".swift": "swift", ".kt": "kotlin", ".scala": "scala",
|
|
229
|
+
".php": "php", ".sh": "bash", ".bash": "bash", ".zsh": "bash",
|
|
230
|
+
".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".ini": "ini",
|
|
231
|
+
".xml": "xml", ".sql": "sql", ".graphql": "graphql",
|
|
232
|
+
".md": "markdown", ".markdown": "markdown", ".mdown": "markdown",
|
|
233
|
+
".mkd": "markdown", ".mkdn": "markdown",
|
|
234
|
+
".dockerfile": "dockerfile", ".gitignore": "plaintext",
|
|
235
|
+
".diff": "diff", ".patch": "diff", ".proto": "protobuf",
|
|
236
|
+
".env": "bash", ".editorconfig": "ini",
|
|
237
|
+
".mdx": "markdown", ".vue": "html", ".svelte": "html",
|
|
238
|
+
};
|
|
239
|
+
const baseName = path.basename(filePath).toLowerCase();
|
|
240
|
+
if (baseName === "dockerfile")
|
|
241
|
+
return "dockerfile";
|
|
242
|
+
if (baseName === ".gitignore")
|
|
243
|
+
return "plaintext";
|
|
244
|
+
return map[ext] || "plaintext";
|
|
245
|
+
}
|
|
246
|
+
// ── Main server ──
|
|
233
247
|
export async function startServer(config, configPath) {
|
|
234
248
|
const app = express();
|
|
235
249
|
const storage = new WandStorage(resolveDatabasePath(configPath));
|
|
236
250
|
setAuthStorage(storage);
|
|
237
|
-
const
|
|
238
|
-
const
|
|
251
|
+
const configDir = resolveConfigDir(configPath);
|
|
252
|
+
const avatarSeed = await ensureAvatarSeed(configDir);
|
|
253
|
+
const processes = new ProcessManager(config, storage, configDir);
|
|
254
|
+
const useHttps = config.https === true;
|
|
239
255
|
const protocol = useHttps ? "https" : "http";
|
|
256
|
+
const nodeModulesDir = path.join(RUNTIME_ROOT_DIR, "node_modules");
|
|
240
257
|
app.use(express.json({ limit: "1mb" }));
|
|
241
|
-
app.use("/vendor/xterm", express.static(path.
|
|
242
|
-
app.use("/vendor/xterm-addon-fit", express.static(path.
|
|
258
|
+
app.use("/vendor/xterm", express.static(path.join(nodeModulesDir, "xterm")));
|
|
259
|
+
app.use("/vendor/xterm-addon-fit", express.static(path.join(nodeModulesDir, "@xterm", "addon-fit")));
|
|
260
|
+
// ── Web UI and PWA endpoints ──
|
|
243
261
|
app.get("/", (_req, res) => {
|
|
262
|
+
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
244
263
|
res.type("html").send(renderApp(configPath));
|
|
245
264
|
});
|
|
246
|
-
// PWA manifest
|
|
247
265
|
app.get("/manifest.json", (_req, res) => {
|
|
248
|
-
res.
|
|
249
|
-
|
|
250
|
-
scope: "/",
|
|
251
|
-
name: "Wand Console",
|
|
252
|
-
short_name: "Wand",
|
|
253
|
-
description: "Local CLI Console for Vibe Coding",
|
|
254
|
-
start_url: "/",
|
|
255
|
-
display: "standalone",
|
|
256
|
-
display_override: ["standalone", "minimal-ui", "browser"],
|
|
257
|
-
background_color: "#f6f1e8",
|
|
258
|
-
theme_color: "#c5653d",
|
|
259
|
-
orientation: "any",
|
|
260
|
-
icons: [
|
|
261
|
-
{ src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" },
|
|
262
|
-
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" },
|
|
263
|
-
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }
|
|
264
|
-
],
|
|
265
|
-
categories: ["developer tools", "productivity"],
|
|
266
|
-
shortcuts: [
|
|
267
|
-
{ name: "New Session", short_name: "New", url: "/?action=new", description: "Start a new CLI session" }
|
|
268
|
-
],
|
|
269
|
-
// iOS Safari specific
|
|
270
|
-
ios: {
|
|
271
|
-
statusBarStyle: "black-translucent"
|
|
272
|
-
},
|
|
273
|
-
// Android Chrome specific
|
|
274
|
-
share_target: {
|
|
275
|
-
action: "/",
|
|
276
|
-
method: "GET",
|
|
277
|
-
params: {
|
|
278
|
-
text: "q",
|
|
279
|
-
url: "url"
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}));
|
|
266
|
+
res.setHeader("Content-Type", "application/manifest+json");
|
|
267
|
+
res.send(generatePwaManifest());
|
|
283
268
|
});
|
|
284
|
-
const iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
|
285
|
-
<defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
286
|
-
<stop offset="0%" style="stop-color:#d77a52"/>
|
|
287
|
-
<stop offset="100%" style="stop-color:#a95130"/>
|
|
288
|
-
</linearGradient></defs>
|
|
289
|
-
<rect width="192" height="192" rx="38" fill="url(#g)"/>
|
|
290
|
-
<text x="96" y="128" text-anchor="middle" font-family="system-ui,sans-serif" font-size="88" font-weight="700" fill="white">W</text>
|
|
291
|
-
</svg>`;
|
|
292
269
|
app.get("/icon.svg", (_req, res) => {
|
|
293
|
-
res.type("svg").send(
|
|
270
|
+
res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 192));
|
|
294
271
|
});
|
|
272
|
+
const iconsDir = path.resolve(existsSync(path.join(SERVER_MODULE_DIR, "web-ui", "content"))
|
|
273
|
+
? path.join(SERVER_MODULE_DIR, "web-ui", "content")
|
|
274
|
+
: path.join(RUNTIME_ROOT_DIR, "src", "web-ui", "content"));
|
|
295
275
|
app.get("/icon-192.png", (_req, res) => {
|
|
296
|
-
res.
|
|
276
|
+
res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 192));
|
|
297
277
|
});
|
|
298
278
|
app.get("/icon-512.png", (_req, res) => {
|
|
299
|
-
res.
|
|
279
|
+
res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 512));
|
|
300
280
|
});
|
|
301
|
-
// Service Worker for offline support
|
|
302
281
|
app.get("/sw.js", (_req, res) => {
|
|
303
|
-
res.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const STATIC_ASSETS = [
|
|
308
|
-
APP_SHELL,
|
|
309
|
-
'/manifest.json',
|
|
310
|
-
'/icon.svg',
|
|
311
|
-
'/vendor/xterm/css/xterm.css',
|
|
312
|
-
'/vendor/xterm/lib/xterm.js',
|
|
313
|
-
'/vendor/xterm-addon-fit/lib/addon-fit.js'
|
|
314
|
-
];
|
|
315
|
-
|
|
316
|
-
self.addEventListener('install', (event) => {
|
|
317
|
-
event.waitUntil(caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS)));
|
|
318
|
-
self.skipWaiting();
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
self.addEventListener('activate', (event) => {
|
|
322
|
-
event.waitUntil(
|
|
323
|
-
caches.keys().then((keys) => Promise.all(
|
|
324
|
-
keys
|
|
325
|
-
.filter((key) => key !== STATIC_CACHE && key !== RUNTIME_CACHE)
|
|
326
|
-
.map((key) => caches.delete(key))
|
|
327
|
-
))
|
|
328
|
-
);
|
|
329
|
-
self.clients.claim();
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
async function cacheFirst(request) {
|
|
333
|
-
const cached = await caches.match(request);
|
|
334
|
-
if (cached) return cached;
|
|
335
|
-
|
|
336
|
-
const response = await fetch(request);
|
|
337
|
-
if (response.ok && request.method === 'GET') {
|
|
338
|
-
const clone = response.clone();
|
|
339
|
-
const cache = await caches.open(RUNTIME_CACHE);
|
|
340
|
-
cache.put(request, clone);
|
|
341
|
-
}
|
|
342
|
-
return response;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
self.addEventListener('fetch', (event) => {
|
|
346
|
-
const request = event.request;
|
|
347
|
-
const url = new URL(request.url);
|
|
348
|
-
|
|
349
|
-
if (request.method !== 'GET') {
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
if (url.pathname.startsWith('/api/')) {
|
|
354
|
-
event.respondWith(
|
|
355
|
-
fetch(request).catch(() => new Response(JSON.stringify({ error: 'Offline' }), {
|
|
356
|
-
status: 503,
|
|
357
|
-
headers: { 'Content-Type': 'application/json' }
|
|
358
|
-
}))
|
|
359
|
-
);
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
if (request.mode === 'navigate') {
|
|
364
|
-
event.respondWith(
|
|
365
|
-
fetch(request)
|
|
366
|
-
.then((response) => {
|
|
367
|
-
const clone = response.clone();
|
|
368
|
-
caches.open(RUNTIME_CACHE).then((cache) => cache.put(APP_SHELL, clone));
|
|
369
|
-
return response;
|
|
370
|
-
})
|
|
371
|
-
.catch(async () => (await caches.match(APP_SHELL)) || Response.error())
|
|
372
|
-
);
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
event.respondWith(
|
|
377
|
-
cacheFirst(request).catch(async () => {
|
|
378
|
-
const cached = await caches.match(request);
|
|
379
|
-
return cached || (await caches.match(APP_SHELL)) || Response.error();
|
|
380
|
-
})
|
|
381
|
-
);
|
|
382
|
-
});
|
|
383
|
-
`);
|
|
282
|
+
res.setHeader("Content-Type", "application/javascript");
|
|
283
|
+
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
284
|
+
res.setHeader("Service-Worker-Allowed", "/");
|
|
285
|
+
res.send(generateServiceWorker());
|
|
384
286
|
});
|
|
385
287
|
app.get("/offline", (_req, res) => {
|
|
386
288
|
res.type("html").send(renderApp(configPath));
|
|
387
289
|
});
|
|
290
|
+
// ── Auth routes ──
|
|
388
291
|
app.post("/api/login", (req, res) => {
|
|
389
292
|
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
|
|
390
293
|
if (!checkRateLimit(clientIp)) {
|
|
@@ -392,7 +295,6 @@ self.addEventListener('fetch', (event) => {
|
|
|
392
295
|
return;
|
|
393
296
|
}
|
|
394
297
|
const { password } = req.body;
|
|
395
|
-
// Check password: prefer database password, fallback to config password
|
|
396
298
|
const dbPassword = storage.getPassword();
|
|
397
299
|
const effectivePassword = dbPassword ?? config.password;
|
|
398
300
|
if (password !== effectivePassword) {
|
|
@@ -406,7 +308,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
406
308
|
httpOnly: true,
|
|
407
309
|
sameSite: "strict",
|
|
408
310
|
secure: useHttps,
|
|
409
|
-
maxAge: 1000 * 60 * 60 * 12
|
|
311
|
+
maxAge: 1000 * 60 * 60 * 12,
|
|
410
312
|
});
|
|
411
313
|
res.json({ ok: true });
|
|
412
314
|
});
|
|
@@ -415,7 +317,6 @@ self.addEventListener('fetch', (event) => {
|
|
|
415
317
|
res.clearCookie("wand_session");
|
|
416
318
|
res.json({ ok: true });
|
|
417
319
|
});
|
|
418
|
-
// Set password endpoint (requires auth)
|
|
419
320
|
app.post("/api/set-password", requireAuth, (req, res) => {
|
|
420
321
|
const { password } = req.body;
|
|
421
322
|
if (!password || password.length < 6) {
|
|
@@ -426,18 +327,143 @@ self.addEventListener('fetch', (event) => {
|
|
|
426
327
|
res.json({ ok: true });
|
|
427
328
|
});
|
|
428
329
|
app.use("/api", requireAuth);
|
|
330
|
+
// ── Config & Session info ──
|
|
429
331
|
app.get("/api/config", (_req, res) => {
|
|
430
332
|
res.json({
|
|
431
333
|
host: config.host,
|
|
432
334
|
port: config.port,
|
|
433
335
|
defaultMode: config.defaultMode,
|
|
434
336
|
defaultCwd: config.defaultCwd,
|
|
435
|
-
commandPresets: config.commandPresets
|
|
337
|
+
commandPresets: config.commandPresets,
|
|
436
338
|
});
|
|
437
339
|
});
|
|
438
340
|
app.get("/api/sessions", (_req, res) => {
|
|
439
341
|
res.json(processes.list());
|
|
440
342
|
});
|
|
343
|
+
app.get("/api/claude-history", (_req, res) => {
|
|
344
|
+
try {
|
|
345
|
+
const sessions = processes.listClaudeHistorySessions();
|
|
346
|
+
const hidden = getHiddenClaudeSessionIds(storage);
|
|
347
|
+
const filtered = hidden.size > 0
|
|
348
|
+
? sessions.filter((s) => !s.claudeSessionId || !hidden.has(s.claudeSessionId))
|
|
349
|
+
: sessions;
|
|
350
|
+
res.json(filtered);
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
res.status(500).json({ error: getErrorMessage(error, "无法扫描 Claude 历史会话。") });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
app.delete("/api/claude-history/:claudeSessionId", (req, res) => {
|
|
357
|
+
const claudeSessionId = req.params.claudeSessionId?.trim();
|
|
358
|
+
if (!claudeSessionId) {
|
|
359
|
+
res.status(400).json({ error: "会话 ID 不能为空。" });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const hidden = getHiddenClaudeSessionIds(storage);
|
|
363
|
+
if (!hidden.has(claudeSessionId)) {
|
|
364
|
+
hidden.add(claudeSessionId);
|
|
365
|
+
saveHiddenClaudeSessionIds(storage, hidden);
|
|
366
|
+
}
|
|
367
|
+
res.json({ ok: true });
|
|
368
|
+
});
|
|
369
|
+
app.delete("/api/claude-history", (req, res) => {
|
|
370
|
+
const cwd = typeof req.query.cwd === "string" ? req.query.cwd.trim() : "";
|
|
371
|
+
if (!cwd) {
|
|
372
|
+
res.status(400).json({ error: "目录不能为空。" });
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
const sessions = processes.listClaudeHistorySessions();
|
|
377
|
+
const hidden = getHiddenClaudeSessionIds(storage);
|
|
378
|
+
let added = 0;
|
|
379
|
+
for (const session of sessions) {
|
|
380
|
+
if (!session.claudeSessionId || session.cwd !== cwd) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (hidden.has(session.claudeSessionId)) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
hidden.add(session.claudeSessionId);
|
|
387
|
+
added += 1;
|
|
388
|
+
}
|
|
389
|
+
if (added > 0) {
|
|
390
|
+
saveHiddenClaudeSessionIds(storage, hidden);
|
|
391
|
+
}
|
|
392
|
+
res.json({ ok: true, deleted: added });
|
|
393
|
+
}
|
|
394
|
+
catch (error) {
|
|
395
|
+
res.status(500).json({ error: getErrorMessage(error, "无法删除该目录下的历史会话。") });
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
app.post("/api/claude-history/batch-delete", express.json(), (req, res) => {
|
|
399
|
+
const claudeSessionIds = Array.isArray(req.body?.claudeSessionIds)
|
|
400
|
+
? req.body.claudeSessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
401
|
+
: [];
|
|
402
|
+
if (claudeSessionIds.length === 0) {
|
|
403
|
+
res.status(400).json({ error: "至少提供一个历史会话 ID。" });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const hidden = getHiddenClaudeSessionIds(storage);
|
|
408
|
+
let added = 0;
|
|
409
|
+
for (const claudeSessionId of claudeSessionIds) {
|
|
410
|
+
if (hidden.has(claudeSessionId)) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
hidden.add(claudeSessionId);
|
|
414
|
+
added += 1;
|
|
415
|
+
}
|
|
416
|
+
if (added > 0) {
|
|
417
|
+
saveHiddenClaudeSessionIds(storage, hidden);
|
|
418
|
+
}
|
|
419
|
+
res.json({ ok: true, deleted: added });
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
res.status(500).json({ error: getErrorMessage(error, "无法批量删除历史会话。") });
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
app.post("/api/sessions/batch-delete", express.json(), (req, res) => {
|
|
426
|
+
const sessionIds = Array.isArray(req.body?.sessionIds)
|
|
427
|
+
? req.body.sessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
428
|
+
: [];
|
|
429
|
+
if (sessionIds.length === 0) {
|
|
430
|
+
res.status(400).json({ error: "至少提供一个会话 ID。" });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
let deleted = 0;
|
|
434
|
+
const failed = [];
|
|
435
|
+
for (const sessionId of sessionIds) {
|
|
436
|
+
try {
|
|
437
|
+
processes.delete(sessionId);
|
|
438
|
+
deleted += 1;
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
failed.push(sessionId);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (deleted === 0 && failed.length > 0) {
|
|
445
|
+
res.status(400).json({ error: "无法批量删除会话。", failed });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
res.json({ ok: true, deleted, failed });
|
|
449
|
+
});
|
|
450
|
+
app.get("/api/sessions/:id", (req, res) => {
|
|
451
|
+
const snapshot = processes.get(req.params.id);
|
|
452
|
+
if (!snapshot) {
|
|
453
|
+
res.status(404).json({ error: "未找到该会话,可能已被删除。" });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (req.query.format === "chat") {
|
|
457
|
+
const messages = snapshot.messages && snapshot.messages.length > 0
|
|
458
|
+
? snapshot.messages
|
|
459
|
+
: parseMessages(snapshot.output);
|
|
460
|
+
res.json({ ...snapshot, messages });
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
res.json(snapshot);
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
// ── Path suggestion ──
|
|
441
467
|
app.get("/api/path-suggestions", async (req, res) => {
|
|
442
468
|
const query = typeof req.query.q === "string" ? req.query.q : "";
|
|
443
469
|
try {
|
|
@@ -448,11 +474,11 @@ self.addEventListener('fetch', (event) => {
|
|
|
448
474
|
res.status(400).json({ error: getErrorMessage(error, "无法加载路径建议。") });
|
|
449
475
|
}
|
|
450
476
|
});
|
|
477
|
+
// ── File browsing ──
|
|
451
478
|
app.get("/api/directory", async (req, res) => {
|
|
452
479
|
const q = typeof req.query.q === "string" ? req.query.q : "";
|
|
453
480
|
const includeGitStatus = req.query.gitStatus === "true";
|
|
454
481
|
const targetPath = path.resolve(process.cwd(), q);
|
|
455
|
-
// Security check: ensure the resolved path is within the current working directory
|
|
456
482
|
const allowedBase = process.cwd();
|
|
457
483
|
if (!isPathWithinBase(targetPath, allowedBase)) {
|
|
458
484
|
res.status(403).json({ error: "访问被拒绝:路径必须在项目目录内。" });
|
|
@@ -462,7 +488,6 @@ self.addEventListener('fetch', (event) => {
|
|
|
462
488
|
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
463
489
|
let items = entries
|
|
464
490
|
.sort((a, b) => {
|
|
465
|
-
// Directories first, then alphabetically
|
|
466
491
|
if (a.isDirectory() && !b.isDirectory())
|
|
467
492
|
return -1;
|
|
468
493
|
if (!a.isDirectory() && b.isDirectory())
|
|
@@ -473,9 +498,8 @@ self.addEventListener('fetch', (event) => {
|
|
|
473
498
|
.map((entry) => ({
|
|
474
499
|
path: path.join(targetPath, entry.name),
|
|
475
500
|
name: entry.name,
|
|
476
|
-
type: entry.isDirectory() ? "dir" : "file"
|
|
501
|
+
type: entry.isDirectory() ? "dir" : "file",
|
|
477
502
|
}));
|
|
478
|
-
// Enrich with git status if requested
|
|
479
503
|
if (includeGitStatus) {
|
|
480
504
|
items = await enrichWithGitStatus(items, targetPath);
|
|
481
505
|
}
|
|
@@ -485,8 +509,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
485
509
|
res.status(400).json({ error: getErrorMessage(error, "无法读取目录。可能原因:路径不存在或权限不足。") });
|
|
486
510
|
}
|
|
487
511
|
});
|
|
488
|
-
|
|
489
|
-
const MAX_FILE_SIZE = 512 * 1024; // 512KB limit
|
|
512
|
+
const MAX_FILE_SIZE = 512 * 1024;
|
|
490
513
|
app.get("/api/file-preview", async (req, res) => {
|
|
491
514
|
const filePath = typeof req.query.path === "string" ? req.query.path : "";
|
|
492
515
|
if (!filePath) {
|
|
@@ -511,9 +534,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
511
534
|
}
|
|
512
535
|
const ext = path.extname(filePath).toLowerCase();
|
|
513
536
|
const previewableExts = [
|
|
514
|
-
// Markdown
|
|
515
537
|
".md", ".markdown", ".mdown", ".mkd", ".mkdn",
|
|
516
|
-
// Code
|
|
517
538
|
".ts", ".tsx", ".js", ".jsx", ".json", ".html", ".css", ".scss", ".less",
|
|
518
539
|
".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
|
|
519
540
|
".cs", ".swift", ".kt", ".scala", ".php", ".sh", ".bash", ".zsh",
|
|
@@ -521,58 +542,23 @@ self.addEventListener('fetch', (event) => {
|
|
|
521
542
|
".xml", ".sql", ".graphql", ".proto",
|
|
522
543
|
".dockerfile", ".gitignore", ".env", ".editorconfig",
|
|
523
544
|
".mdx", ".vue", ".svelte",
|
|
524
|
-
|
|
525
|
-
".txt", ".log", ".diff", ".patch"
|
|
545
|
+
".txt", ".log", ".diff", ".patch",
|
|
526
546
|
];
|
|
527
547
|
const isText = previewableExts.includes(ext) ||
|
|
528
548
|
ext === "" ||
|
|
529
|
-
[".gitignore", "dockerfile", ".env.local", ".env.development"].some(e => filePath.toLowerCase().endsWith(e));
|
|
549
|
+
[".gitignore", "dockerfile", ".env.local", ".env.development"].some((e) => filePath.toLowerCase().endsWith(e));
|
|
530
550
|
if (!isText) {
|
|
531
551
|
res.status(415).json({ error: "Unsupported file type", ext });
|
|
532
552
|
return;
|
|
533
553
|
}
|
|
534
554
|
const content = await readFile(resolvedPath, "utf-8");
|
|
535
555
|
const lang = getLanguageFromExt(ext, filePath);
|
|
536
|
-
res.json({
|
|
537
|
-
path: resolvedPath,
|
|
538
|
-
name: path.basename(filePath),
|
|
539
|
-
ext,
|
|
540
|
-
lang,
|
|
541
|
-
content,
|
|
542
|
-
size: fileStat.size
|
|
543
|
-
});
|
|
556
|
+
res.json({ path: resolvedPath, name: path.basename(filePath), ext, lang, content, size: fileStat.size });
|
|
544
557
|
}
|
|
545
558
|
catch (error) {
|
|
546
559
|
res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
|
|
547
560
|
}
|
|
548
561
|
});
|
|
549
|
-
// Helper to detect language from extension
|
|
550
|
-
function getLanguageFromExt(ext, filePath) {
|
|
551
|
-
const map = {
|
|
552
|
-
".ts": "typescript", ".tsx": "tsx", ".js": "javascript", ".jsx": "jsx",
|
|
553
|
-
".json": "json", ".html": "html", ".htm": "html",
|
|
554
|
-
".css": "css", ".scss": "scss", ".less": "less",
|
|
555
|
-
".py": "python", ".rb": "ruby", ".go": "go", ".rs": "rust",
|
|
556
|
-
".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
|
|
557
|
-
".cs": "csharp", ".swift": "swift", ".kt": "kotlin", ".scala": "scala",
|
|
558
|
-
".php": "php", ".sh": "bash", ".bash": "bash", ".zsh": "bash",
|
|
559
|
-
".yaml": "yaml", ".yml": "yaml", ".toml": "toml", ".ini": "ini",
|
|
560
|
-
".xml": "xml", ".sql": "sql", ".graphql": "graphql",
|
|
561
|
-
".md": "markdown", ".markdown": "markdown", ".mdown": "markdown",
|
|
562
|
-
".mkd": "markdown", ".mkdn": "markdown",
|
|
563
|
-
".dockerfile": "dockerfile", ".gitignore": "plaintext",
|
|
564
|
-
".diff": "diff", ".patch": "diff", ".proto": "protobuf",
|
|
565
|
-
".env": "bash", ".editorconfig": "ini",
|
|
566
|
-
".mdx": "markdown", ".vue": "html", ".svelte": "html"
|
|
567
|
-
};
|
|
568
|
-
const baseName = path.basename(filePath).toLowerCase();
|
|
569
|
-
if (baseName === "dockerfile")
|
|
570
|
-
return "dockerfile";
|
|
571
|
-
if (baseName === ".gitignore")
|
|
572
|
-
return "plaintext";
|
|
573
|
-
return map[ext] || "plaintext";
|
|
574
|
-
}
|
|
575
|
-
// Folder picker API - starts from /tmp by default, supports navigation
|
|
576
562
|
app.get("/api/folders", async (req, res) => {
|
|
577
563
|
const q = typeof req.query.q === "string" ? req.query.q : "/tmp";
|
|
578
564
|
const targetPath = normalizeFolderPath(q);
|
|
@@ -583,38 +569,24 @@ self.addEventListener('fetch', (event) => {
|
|
|
583
569
|
try {
|
|
584
570
|
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
585
571
|
const items = [];
|
|
586
|
-
// Add parent directory navigation (..)
|
|
587
572
|
const parentPath = path.dirname(targetPath);
|
|
588
573
|
if (parentPath !== targetPath) {
|
|
589
|
-
items.push({
|
|
590
|
-
path: parentPath,
|
|
591
|
-
name: "..",
|
|
592
|
-
type: "parent",
|
|
593
|
-
isParent: true
|
|
594
|
-
});
|
|
574
|
+
items.push({ path: parentPath, name: "..", type: "parent", isParent: true });
|
|
595
575
|
}
|
|
596
|
-
// Add subdirectories
|
|
597
576
|
entries
|
|
598
577
|
.filter((entry) => entry.isDirectory())
|
|
599
578
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
600
579
|
.slice(0, 100)
|
|
601
580
|
.forEach((entry) => {
|
|
602
|
-
items.push({
|
|
603
|
-
path: path.join(targetPath, entry.name),
|
|
604
|
-
name: entry.name,
|
|
605
|
-
type: "dir"
|
|
606
|
-
});
|
|
607
|
-
});
|
|
608
|
-
res.json({
|
|
609
|
-
currentPath: targetPath,
|
|
610
|
-
items: items
|
|
581
|
+
items.push({ path: path.join(targetPath, entry.name), name: entry.name, type: "dir" });
|
|
611
582
|
});
|
|
583
|
+
res.json({ currentPath: targetPath, items });
|
|
612
584
|
}
|
|
613
585
|
catch (error) {
|
|
614
|
-
if (error.code ===
|
|
586
|
+
if (error.code === "ENOENT") {
|
|
615
587
|
res.status(404).json({ error: "路径不存在:" + q, currentPath: q, items: [] });
|
|
616
588
|
}
|
|
617
|
-
else if (error.code ===
|
|
589
|
+
else if (error.code === "EACCES") {
|
|
618
590
|
res.status(403).json({ error: "权限不足,无法访问:" + q, currentPath: q, items: [] });
|
|
619
591
|
}
|
|
620
592
|
else {
|
|
@@ -622,33 +594,19 @@ self.addEventListener('fetch', (event) => {
|
|
|
622
594
|
}
|
|
623
595
|
}
|
|
624
596
|
});
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
const quickPaths = [
|
|
597
|
+
app.get("/api/quick-paths", async (_req, res) => {
|
|
598
|
+
const home = process.env.HOME || process.env.USERPROFILE || "/home";
|
|
599
|
+
res.json([
|
|
629
600
|
{ path: "/tmp", name: "临时目录", icon: "🗑️" },
|
|
630
601
|
{ path: home, name: "主目录", icon: "🏠" },
|
|
631
602
|
{ path: process.cwd(), name: "当前目录", icon: "📂" },
|
|
632
|
-
{ path: "/", name: "根目录", icon: "📁" }
|
|
633
|
-
];
|
|
634
|
-
res.json(quickPaths);
|
|
603
|
+
{ path: "/", name: "根目录", icon: "📁" },
|
|
604
|
+
]);
|
|
635
605
|
});
|
|
636
|
-
function parseStoredPathList(raw) {
|
|
637
|
-
if (!raw) {
|
|
638
|
-
return [];
|
|
639
|
-
}
|
|
640
|
-
try {
|
|
641
|
-
const parsed = JSON.parse(raw);
|
|
642
|
-
return Array.isArray(parsed) ? parsed : [];
|
|
643
|
-
}
|
|
644
|
-
catch {
|
|
645
|
-
return [];
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
606
|
app.get("/api/favorite-paths", (_req, res) => {
|
|
649
607
|
const stored = storage.getConfigValue("favorite_paths");
|
|
650
608
|
const favorites = parseStoredPathList(stored);
|
|
651
|
-
res.json(favorites.filter((
|
|
609
|
+
res.json(favorites.filter((f) => !isBlockedFolderPath(normalizeFolderPath(f.path))));
|
|
652
610
|
});
|
|
653
611
|
app.post("/api/favorite-paths", (req, res) => {
|
|
654
612
|
const { path: favPath, name, icon } = req.body;
|
|
@@ -663,7 +621,6 @@ self.addEventListener('fetch', (event) => {
|
|
|
663
621
|
}
|
|
664
622
|
const stored = storage.getConfigValue("favorite_paths");
|
|
665
623
|
const favorites = parseStoredPathList(stored);
|
|
666
|
-
// Check if already exists
|
|
667
624
|
if (favorites.some((f) => normalizeFolderPath(f.path) === resolvedFavoritePath)) {
|
|
668
625
|
res.status(400).json({ error: "该路径已在收藏列表中。" });
|
|
669
626
|
return;
|
|
@@ -672,7 +629,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
672
629
|
path: resolvedFavoritePath,
|
|
673
630
|
name: name || path.basename(resolvedFavoritePath),
|
|
674
631
|
icon: icon || "⭐",
|
|
675
|
-
addedAt: new Date().toISOString()
|
|
632
|
+
addedAt: new Date().toISOString(),
|
|
676
633
|
};
|
|
677
634
|
favorites.push(newFavorite);
|
|
678
635
|
storage.setConfigValue("favorite_paths", JSON.stringify(favorites));
|
|
@@ -695,7 +652,6 @@ self.addEventListener('fetch', (event) => {
|
|
|
695
652
|
storage.setConfigValue("favorite_paths", JSON.stringify(favorites));
|
|
696
653
|
res.json({ ok: true });
|
|
697
654
|
});
|
|
698
|
-
const MAX_RECENT_PATHS = 10;
|
|
699
655
|
app.get("/api/recent-paths", (_req, res) => {
|
|
700
656
|
const stored = storage.getConfigValue("recent_paths");
|
|
701
657
|
const recent = parseStoredPathList(stored);
|
|
@@ -714,21 +670,17 @@ self.addEventListener('fetch', (event) => {
|
|
|
714
670
|
}
|
|
715
671
|
const stored = storage.getConfigValue("recent_paths");
|
|
716
672
|
let recent = parseStoredPathList(stored);
|
|
717
|
-
// Remove existing entry for this path (to update position)
|
|
718
673
|
recent = recent.filter((r) => normalizeFolderPath(r.path) !== resolvedRecentPath);
|
|
719
|
-
// Add to front
|
|
720
674
|
const newRecent = {
|
|
721
675
|
path: resolvedRecentPath,
|
|
722
676
|
name: path.basename(resolvedRecentPath),
|
|
723
|
-
lastUsedAt: new Date().toISOString()
|
|
677
|
+
lastUsedAt: new Date().toISOString(),
|
|
724
678
|
};
|
|
725
679
|
recent.unshift(newRecent);
|
|
726
|
-
// Keep only last N entries
|
|
727
680
|
recent = recent.slice(0, MAX_RECENT_PATHS);
|
|
728
681
|
storage.setConfigValue("recent_paths", JSON.stringify(recent));
|
|
729
682
|
res.json(newRecent);
|
|
730
683
|
});
|
|
731
|
-
// ============ Path Validation API ============
|
|
732
684
|
app.get("/api/validate-path", async (req, res) => {
|
|
733
685
|
const inputPath = typeof req.query.path === "string" ? req.query.path : "";
|
|
734
686
|
if (!inputPath.trim()) {
|
|
@@ -741,17 +693,16 @@ self.addEventListener('fetch', (event) => {
|
|
|
741
693
|
res.json({ valid: false, error: "访问被拒绝:无法访问系统敏感目录。", resolvedPath });
|
|
742
694
|
return;
|
|
743
695
|
}
|
|
744
|
-
const stats = await import("node:fs/promises").then(fs => fs.stat(resolvedPath));
|
|
696
|
+
const stats = await import("node:fs/promises").then((fs) => fs.stat(resolvedPath));
|
|
745
697
|
if (!stats.isDirectory()) {
|
|
746
698
|
res.json({ valid: false, error: "路径不是目录", resolvedPath });
|
|
747
699
|
return;
|
|
748
700
|
}
|
|
749
|
-
// Check read permission
|
|
750
701
|
try {
|
|
751
702
|
await readdir(resolvedPath);
|
|
752
703
|
res.json({ valid: true, resolvedPath, name: path.basename(resolvedPath) });
|
|
753
704
|
}
|
|
754
|
-
catch
|
|
705
|
+
catch {
|
|
755
706
|
res.json({ valid: false, error: "没有读取权限", resolvedPath });
|
|
756
707
|
}
|
|
757
708
|
}
|
|
@@ -768,13 +719,11 @@ self.addEventListener('fetch', (event) => {
|
|
|
768
719
|
}
|
|
769
720
|
}
|
|
770
721
|
});
|
|
771
|
-
// File search API - supports fuzzy matching across directory tree
|
|
772
722
|
app.get("/api/file-search", async (req, res) => {
|
|
773
723
|
const query = typeof req.query.q === "string" ? req.query.q : "";
|
|
774
724
|
const cwd = typeof req.query.cwd === "string" ? req.query.cwd : process.cwd();
|
|
775
725
|
const maxDepth = typeof req.query.depth === "string" ? parseInt(req.query.depth, 10) : 5;
|
|
776
726
|
const maxResults = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50;
|
|
777
|
-
// Security check: ensure cwd is within allowed base
|
|
778
727
|
const allowedBase = process.cwd();
|
|
779
728
|
const resolvedCwd = path.resolve(allowedBase, cwd);
|
|
780
729
|
if (!isPathWithinBase(resolvedCwd, allowedBase)) {
|
|
@@ -788,7 +737,6 @@ self.addEventListener('fetch', (event) => {
|
|
|
788
737
|
try {
|
|
789
738
|
const results = [];
|
|
790
739
|
const queryLower = query.toLowerCase();
|
|
791
|
-
// Recursive search function
|
|
792
740
|
async function searchDir(dirPath, currentDepth) {
|
|
793
741
|
if (currentDepth > maxDepth || results.length >= maxResults)
|
|
794
742
|
return;
|
|
@@ -796,29 +744,25 @@ self.addEventListener('fetch', (event) => {
|
|
|
796
744
|
for (const entry of entries) {
|
|
797
745
|
if (results.length >= maxResults)
|
|
798
746
|
break;
|
|
799
|
-
// Skip hidden files and node_modules
|
|
800
747
|
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
801
748
|
continue;
|
|
802
749
|
const entryPath = path.join(dirPath, entry.name);
|
|
803
750
|
const nameLower = entry.name.toLowerCase();
|
|
804
|
-
// Check if name matches query (fuzzy match)
|
|
805
751
|
const matchIndex = nameLower.indexOf(queryLower);
|
|
806
752
|
if (matchIndex !== -1) {
|
|
807
753
|
results.push({
|
|
808
754
|
path: entryPath,
|
|
809
755
|
name: entry.name,
|
|
810
756
|
type: entry.isDirectory() ? "dir" : "file",
|
|
811
|
-
matchScore: matchIndex
|
|
757
|
+
matchScore: matchIndex,
|
|
812
758
|
});
|
|
813
759
|
}
|
|
814
|
-
// Recurse into directories
|
|
815
760
|
if (entry.isDirectory()) {
|
|
816
761
|
await searchDir(entryPath, currentDepth + 1);
|
|
817
762
|
}
|
|
818
763
|
}
|
|
819
764
|
}
|
|
820
765
|
await searchDir(resolvedCwd, 0);
|
|
821
|
-
// Sort by match score (earlier match = better) and then alphabetically
|
|
822
766
|
results.sort((a, b) => {
|
|
823
767
|
if (a.matchScore !== b.matchScore)
|
|
824
768
|
return a.matchScore - b.matchScore;
|
|
@@ -830,23 +774,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
830
774
|
res.status(400).json({ error: getErrorMessage(error, "搜索失败。可能原因:路径不存在或权限不足。") });
|
|
831
775
|
}
|
|
832
776
|
});
|
|
833
|
-
|
|
834
|
-
const snapshot = processes.get(req.params.id);
|
|
835
|
-
if (!snapshot) {
|
|
836
|
-
res.status(404).json({ error: "未找到该会话,可能已被删除。" });
|
|
837
|
-
return;
|
|
838
|
-
}
|
|
839
|
-
if (req.query.format === "chat") {
|
|
840
|
-
// Prefer PTY-derived structured messages, fall back to parsing raw output
|
|
841
|
-
const messages = snapshot.messages && snapshot.messages.length > 0
|
|
842
|
-
? snapshot.messages
|
|
843
|
-
: parseMessages(snapshot.output);
|
|
844
|
-
res.json({ ...snapshot, messages });
|
|
845
|
-
}
|
|
846
|
-
else {
|
|
847
|
-
res.json(snapshot);
|
|
848
|
-
}
|
|
849
|
-
});
|
|
777
|
+
// ── Session control ──
|
|
850
778
|
app.post("/api/commands", (req, res) => {
|
|
851
779
|
const body = req.body;
|
|
852
780
|
if (!body.command?.trim()) {
|
|
@@ -859,78 +787,101 @@ self.addEventListener('fetch', (event) => {
|
|
|
859
787
|
res.status(201).json(snapshot);
|
|
860
788
|
}
|
|
861
789
|
catch (error) {
|
|
862
|
-
res.status(400).json({ error: getErrorMessage(error, "
|
|
790
|
+
res.status(400).json({ error: getErrorMessage(error, "无法启动命令。请检查命令是否安装。") });
|
|
863
791
|
}
|
|
864
792
|
});
|
|
865
|
-
// Resume a session with a different mode (e.g., switch from terminal to chat)
|
|
866
793
|
app.post("/api/sessions/:id/resume", (req, res) => {
|
|
867
794
|
const sessionId = req.params.id;
|
|
868
795
|
const body = req.body;
|
|
869
796
|
try {
|
|
870
|
-
const existingSession = processes.get(sessionId);
|
|
797
|
+
const existingSession = processes.get(sessionId) || storage.getSession(sessionId);
|
|
871
798
|
if (!existingSession) {
|
|
872
799
|
res.status(404).json({ error: "会话不存在。" });
|
|
873
800
|
return;
|
|
874
801
|
}
|
|
875
|
-
if (existingSession.status !== "running") {
|
|
876
|
-
res.status(400).json({ error: "会话已结束,无法恢复。" });
|
|
877
|
-
return;
|
|
878
|
-
}
|
|
879
|
-
// Get the Claude session ID for resuming
|
|
880
802
|
const claudeSessionId = existingSession.claudeSessionId;
|
|
881
803
|
if (!claudeSessionId) {
|
|
882
804
|
res.status(400).json({ error: "此会话没有 Claude 会话 ID,无法恢复。" });
|
|
883
805
|
return;
|
|
884
806
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
// Build the resume command
|
|
888
|
-
const command = existingSession.command;
|
|
889
|
-
const isClaude = /^claude\b/.test(command);
|
|
890
|
-
if (!isClaude) {
|
|
807
|
+
const command = existingSession.command.trim();
|
|
808
|
+
if (!/^claude\b/.test(command)) {
|
|
891
809
|
res.status(400).json({ error: "只有 Claude 命令支持恢复功能。" });
|
|
892
810
|
return;
|
|
893
811
|
}
|
|
894
|
-
|
|
812
|
+
const newMode = body.mode
|
|
813
|
+
? normalizeMode(body.mode, config.defaultMode)
|
|
814
|
+
: normalizeMode(existingSession.mode, config.defaultMode);
|
|
895
815
|
const resumeCommand = `${command} --resume ${claudeSessionId}`;
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
res.status(201).json(snapshot);
|
|
816
|
+
const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { resumedFromSessionId: sessionId });
|
|
817
|
+
storage.saveSession({ ...existingSession, resumedToSessionId: newSnapshot.id });
|
|
818
|
+
res.status(201).json({ resumedFromSessionId: sessionId, ...newSnapshot });
|
|
900
819
|
}
|
|
901
820
|
catch (error) {
|
|
902
821
|
res.status(400).json({ error: getErrorMessage(error, "无法恢复会话。") });
|
|
903
822
|
}
|
|
904
823
|
});
|
|
824
|
+
app.post("/api/claude-sessions/:claudeSessionId/resume", (req, res) => {
|
|
825
|
+
const claudeSessionId = String(req.params.claudeSessionId || "").trim();
|
|
826
|
+
const body = req.body;
|
|
827
|
+
try {
|
|
828
|
+
if (!claudeSessionId) {
|
|
829
|
+
res.status(400).json({ error: "Claude 会话 ID 不能为空。" });
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const existingSession = storage.getLatestSessionByClaudeSessionId(claudeSessionId);
|
|
833
|
+
if (existingSession) {
|
|
834
|
+
const command = existingSession.command.trim();
|
|
835
|
+
if (!/^claude\b/.test(command)) {
|
|
836
|
+
res.status(400).json({ error: "只有 Claude 命令支持按 Claude Session ID 恢复。" });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (!existingSession.cwd || !processes.hasClaudeSessionFile(existingSession.cwd, claudeSessionId)) {
|
|
840
|
+
res.status(400).json({ error: "对应的 Claude 历史会话文件不存在,无法恢复。" });
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const newMode = body.mode
|
|
844
|
+
? normalizeMode(body.mode, config.defaultMode)
|
|
845
|
+
: normalizeMode(existingSession.mode, config.defaultMode);
|
|
846
|
+
const resumeCommand = `${command} --resume ${claudeSessionId}`;
|
|
847
|
+
const newSnapshot = processes.start(resumeCommand, existingSession.cwd, newMode, undefined, { resumedFromSessionId: existingSession.id });
|
|
848
|
+
storage.saveSession({ ...existingSession, resumedToSessionId: newSnapshot.id });
|
|
849
|
+
res.status(201).json({ resumedFromSessionId: existingSession.id, resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
|
|
850
|
+
}
|
|
851
|
+
else {
|
|
852
|
+
// No existing wand session — resume directly with cwd from request body
|
|
853
|
+
const cwd = body.cwd?.trim();
|
|
854
|
+
if (!cwd) {
|
|
855
|
+
res.status(400).json({ error: "未找到对应的会话记录,请提供工作目录 (cwd)。" });
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const newMode = normalizeMode(body.mode, config.defaultMode);
|
|
859
|
+
const resumeCommand = `claude --resume ${claudeSessionId}`;
|
|
860
|
+
const newSnapshot = processes.start(resumeCommand, cwd, newMode);
|
|
861
|
+
res.status(201).json({ resumedClaudeSessionId: claudeSessionId, ...newSnapshot });
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
res.status(400).json({ error: getErrorMessage(error, "无法按 Claude 会话 ID 恢复会话。") });
|
|
866
|
+
}
|
|
867
|
+
});
|
|
905
868
|
app.post("/api/sessions/:id/input", (req, res) => {
|
|
906
869
|
const body = req.body;
|
|
907
870
|
const sessionId = req.params.id;
|
|
908
871
|
const input = body.input ?? "";
|
|
909
872
|
const view = body.view;
|
|
910
|
-
console.error("[wand] Input request received", {
|
|
911
|
-
sessionId,
|
|
912
|
-
inputLength: input.length,
|
|
913
|
-
view: view ?? "chat"
|
|
914
|
-
});
|
|
873
|
+
console.error("[wand] Input request received", { sessionId, inputLength: input.length, view: view ?? "chat" });
|
|
915
874
|
try {
|
|
916
875
|
const snapshot = processes.sendInput(sessionId, input, view);
|
|
917
|
-
console.error("[wand] Input request succeeded", {
|
|
918
|
-
sessionId,
|
|
919
|
-
status: snapshot.status,
|
|
920
|
-
inputLength: input.length,
|
|
921
|
-
view: view ?? "chat"
|
|
922
|
-
});
|
|
876
|
+
console.error("[wand] Input request succeeded", { sessionId, status: snapshot.status, inputLength: input.length, view: view ?? "chat" });
|
|
923
877
|
res.json(snapshot);
|
|
924
878
|
}
|
|
925
879
|
catch (error) {
|
|
926
880
|
const response = getInputErrorResponse(error, sessionId);
|
|
927
881
|
console.error("[wand] Input request failed", {
|
|
928
|
-
sessionId,
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
responseStatus: response.statusCode,
|
|
932
|
-
responsePayload: response.payload,
|
|
933
|
-
error: getInputDebugMeta(error)
|
|
882
|
+
sessionId, inputLength: input.length, view: view ?? "chat",
|
|
883
|
+
responseStatus: response.statusCode, responsePayload: response.payload,
|
|
884
|
+
error: getInputDebugMeta(error),
|
|
934
885
|
});
|
|
935
886
|
res.status(response.statusCode).json(response.payload);
|
|
936
887
|
}
|
|
@@ -947,8 +898,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
947
898
|
});
|
|
948
899
|
app.post("/api/sessions/:id/approve-permission", (req, res) => {
|
|
949
900
|
try {
|
|
950
|
-
|
|
951
|
-
res.json(snapshot);
|
|
901
|
+
res.json(processes.approvePermission(req.params.id));
|
|
952
902
|
}
|
|
953
903
|
catch (error) {
|
|
954
904
|
res.status(400).json({ error: getErrorMessage(error, "无法批准该授权请求。") });
|
|
@@ -956,8 +906,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
956
906
|
});
|
|
957
907
|
app.post("/api/sessions/:id/deny-permission", (req, res) => {
|
|
958
908
|
try {
|
|
959
|
-
|
|
960
|
-
res.json(snapshot);
|
|
909
|
+
res.json(processes.denyPermission(req.params.id));
|
|
961
910
|
}
|
|
962
911
|
catch (error) {
|
|
963
912
|
res.status(400).json({ error: getErrorMessage(error, "无法拒绝该授权请求。") });
|
|
@@ -967,8 +916,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
967
916
|
try {
|
|
968
917
|
const { requestId } = req.params;
|
|
969
918
|
const body = req.body;
|
|
970
|
-
|
|
971
|
-
res.json(snapshot);
|
|
919
|
+
res.json(processes.resolveEscalation(req.params.id, requestId, body.resolution));
|
|
972
920
|
}
|
|
973
921
|
catch (error) {
|
|
974
922
|
res.status(400).json({ error: getErrorMessage(error, "无法处理该授权请求。") });
|
|
@@ -976,8 +924,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
976
924
|
});
|
|
977
925
|
app.post("/api/sessions/:id/stop", (req, res) => {
|
|
978
926
|
try {
|
|
979
|
-
|
|
980
|
-
res.json(snapshot);
|
|
927
|
+
res.json(processes.stop(req.params.id));
|
|
981
928
|
}
|
|
982
929
|
catch (error) {
|
|
983
930
|
res.status(400).json({ error: getErrorMessage(error, "无法停止会话。") });
|
|
@@ -992,8 +939,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
992
939
|
res.status(400).json({ error: getErrorMessage(error, "无法删除会话。") });
|
|
993
940
|
}
|
|
994
941
|
});
|
|
995
|
-
|
|
996
|
-
// Create server (HTTP or HTTPS) - useHttps and protocol already defined above
|
|
942
|
+
// ── WebSocket broadcast layer ──
|
|
997
943
|
const server = useHttps
|
|
998
944
|
? (() => {
|
|
999
945
|
const ssl = ensureCertificates(resolveConfigDir(configPath));
|
|
@@ -1001,129 +947,13 @@ self.addEventListener('fetch', (event) => {
|
|
|
1001
947
|
})()
|
|
1002
948
|
: createHttpServer(app);
|
|
1003
949
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
// Output debounce cache - batch rapid output events per session
|
|
1008
|
-
const outputDebounceCache = new Map();
|
|
1009
|
-
// Process send queue for a WebSocket client
|
|
1010
|
-
function processWsQueue(client) {
|
|
1011
|
-
if (client.sendInProgress || client.sendQueue.length === 0 || client.backpressurePaused) {
|
|
1012
|
-
return;
|
|
1013
|
-
}
|
|
1014
|
-
client.sendInProgress = true;
|
|
1015
|
-
const message = client.sendQueue.shift();
|
|
1016
|
-
if (client.ws.readyState === WebSocket.OPEN) {
|
|
1017
|
-
client.ws.send(message, (err) => {
|
|
1018
|
-
client.sendInProgress = false;
|
|
1019
|
-
if (err) {
|
|
1020
|
-
// Error sending, drop message
|
|
1021
|
-
return;
|
|
1022
|
-
}
|
|
1023
|
-
// Check backpressure threshold
|
|
1024
|
-
const threshold = MAX_QUEUE_SIZE * 0.8;
|
|
1025
|
-
if (client.backpressurePaused && client.sendQueue.length < threshold) {
|
|
1026
|
-
client.backpressurePaused = false;
|
|
1027
|
-
}
|
|
1028
|
-
// Continue processing queue
|
|
1029
|
-
processWsQueue(client);
|
|
1030
|
-
});
|
|
1031
|
-
}
|
|
1032
|
-
else {
|
|
1033
|
-
client.sendInProgress = false;
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
// Broadcast process events to WebSocket clients with debouncing and backpressure control
|
|
950
|
+
const wsManager = new WsBroadcastManager(wss);
|
|
951
|
+
wsManager.setup((id) => processes.get(id));
|
|
952
|
+
// Wire process events to WebSocket broadcast
|
|
1037
953
|
processes.on("process", (event) => {
|
|
1038
|
-
|
|
1039
|
-
if (event.type === "output") {
|
|
1040
|
-
const existing = outputDebounceCache.get(event.sessionId);
|
|
1041
|
-
if (existing) {
|
|
1042
|
-
clearTimeout(existing.timer);
|
|
1043
|
-
}
|
|
1044
|
-
const timer = setTimeout(() => {
|
|
1045
|
-
outputDebounceCache.delete(event.sessionId);
|
|
1046
|
-
broadcastEvent(event);
|
|
1047
|
-
}, OUTPUT_DEBOUNCE_MS);
|
|
1048
|
-
outputDebounceCache.set(event.sessionId, { event, timer });
|
|
1049
|
-
return;
|
|
1050
|
-
}
|
|
1051
|
-
// Non-output events (started, ended, status) are sent immediately
|
|
1052
|
-
broadcastEvent(event);
|
|
954
|
+
wsManager.emitEvent(event);
|
|
1053
955
|
});
|
|
1054
|
-
|
|
1055
|
-
const message = JSON.stringify(event);
|
|
1056
|
-
for (const client of wsClients) {
|
|
1057
|
-
if (client.ws.readyState === WebSocket.OPEN) {
|
|
1058
|
-
// Apply backpressure if queue is too large
|
|
1059
|
-
if (client.sendQueue.length >= MAX_QUEUE_SIZE) {
|
|
1060
|
-
client.backpressurePaused = true;
|
|
1061
|
-
continue;
|
|
1062
|
-
}
|
|
1063
|
-
if (!client.backpressurePaused) {
|
|
1064
|
-
client.sendQueue.push(message);
|
|
1065
|
-
processWsQueue(client);
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
wss.on("connection", (ws, req) => {
|
|
1071
|
-
const sessionToken = readSessionCookie(req);
|
|
1072
|
-
if (!sessionToken || !validateSession(sessionToken)) {
|
|
1073
|
-
ws.close(1008, "Unauthorized");
|
|
1074
|
-
return;
|
|
1075
|
-
}
|
|
1076
|
-
const client = {
|
|
1077
|
-
ws,
|
|
1078
|
-
sendQueue: [],
|
|
1079
|
-
sendInProgress: false,
|
|
1080
|
-
backpressurePaused: false,
|
|
1081
|
-
lastOutputBySession: new Map()
|
|
1082
|
-
};
|
|
1083
|
-
wsClients.add(client);
|
|
1084
|
-
ws.on("close", () => {
|
|
1085
|
-
wsClients.delete(client);
|
|
1086
|
-
});
|
|
1087
|
-
ws.on("error", () => {
|
|
1088
|
-
// Already closed, ignore
|
|
1089
|
-
});
|
|
1090
|
-
ws.on("message", (data) => {
|
|
1091
|
-
try {
|
|
1092
|
-
const msg = JSON.parse(data.toString());
|
|
1093
|
-
// Handle subscribe/unsubscribe for specific sessions
|
|
1094
|
-
if (msg.type === "subscribe" && msg.sessionId) {
|
|
1095
|
-
// Client wants updates for a specific session
|
|
1096
|
-
const snapshot = processes.get(msg.sessionId);
|
|
1097
|
-
if (snapshot) {
|
|
1098
|
-
// Send full session snapshot including messages for reconnection recovery
|
|
1099
|
-
ws.send(JSON.stringify({
|
|
1100
|
-
type: "init",
|
|
1101
|
-
sessionId: msg.sessionId,
|
|
1102
|
-
data: {
|
|
1103
|
-
...snapshot,
|
|
1104
|
-
// Ensure messages are included for chat mode recovery
|
|
1105
|
-
messages: snapshot.messages,
|
|
1106
|
-
// Include full output for terminal mode recovery
|
|
1107
|
-
output: snapshot.output
|
|
1108
|
-
}
|
|
1109
|
-
}));
|
|
1110
|
-
}
|
|
1111
|
-
else {
|
|
1112
|
-
// Session not found - might be deleted or never existed
|
|
1113
|
-
ws.send(JSON.stringify({
|
|
1114
|
-
type: "error",
|
|
1115
|
-
sessionId: msg.sessionId,
|
|
1116
|
-
error: "Session not found"
|
|
1117
|
-
}));
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
catch {
|
|
1122
|
-
// Ignore malformed messages
|
|
1123
|
-
}
|
|
1124
|
-
});
|
|
1125
|
-
});
|
|
1126
|
-
// Start server
|
|
956
|
+
// ── Start listening ──
|
|
1127
957
|
await new Promise((resolve, reject) => {
|
|
1128
958
|
server.listen(config.port, config.host, () => {
|
|
1129
959
|
const listenAddr = config.host === "0.0.0.0" ? "0.0.0.0 (所有接口)" : config.host;
|
|
@@ -1139,52 +969,9 @@ self.addEventListener('fetch', (event) => {
|
|
|
1139
969
|
reject(err);
|
|
1140
970
|
});
|
|
1141
971
|
});
|
|
1142
|
-
// Print security warnings
|
|
1143
972
|
if (!storage.hasCustomPassword() && config.password === "change-me") {
|
|
1144
973
|
wandWarn("正在使用默认密码(change-me),任何能访问本机的人都可以登录。", "修改方法:在界面右上角「设置」中修改密码,或运行:node dist/cli.js config:set password <你的新密码>");
|
|
1145
974
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
if (!validateSession(readSessionCookie(req))) {
|
|
1149
|
-
res.status(401).json({ error: "未授权,请先登录。" });
|
|
1150
|
-
return;
|
|
1151
|
-
}
|
|
1152
|
-
next();
|
|
1153
|
-
}
|
|
1154
|
-
function normalizeMode(input, fallback) {
|
|
1155
|
-
return isExecutionMode(input) ? input : fallback;
|
|
1156
|
-
}
|
|
1157
|
-
function readSessionCookie(req) {
|
|
1158
|
-
const cookie = req.headers.cookie;
|
|
1159
|
-
if (!cookie) {
|
|
1160
|
-
return undefined;
|
|
1161
|
-
}
|
|
1162
|
-
const match = cookie
|
|
1163
|
-
.split(";")
|
|
1164
|
-
.map((part) => part.trim())
|
|
1165
|
-
.find((part) => part.startsWith("wand_session="));
|
|
1166
|
-
return match?.slice("wand_session=".length);
|
|
1167
|
-
}
|
|
1168
|
-
async function listPathSuggestions(input, fallbackCwd) {
|
|
1169
|
-
const normalizedInput = input.trim();
|
|
1170
|
-
const baseInput = normalizedInput || fallbackCwd;
|
|
1171
|
-
const resolvedInput = path.resolve(process.cwd(), baseInput);
|
|
1172
|
-
const endsWithSeparator = /[\\/]$/.test(normalizedInput);
|
|
1173
|
-
let searchDir = resolvedInput;
|
|
1174
|
-
let partialName = "";
|
|
1175
|
-
if (!endsWithSeparator) {
|
|
1176
|
-
searchDir = path.dirname(resolvedInput);
|
|
1177
|
-
partialName = path.basename(resolvedInput);
|
|
1178
|
-
}
|
|
1179
|
-
const entries = await readdir(searchDir, { withFileTypes: true });
|
|
1180
|
-
return entries
|
|
1181
|
-
.filter((entry) => entry.isDirectory())
|
|
1182
|
-
.filter((entry) => !partialName || entry.name.toLowerCase().startsWith(partialName.toLowerCase()))
|
|
1183
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
1184
|
-
.slice(0, 8)
|
|
1185
|
-
.map((entry) => ({
|
|
1186
|
-
path: path.join(searchDir, entry.name),
|
|
1187
|
-
name: entry.name,
|
|
1188
|
-
isDirectory: true
|
|
1189
|
-
}));
|
|
975
|
+
// Start configured background sessions after the server is already reachable.
|
|
976
|
+
processes.runStartupCommands();
|
|
1190
977
|
}
|