@co0ontty/wand 0.2.1 → 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 +25 -5
- package/dist/acp-protocol.d.ts +67 -0
- package/dist/acp-protocol.js +291 -0
- package/dist/avatar.d.ts +14 -0
- package/dist/avatar.js +110 -0
- package/dist/claude-pty-bridge.d.ts +137 -0
- package/dist/claude-pty-bridge.js +619 -0
- package/dist/claude-stream-adapter.d.ts +35 -0
- package/dist/claude-stream-adapter.js +153 -0
- package/dist/claude-structured-runner.d.ts +27 -0
- package/dist/claude-structured-runner.js +106 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +10 -2
- package/dist/config.js +8 -4
- package/dist/message-parser.js +16 -150
- package/dist/message-queue.d.ts +57 -0
- package/dist/message-queue.js +127 -0
- 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 +82 -27
- package/dist/process-manager.js +1445 -822
- 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 +511 -409
- package/dist/session-lifecycle.d.ts +81 -0
- package/dist/session-lifecycle.js +181 -0
- 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 +73 -7
- package/dist/types.d.ts +112 -6
- 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 +3770 -852
- package/dist/web-ui/content/styles.css +5505 -2779
- package/dist/web-ui/index.js +8 -5
- package/dist/web-ui/scripts.js +8 -1
- package/dist/ws-broadcast.d.ts +27 -0
- package/dist/ws-broadcast.js +160 -0
- package/package.json +2 -9
- package/dist/web-ui/utils.d.ts +0 -4
- package/dist/web-ui/utils.js +0 -12
- package/dist/web-ui.d.ts +0 -1
- package/dist/web-ui.js +0 -2
package/dist/server.js
CHANGED
|
@@ -1,26 +1,62 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import { readdir } from "node:fs/promises";
|
|
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";
|
|
14
|
-
import { ProcessManager } from "./process-manager.js";
|
|
18
|
+
import { ProcessManager, SessionInputError } from "./process-manager.js";
|
|
15
19
|
import { resolveDatabasePath, WandStorage } from "./storage.js";
|
|
16
|
-
import { renderApp } from "./web-ui.js";
|
|
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
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
function getInputErrorResponse(error, sessionId) {
|
|
31
|
+
if (error instanceof SessionInputError) {
|
|
32
|
+
const statusCode = error.code === "SESSION_NOT_FOUND" ? 404 : 409;
|
|
33
|
+
return {
|
|
34
|
+
statusCode,
|
|
35
|
+
payload: {
|
|
36
|
+
error: error.message,
|
|
37
|
+
errorCode: error.code,
|
|
38
|
+
sessionId,
|
|
39
|
+
sessionStatus: error.sessionStatus ?? null,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
statusCode: 400,
|
|
45
|
+
payload: {
|
|
46
|
+
error: getErrorMessage(error, "会话已结束,请启动新会话。"),
|
|
47
|
+
errorCode: "INPUT_SEND_FAILED",
|
|
48
|
+
sessionId,
|
|
49
|
+
sessionStatus: null,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function getInputDebugMeta(error) {
|
|
54
|
+
if (error instanceof Error) {
|
|
55
|
+
return { name: error.name, message: error.message, stack: error.stack };
|
|
56
|
+
}
|
|
57
|
+
return { error };
|
|
58
|
+
}
|
|
59
|
+
// ── Git helpers ──
|
|
24
60
|
async function isGitRepo(dirPath) {
|
|
25
61
|
try {
|
|
26
62
|
await execAsync("git rev-parse --is-inside-work-tree", { cwd: dirPath });
|
|
@@ -30,9 +66,6 @@ async function isGitRepo(dirPath) {
|
|
|
30
66
|
return false;
|
|
31
67
|
}
|
|
32
68
|
}
|
|
33
|
-
/**
|
|
34
|
-
* Get the git repository root directory
|
|
35
|
-
*/
|
|
36
69
|
async function getGitRepoRoot(dirPath) {
|
|
37
70
|
try {
|
|
38
71
|
const { stdout } = await execAsync("git rev-parse --show-toplevel", { cwd: dirPath });
|
|
@@ -42,20 +75,12 @@ async function getGitRepoRoot(dirPath) {
|
|
|
42
75
|
return null;
|
|
43
76
|
}
|
|
44
77
|
}
|
|
45
|
-
/**
|
|
46
|
-
* Get git status for all files in a directory
|
|
47
|
-
* Returns a map of relative file paths to their git status
|
|
48
|
-
*/
|
|
49
78
|
async function getGitStatusMap(gitRoot) {
|
|
50
79
|
const statusMap = new Map();
|
|
51
80
|
try {
|
|
52
|
-
// Get git status in porcelain format (stable for parsing)
|
|
53
|
-
// -uno: don't list untracked files (we'll get them separately)
|
|
54
81
|
const { stdout: stagedStdout } = await execAsync("git status --porcelain -uno", { cwd: gitRoot });
|
|
55
|
-
// Get untracked files separately
|
|
56
82
|
const { stdout: untrackedStdout } = await execAsync("git ls-files --others --exclude-standard", { cwd: gitRoot });
|
|
57
|
-
|
|
58
|
-
const lines = stagedStdout.split("\n").filter(line => line.trim());
|
|
83
|
+
const lines = stagedStdout.split("\n").filter((line) => line.trim());
|
|
59
84
|
for (const line of lines) {
|
|
60
85
|
if (line.length < 4)
|
|
61
86
|
continue;
|
|
@@ -65,7 +90,6 @@ async function getGitStatusMap(gitRoot) {
|
|
|
65
90
|
if (!filePath)
|
|
66
91
|
continue;
|
|
67
92
|
const status = {};
|
|
68
|
-
// Parse staged status
|
|
69
93
|
if (stagedChar === "M")
|
|
70
94
|
status.staged = "modified";
|
|
71
95
|
else if (stagedChar === "A")
|
|
@@ -74,15 +98,13 @@ async function getGitStatusMap(gitRoot) {
|
|
|
74
98
|
status.staged = "deleted";
|
|
75
99
|
else if (stagedChar === "R")
|
|
76
100
|
status.staged = "renamed";
|
|
77
|
-
// Parse unstaged status
|
|
78
101
|
if (unstagedChar === "M")
|
|
79
102
|
status.unstaged = "modified";
|
|
80
103
|
else if (unstagedChar === "D")
|
|
81
104
|
status.unstaged = "deleted";
|
|
82
105
|
statusMap.set(filePath, status);
|
|
83
106
|
}
|
|
84
|
-
|
|
85
|
-
const untrackedFiles = untrackedStdout.split("\n").filter(line => line.trim());
|
|
107
|
+
const untrackedFiles = untrackedStdout.split("\n").filter((line) => line.trim());
|
|
86
108
|
for (const filePath of untrackedFiles) {
|
|
87
109
|
const existing = statusMap.get(filePath);
|
|
88
110
|
if (existing) {
|
|
@@ -94,72 +116,69 @@ async function getGitStatusMap(gitRoot) {
|
|
|
94
116
|
}
|
|
95
117
|
return statusMap;
|
|
96
118
|
}
|
|
97
|
-
catch
|
|
98
|
-
// Git command failed, return empty map
|
|
119
|
+
catch {
|
|
99
120
|
return statusMap;
|
|
100
121
|
}
|
|
101
122
|
}
|
|
102
|
-
/**
|
|
103
|
-
* Enrich file entries with git status
|
|
104
|
-
*/
|
|
105
123
|
async function enrichWithGitStatus(items, dirPath) {
|
|
106
124
|
try {
|
|
107
125
|
const gitRoot = await getGitRepoRoot(dirPath);
|
|
108
|
-
if (!gitRoot)
|
|
126
|
+
if (!gitRoot)
|
|
109
127
|
return items;
|
|
110
|
-
}
|
|
111
128
|
const gitStatusMap = await getGitStatusMap(gitRoot);
|
|
112
129
|
return items.map((item) => {
|
|
113
|
-
// Get path relative to git root
|
|
114
130
|
const relativePath = path.relative(gitRoot, item.path);
|
|
115
|
-
|
|
116
|
-
const normalizedPath = relativePath.replace(/\\/g, '/');
|
|
131
|
+
const normalizedPath = relativePath.replace(/\\/g, "/");
|
|
117
132
|
const gitStatus = gitStatusMap.get(normalizedPath);
|
|
118
|
-
return {
|
|
119
|
-
...item,
|
|
120
|
-
gitStatus: gitStatus || undefined
|
|
121
|
-
};
|
|
133
|
+
return { ...item, gitStatus: gitStatus || undefined };
|
|
122
134
|
});
|
|
123
135
|
}
|
|
124
136
|
catch {
|
|
125
137
|
return items;
|
|
126
138
|
}
|
|
127
139
|
}
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
function checkRateLimit(ip) {
|
|
133
|
-
const now = Date.now();
|
|
134
|
-
const record = loginAttempts.get(ip);
|
|
135
|
-
if (!record || now > record.resetAt) {
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
return record.count < RATE_LIMIT_MAX;
|
|
139
|
-
}
|
|
140
|
-
function recordFailedLogin(ip) {
|
|
141
|
-
const now = Date.now();
|
|
142
|
-
const record = loginAttempts.get(ip);
|
|
143
|
-
if (!record || now > record.resetAt) {
|
|
144
|
-
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: "未授权,请先登录。" });
|
|
145
144
|
return;
|
|
146
145
|
}
|
|
147
|
-
|
|
146
|
+
next();
|
|
148
147
|
}
|
|
149
|
-
function
|
|
150
|
-
|
|
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);
|
|
151
154
|
}
|
|
152
|
-
function
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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);
|
|
158
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
|
+
}));
|
|
159
180
|
}
|
|
160
|
-
//
|
|
161
|
-
setInterval(cleanupRateLimiter, 5 * 60 * 1000);
|
|
162
|
-
// Catch-all for unexpected startup errors
|
|
181
|
+
// ── Startup error handling ──
|
|
163
182
|
process.on("uncaughtException", (err) => {
|
|
164
183
|
wandError("服务器异常", err.message, "请检查配置是否正确,或尝试重启服务。");
|
|
165
184
|
process.exit(1);
|
|
@@ -168,7 +187,6 @@ process.on("unhandledRejection", (reason) => {
|
|
|
168
187
|
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
169
188
|
wandError("未处理的异步错误", msg);
|
|
170
189
|
});
|
|
171
|
-
// ── Friendly error / warn / info helpers ──────────────────────────────────
|
|
172
190
|
function wandError(label, message, suggestion) {
|
|
173
191
|
process.stderr.write(`\n✗ [wand] ${label}:${message}\n`);
|
|
174
192
|
if (suggestion)
|
|
@@ -180,97 +198,96 @@ function wandWarn(message, hint) {
|
|
|
180
198
|
if (hint)
|
|
181
199
|
process.stderr.write(` 提示:${hint}\n`);
|
|
182
200
|
}
|
|
183
|
-
function
|
|
184
|
-
|
|
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)));
|
|
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";
|
|
185
245
|
}
|
|
246
|
+
// ── Main server ──
|
|
186
247
|
export async function startServer(config, configPath) {
|
|
187
248
|
const app = express();
|
|
188
249
|
const storage = new WandStorage(resolveDatabasePath(configPath));
|
|
189
250
|
setAuthStorage(storage);
|
|
190
|
-
const
|
|
191
|
-
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;
|
|
192
255
|
const protocol = useHttps ? "https" : "http";
|
|
256
|
+
const nodeModulesDir = path.join(RUNTIME_ROOT_DIR, "node_modules");
|
|
193
257
|
app.use(express.json({ limit: "1mb" }));
|
|
194
|
-
app.use("/vendor/xterm", express.static(path.
|
|
195
|
-
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 ──
|
|
196
261
|
app.get("/", (_req, res) => {
|
|
262
|
+
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
197
263
|
res.type("html").send(renderApp(configPath));
|
|
198
264
|
});
|
|
199
|
-
// PWA manifest
|
|
200
265
|
app.get("/manifest.json", (_req, res) => {
|
|
201
|
-
res.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
icons: [
|
|
211
|
-
{ src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any maskable" },
|
|
212
|
-
{ src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any maskable" }
|
|
213
|
-
],
|
|
214
|
-
categories: ["developer tools", "productivity"],
|
|
215
|
-
shortcuts: [
|
|
216
|
-
{ name: "New Session", short_name: "New", url: "/?action=new", description: "Start a new CLI session" }
|
|
217
|
-
]
|
|
218
|
-
}));
|
|
219
|
-
});
|
|
220
|
-
// PWA icons (SVG data URL converted to simple PNG-like response)
|
|
221
|
-
const iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
|
222
|
-
<defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
223
|
-
<stop offset="0%" style="stop-color:#d77a52"/>
|
|
224
|
-
<stop offset="100%" style="stop-color:#a95130"/>
|
|
225
|
-
</linearGradient></defs>
|
|
226
|
-
<rect width="192" height="192" rx="38" fill="url(#g)"/>
|
|
227
|
-
<text x="96" y="128" text-anchor="middle" font-family="system-ui,sans-serif" font-size="88" font-weight="700" fill="white">W</text>
|
|
228
|
-
</svg>`;
|
|
266
|
+
res.setHeader("Content-Type", "application/manifest+json");
|
|
267
|
+
res.send(generatePwaManifest());
|
|
268
|
+
});
|
|
269
|
+
app.get("/icon.svg", (_req, res) => {
|
|
270
|
+
res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 192));
|
|
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"));
|
|
229
275
|
app.get("/icon-192.png", (_req, res) => {
|
|
230
|
-
res.type("svg").send(
|
|
276
|
+
res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 192));
|
|
231
277
|
});
|
|
232
278
|
app.get("/icon-512.png", (_req, res) => {
|
|
233
|
-
res.type("svg").send(
|
|
279
|
+
res.type("image/svg+xml").send(getAvatarSvg(avatarSeed, 512));
|
|
234
280
|
});
|
|
235
|
-
// Service Worker for offline support
|
|
236
281
|
app.get("/sw.js", (_req, res) => {
|
|
237
|
-
res.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
'/vendor/xterm/css/xterm.css',
|
|
242
|
-
'/vendor/xterm/lib/xterm.js',
|
|
243
|
-
'/vendor/xterm-addon-fit/lib/addon-fit.js'
|
|
244
|
-
];
|
|
245
|
-
|
|
246
|
-
self.addEventListener('install', (event) => {
|
|
247
|
-
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)));
|
|
248
|
-
self.skipWaiting();
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
self.addEventListener('activate', (event) => {
|
|
252
|
-
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))));
|
|
253
|
-
self.clients.claim();
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
self.addEventListener('fetch', (event) => {
|
|
257
|
-
const url = new URL(event.request.url);
|
|
258
|
-
// API calls should always go to network
|
|
259
|
-
if (url.pathname.startsWith('/api/')) {
|
|
260
|
-
event.respondWith(fetch(event.request).catch(() => new Response(JSON.stringify({ error: 'Offline' }), { status: 503, headers: { 'Content-Type': 'application/json' } })));
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
// Static assets: cache first, network fallback
|
|
264
|
-
event.respondWith(caches.match(event.request).then((cached) => cached || fetch(event.request).then((response) => {
|
|
265
|
-
if (response.ok && event.request.method === 'GET') {
|
|
266
|
-
const clone = response.clone();
|
|
267
|
-
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
|
268
|
-
}
|
|
269
|
-
return response;
|
|
270
|
-
}).catch(() => caches.match('/'))));
|
|
271
|
-
});
|
|
272
|
-
`);
|
|
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());
|
|
273
286
|
});
|
|
287
|
+
app.get("/offline", (_req, res) => {
|
|
288
|
+
res.type("html").send(renderApp(configPath));
|
|
289
|
+
});
|
|
290
|
+
// ── Auth routes ──
|
|
274
291
|
app.post("/api/login", (req, res) => {
|
|
275
292
|
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
|
|
276
293
|
if (!checkRateLimit(clientIp)) {
|
|
@@ -278,7 +295,6 @@ self.addEventListener('fetch', (event) => {
|
|
|
278
295
|
return;
|
|
279
296
|
}
|
|
280
297
|
const { password } = req.body;
|
|
281
|
-
// Check password: prefer database password, fallback to config password
|
|
282
298
|
const dbPassword = storage.getPassword();
|
|
283
299
|
const effectivePassword = dbPassword ?? config.password;
|
|
284
300
|
if (password !== effectivePassword) {
|
|
@@ -292,7 +308,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
292
308
|
httpOnly: true,
|
|
293
309
|
sameSite: "strict",
|
|
294
310
|
secure: useHttps,
|
|
295
|
-
maxAge: 1000 * 60 * 60 * 12
|
|
311
|
+
maxAge: 1000 * 60 * 60 * 12,
|
|
296
312
|
});
|
|
297
313
|
res.json({ ok: true });
|
|
298
314
|
});
|
|
@@ -301,7 +317,6 @@ self.addEventListener('fetch', (event) => {
|
|
|
301
317
|
res.clearCookie("wand_session");
|
|
302
318
|
res.json({ ok: true });
|
|
303
319
|
});
|
|
304
|
-
// Set password endpoint (requires auth)
|
|
305
320
|
app.post("/api/set-password", requireAuth, (req, res) => {
|
|
306
321
|
const { password } = req.body;
|
|
307
322
|
if (!password || password.length < 6) {
|
|
@@ -312,18 +327,143 @@ self.addEventListener('fetch', (event) => {
|
|
|
312
327
|
res.json({ ok: true });
|
|
313
328
|
});
|
|
314
329
|
app.use("/api", requireAuth);
|
|
330
|
+
// ── Config & Session info ──
|
|
315
331
|
app.get("/api/config", (_req, res) => {
|
|
316
332
|
res.json({
|
|
317
333
|
host: config.host,
|
|
318
334
|
port: config.port,
|
|
319
335
|
defaultMode: config.defaultMode,
|
|
320
336
|
defaultCwd: config.defaultCwd,
|
|
321
|
-
commandPresets: config.commandPresets
|
|
337
|
+
commandPresets: config.commandPresets,
|
|
322
338
|
});
|
|
323
339
|
});
|
|
324
340
|
app.get("/api/sessions", (_req, res) => {
|
|
325
341
|
res.json(processes.list());
|
|
326
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 ──
|
|
327
467
|
app.get("/api/path-suggestions", async (req, res) => {
|
|
328
468
|
const query = typeof req.query.q === "string" ? req.query.q : "";
|
|
329
469
|
try {
|
|
@@ -334,13 +474,13 @@ self.addEventListener('fetch', (event) => {
|
|
|
334
474
|
res.status(400).json({ error: getErrorMessage(error, "无法加载路径建议。") });
|
|
335
475
|
}
|
|
336
476
|
});
|
|
477
|
+
// ── File browsing ──
|
|
337
478
|
app.get("/api/directory", async (req, res) => {
|
|
338
479
|
const q = typeof req.query.q === "string" ? req.query.q : "";
|
|
339
480
|
const includeGitStatus = req.query.gitStatus === "true";
|
|
340
481
|
const targetPath = path.resolve(process.cwd(), q);
|
|
341
|
-
// Security check: ensure the resolved path is within the current working directory
|
|
342
482
|
const allowedBase = process.cwd();
|
|
343
|
-
if (!targetPath
|
|
483
|
+
if (!isPathWithinBase(targetPath, allowedBase)) {
|
|
344
484
|
res.status(403).json({ error: "访问被拒绝:路径必须在项目目录内。" });
|
|
345
485
|
return;
|
|
346
486
|
}
|
|
@@ -348,7 +488,6 @@ self.addEventListener('fetch', (event) => {
|
|
|
348
488
|
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
349
489
|
let items = entries
|
|
350
490
|
.sort((a, b) => {
|
|
351
|
-
// Directories first, then alphabetically
|
|
352
491
|
if (a.isDirectory() && !b.isDirectory())
|
|
353
492
|
return -1;
|
|
354
493
|
if (!a.isDirectory() && b.isDirectory())
|
|
@@ -359,9 +498,8 @@ self.addEventListener('fetch', (event) => {
|
|
|
359
498
|
.map((entry) => ({
|
|
360
499
|
path: path.join(targetPath, entry.name),
|
|
361
500
|
name: entry.name,
|
|
362
|
-
type: entry.isDirectory() ? "dir" : "file"
|
|
501
|
+
type: entry.isDirectory() ? "dir" : "file",
|
|
363
502
|
}));
|
|
364
|
-
// Enrich with git status if requested
|
|
365
503
|
if (includeGitStatus) {
|
|
366
504
|
items = await enrichWithGitStatus(items, targetPath);
|
|
367
505
|
}
|
|
@@ -371,53 +509,84 @@ self.addEventListener('fetch', (event) => {
|
|
|
371
509
|
res.status(400).json({ error: getErrorMessage(error, "无法读取目录。可能原因:路径不存在或权限不足。") });
|
|
372
510
|
}
|
|
373
511
|
});
|
|
374
|
-
|
|
375
|
-
app.get("/api/
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
512
|
+
const MAX_FILE_SIZE = 512 * 1024;
|
|
513
|
+
app.get("/api/file-preview", async (req, res) => {
|
|
514
|
+
const filePath = typeof req.query.path === "string" ? req.query.path : "";
|
|
515
|
+
if (!filePath) {
|
|
516
|
+
res.status(400).json({ error: "Missing path parameter" });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const resolvedPath = path.resolve(filePath);
|
|
520
|
+
const allowedBase = process.cwd();
|
|
521
|
+
if (!isPathWithinBase(resolvedPath, allowedBase)) {
|
|
522
|
+
res.status(403).json({ error: "Access denied" });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
const fileStat = await stat(resolvedPath);
|
|
527
|
+
if (fileStat.isDirectory()) {
|
|
528
|
+
res.status(400).json({ error: "Cannot preview a directory" });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (fileStat.size > MAX_FILE_SIZE) {
|
|
532
|
+
res.status(413).json({ error: "File too large", truncated: true, size: fileStat.size, maxSize: MAX_FILE_SIZE });
|
|
383
533
|
return;
|
|
384
534
|
}
|
|
535
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
536
|
+
const previewableExts = [
|
|
537
|
+
".md", ".markdown", ".mdown", ".mkd", ".mkdn",
|
|
538
|
+
".ts", ".tsx", ".js", ".jsx", ".json", ".html", ".css", ".scss", ".less",
|
|
539
|
+
".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
|
|
540
|
+
".cs", ".swift", ".kt", ".scala", ".php", ".sh", ".bash", ".zsh",
|
|
541
|
+
".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
|
|
542
|
+
".xml", ".sql", ".graphql", ".proto",
|
|
543
|
+
".dockerfile", ".gitignore", ".env", ".editorconfig",
|
|
544
|
+
".mdx", ".vue", ".svelte",
|
|
545
|
+
".txt", ".log", ".diff", ".patch",
|
|
546
|
+
];
|
|
547
|
+
const isText = previewableExts.includes(ext) ||
|
|
548
|
+
ext === "" ||
|
|
549
|
+
[".gitignore", "dockerfile", ".env.local", ".env.development"].some((e) => filePath.toLowerCase().endsWith(e));
|
|
550
|
+
if (!isText) {
|
|
551
|
+
res.status(415).json({ error: "Unsupported file type", ext });
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const content = await readFile(resolvedPath, "utf-8");
|
|
555
|
+
const lang = getLanguageFromExt(ext, filePath);
|
|
556
|
+
res.json({ path: resolvedPath, name: path.basename(filePath), ext, lang, content, size: fileStat.size });
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
app.get("/api/folders", async (req, res) => {
|
|
563
|
+
const q = typeof req.query.q === "string" ? req.query.q : "/tmp";
|
|
564
|
+
const targetPath = normalizeFolderPath(q);
|
|
565
|
+
if (isBlockedFolderPath(targetPath)) {
|
|
566
|
+
res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
|
|
567
|
+
return;
|
|
385
568
|
}
|
|
386
569
|
try {
|
|
387
570
|
const entries = await readdir(targetPath, { withFileTypes: true });
|
|
388
571
|
const items = [];
|
|
389
|
-
// Add parent directory navigation (..)
|
|
390
572
|
const parentPath = path.dirname(targetPath);
|
|
391
573
|
if (parentPath !== targetPath) {
|
|
392
|
-
items.push({
|
|
393
|
-
path: parentPath,
|
|
394
|
-
name: "..",
|
|
395
|
-
type: "parent",
|
|
396
|
-
isParent: true
|
|
397
|
-
});
|
|
574
|
+
items.push({ path: parentPath, name: "..", type: "parent", isParent: true });
|
|
398
575
|
}
|
|
399
|
-
// Add subdirectories
|
|
400
576
|
entries
|
|
401
577
|
.filter((entry) => entry.isDirectory())
|
|
402
578
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
403
579
|
.slice(0, 100)
|
|
404
580
|
.forEach((entry) => {
|
|
405
|
-
items.push({
|
|
406
|
-
path: path.join(targetPath, entry.name),
|
|
407
|
-
name: entry.name,
|
|
408
|
-
type: "dir"
|
|
409
|
-
});
|
|
410
|
-
});
|
|
411
|
-
res.json({
|
|
412
|
-
currentPath: targetPath,
|
|
413
|
-
items: items
|
|
581
|
+
items.push({ path: path.join(targetPath, entry.name), name: entry.name, type: "dir" });
|
|
414
582
|
});
|
|
583
|
+
res.json({ currentPath: targetPath, items });
|
|
415
584
|
}
|
|
416
585
|
catch (error) {
|
|
417
|
-
if (error.code ===
|
|
586
|
+
if (error.code === "ENOENT") {
|
|
418
587
|
res.status(404).json({ error: "路径不存在:" + q, currentPath: q, items: [] });
|
|
419
588
|
}
|
|
420
|
-
else if (error.code ===
|
|
589
|
+
else if (error.code === "EACCES") {
|
|
421
590
|
res.status(403).json({ error: "权限不足,无法访问:" + q, currentPath: q, items: [] });
|
|
422
591
|
}
|
|
423
592
|
else {
|
|
@@ -425,21 +594,19 @@ self.addEventListener('fetch', (event) => {
|
|
|
425
594
|
}
|
|
426
595
|
}
|
|
427
596
|
});
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const quickPaths = [
|
|
597
|
+
app.get("/api/quick-paths", async (_req, res) => {
|
|
598
|
+
const home = process.env.HOME || process.env.USERPROFILE || "/home";
|
|
599
|
+
res.json([
|
|
432
600
|
{ path: "/tmp", name: "临时目录", icon: "🗑️" },
|
|
433
601
|
{ path: home, name: "主目录", icon: "🏠" },
|
|
434
602
|
{ path: process.cwd(), name: "当前目录", icon: "📂" },
|
|
435
|
-
{ path: "/", name: "根目录", icon: "📁" }
|
|
436
|
-
];
|
|
437
|
-
res.json(quickPaths);
|
|
603
|
+
{ path: "/", name: "根目录", icon: "📁" },
|
|
604
|
+
]);
|
|
438
605
|
});
|
|
439
606
|
app.get("/api/favorite-paths", (_req, res) => {
|
|
440
607
|
const stored = storage.getConfigValue("favorite_paths");
|
|
441
|
-
const favorites =
|
|
442
|
-
res.json(favorites);
|
|
608
|
+
const favorites = parseStoredPathList(stored);
|
|
609
|
+
res.json(favorites.filter((f) => !isBlockedFolderPath(normalizeFolderPath(f.path))));
|
|
443
610
|
});
|
|
444
611
|
app.post("/api/favorite-paths", (req, res) => {
|
|
445
612
|
const { path: favPath, name, icon } = req.body;
|
|
@@ -447,18 +614,22 @@ self.addEventListener('fetch', (event) => {
|
|
|
447
614
|
res.status(400).json({ error: "路径不能为空。" });
|
|
448
615
|
return;
|
|
449
616
|
}
|
|
617
|
+
const resolvedFavoritePath = normalizeFolderPath(favPath);
|
|
618
|
+
if (isBlockedFolderPath(resolvedFavoritePath)) {
|
|
619
|
+
res.status(403).json({ error: "访问被拒绝:无法收藏系统敏感目录。" });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
450
622
|
const stored = storage.getConfigValue("favorite_paths");
|
|
451
|
-
const favorites =
|
|
452
|
-
|
|
453
|
-
if (favorites.some((f) => f.path === favPath)) {
|
|
623
|
+
const favorites = parseStoredPathList(stored);
|
|
624
|
+
if (favorites.some((f) => normalizeFolderPath(f.path) === resolvedFavoritePath)) {
|
|
454
625
|
res.status(400).json({ error: "该路径已在收藏列表中。" });
|
|
455
626
|
return;
|
|
456
627
|
}
|
|
457
628
|
const newFavorite = {
|
|
458
|
-
path:
|
|
459
|
-
name: name || path.basename(
|
|
629
|
+
path: resolvedFavoritePath,
|
|
630
|
+
name: name || path.basename(resolvedFavoritePath),
|
|
460
631
|
icon: icon || "⭐",
|
|
461
|
-
addedAt: new Date().toISOString()
|
|
632
|
+
addedAt: new Date().toISOString(),
|
|
462
633
|
};
|
|
463
634
|
favorites.push(newFavorite);
|
|
464
635
|
storage.setConfigValue("favorite_paths", JSON.stringify(favorites));
|
|
@@ -471,7 +642,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
471
642
|
return;
|
|
472
643
|
}
|
|
473
644
|
const stored = storage.getConfigValue("favorite_paths");
|
|
474
|
-
const favorites =
|
|
645
|
+
const favorites = parseStoredPathList(stored);
|
|
475
646
|
const index = favorites.findIndex((f) => f.path === delPath);
|
|
476
647
|
if (index === -1) {
|
|
477
648
|
res.status(404).json({ error: "未找到该收藏路径。" });
|
|
@@ -481,11 +652,10 @@ self.addEventListener('fetch', (event) => {
|
|
|
481
652
|
storage.setConfigValue("favorite_paths", JSON.stringify(favorites));
|
|
482
653
|
res.json({ ok: true });
|
|
483
654
|
});
|
|
484
|
-
const MAX_RECENT_PATHS = 10;
|
|
485
655
|
app.get("/api/recent-paths", (_req, res) => {
|
|
486
656
|
const stored = storage.getConfigValue("recent_paths");
|
|
487
|
-
const recent =
|
|
488
|
-
res.json(recent);
|
|
657
|
+
const recent = parseStoredPathList(stored);
|
|
658
|
+
res.json(recent.filter((item) => !isBlockedFolderPath(normalizeFolderPath(item.path))));
|
|
489
659
|
});
|
|
490
660
|
app.post("/api/recent-paths", (req, res) => {
|
|
491
661
|
const { path: usedPath } = req.body;
|
|
@@ -493,23 +663,24 @@ self.addEventListener('fetch', (event) => {
|
|
|
493
663
|
res.status(400).json({ error: "路径不能为空。" });
|
|
494
664
|
return;
|
|
495
665
|
}
|
|
666
|
+
const resolvedRecentPath = normalizeFolderPath(usedPath);
|
|
667
|
+
if (isBlockedFolderPath(resolvedRecentPath)) {
|
|
668
|
+
res.status(403).json({ error: "访问被拒绝:无法保存系统敏感目录。" });
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
496
671
|
const stored = storage.getConfigValue("recent_paths");
|
|
497
|
-
let recent =
|
|
498
|
-
|
|
499
|
-
recent = recent.filter((r) => r.path !== usedPath);
|
|
500
|
-
// Add to front
|
|
672
|
+
let recent = parseStoredPathList(stored);
|
|
673
|
+
recent = recent.filter((r) => normalizeFolderPath(r.path) !== resolvedRecentPath);
|
|
501
674
|
const newRecent = {
|
|
502
|
-
path:
|
|
503
|
-
name: path.basename(
|
|
504
|
-
lastUsedAt: new Date().toISOString()
|
|
675
|
+
path: resolvedRecentPath,
|
|
676
|
+
name: path.basename(resolvedRecentPath),
|
|
677
|
+
lastUsedAt: new Date().toISOString(),
|
|
505
678
|
};
|
|
506
679
|
recent.unshift(newRecent);
|
|
507
|
-
// Keep only last N entries
|
|
508
680
|
recent = recent.slice(0, MAX_RECENT_PATHS);
|
|
509
681
|
storage.setConfigValue("recent_paths", JSON.stringify(recent));
|
|
510
682
|
res.json(newRecent);
|
|
511
683
|
});
|
|
512
|
-
// ============ Path Validation API ============
|
|
513
684
|
app.get("/api/validate-path", async (req, res) => {
|
|
514
685
|
const inputPath = typeof req.query.path === "string" ? req.query.path : "";
|
|
515
686
|
if (!inputPath.trim()) {
|
|
@@ -517,18 +688,21 @@ self.addEventListener('fetch', (event) => {
|
|
|
517
688
|
return;
|
|
518
689
|
}
|
|
519
690
|
try {
|
|
520
|
-
const resolvedPath =
|
|
521
|
-
|
|
691
|
+
const resolvedPath = normalizeFolderPath(inputPath);
|
|
692
|
+
if (isBlockedFolderPath(resolvedPath)) {
|
|
693
|
+
res.json({ valid: false, error: "访问被拒绝:无法访问系统敏感目录。", resolvedPath });
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const stats = await import("node:fs/promises").then((fs) => fs.stat(resolvedPath));
|
|
522
697
|
if (!stats.isDirectory()) {
|
|
523
698
|
res.json({ valid: false, error: "路径不是目录", resolvedPath });
|
|
524
699
|
return;
|
|
525
700
|
}
|
|
526
|
-
// Check read permission
|
|
527
701
|
try {
|
|
528
702
|
await readdir(resolvedPath);
|
|
529
703
|
res.json({ valid: true, resolvedPath, name: path.basename(resolvedPath) });
|
|
530
704
|
}
|
|
531
|
-
catch
|
|
705
|
+
catch {
|
|
532
706
|
res.json({ valid: false, error: "没有读取权限", resolvedPath });
|
|
533
707
|
}
|
|
534
708
|
}
|
|
@@ -545,16 +719,14 @@ self.addEventListener('fetch', (event) => {
|
|
|
545
719
|
}
|
|
546
720
|
}
|
|
547
721
|
});
|
|
548
|
-
// File search API - supports fuzzy matching across directory tree
|
|
549
722
|
app.get("/api/file-search", async (req, res) => {
|
|
550
723
|
const query = typeof req.query.q === "string" ? req.query.q : "";
|
|
551
724
|
const cwd = typeof req.query.cwd === "string" ? req.query.cwd : process.cwd();
|
|
552
725
|
const maxDepth = typeof req.query.depth === "string" ? parseInt(req.query.depth, 10) : 5;
|
|
553
726
|
const maxResults = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50;
|
|
554
|
-
// Security check: ensure cwd is within allowed base
|
|
555
727
|
const allowedBase = process.cwd();
|
|
556
728
|
const resolvedCwd = path.resolve(allowedBase, cwd);
|
|
557
|
-
if (!resolvedCwd
|
|
729
|
+
if (!isPathWithinBase(resolvedCwd, allowedBase)) {
|
|
558
730
|
res.status(403).json({ error: "访问被拒绝:路径必须在项目目录内。" });
|
|
559
731
|
return;
|
|
560
732
|
}
|
|
@@ -565,7 +737,6 @@ self.addEventListener('fetch', (event) => {
|
|
|
565
737
|
try {
|
|
566
738
|
const results = [];
|
|
567
739
|
const queryLower = query.toLowerCase();
|
|
568
|
-
// Recursive search function
|
|
569
740
|
async function searchDir(dirPath, currentDepth) {
|
|
570
741
|
if (currentDepth > maxDepth || results.length >= maxResults)
|
|
571
742
|
return;
|
|
@@ -573,29 +744,25 @@ self.addEventListener('fetch', (event) => {
|
|
|
573
744
|
for (const entry of entries) {
|
|
574
745
|
if (results.length >= maxResults)
|
|
575
746
|
break;
|
|
576
|
-
// Skip hidden files and node_modules
|
|
577
747
|
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
578
748
|
continue;
|
|
579
749
|
const entryPath = path.join(dirPath, entry.name);
|
|
580
750
|
const nameLower = entry.name.toLowerCase();
|
|
581
|
-
// Check if name matches query (fuzzy match)
|
|
582
751
|
const matchIndex = nameLower.indexOf(queryLower);
|
|
583
752
|
if (matchIndex !== -1) {
|
|
584
753
|
results.push({
|
|
585
754
|
path: entryPath,
|
|
586
755
|
name: entry.name,
|
|
587
756
|
type: entry.isDirectory() ? "dir" : "file",
|
|
588
|
-
matchScore: matchIndex
|
|
757
|
+
matchScore: matchIndex,
|
|
589
758
|
});
|
|
590
759
|
}
|
|
591
|
-
// Recurse into directories
|
|
592
760
|
if (entry.isDirectory()) {
|
|
593
761
|
await searchDir(entryPath, currentDepth + 1);
|
|
594
762
|
}
|
|
595
763
|
}
|
|
596
764
|
}
|
|
597
765
|
await searchDir(resolvedCwd, 0);
|
|
598
|
-
// Sort by match score (earlier match = better) and then alphabetically
|
|
599
766
|
results.sort((a, b) => {
|
|
600
767
|
if (a.matchScore !== b.matchScore)
|
|
601
768
|
return a.matchScore - b.matchScore;
|
|
@@ -607,23 +774,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
607
774
|
res.status(400).json({ error: getErrorMessage(error, "搜索失败。可能原因:路径不存在或权限不足。") });
|
|
608
775
|
}
|
|
609
776
|
});
|
|
610
|
-
|
|
611
|
-
const snapshot = processes.get(req.params.id);
|
|
612
|
-
if (!snapshot) {
|
|
613
|
-
res.status(404).json({ error: "未找到该会话,可能已被删除。" });
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
if (req.query.format === "chat") {
|
|
617
|
-
// Prefer structured messages from JSON chat mode, fall back to PTY parsing
|
|
618
|
-
const messages = snapshot.messages && snapshot.messages.length > 0
|
|
619
|
-
? snapshot.messages
|
|
620
|
-
: parseMessages(snapshot.output);
|
|
621
|
-
res.json({ ...snapshot, messages });
|
|
622
|
-
}
|
|
623
|
-
else {
|
|
624
|
-
res.json(snapshot);
|
|
625
|
-
}
|
|
626
|
-
});
|
|
777
|
+
// ── Session control ──
|
|
627
778
|
app.post("/api/commands", (req, res) => {
|
|
628
779
|
const body = req.body;
|
|
629
780
|
if (!body.command?.trim()) {
|
|
@@ -636,17 +787,103 @@ self.addEventListener('fetch', (event) => {
|
|
|
636
787
|
res.status(201).json(snapshot);
|
|
637
788
|
}
|
|
638
789
|
catch (error) {
|
|
639
|
-
res.status(400).json({ error: getErrorMessage(error, "
|
|
790
|
+
res.status(400).json({ error: getErrorMessage(error, "无法启动命令。请检查命令是否安装。") });
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
app.post("/api/sessions/:id/resume", (req, res) => {
|
|
794
|
+
const sessionId = req.params.id;
|
|
795
|
+
const body = req.body;
|
|
796
|
+
try {
|
|
797
|
+
const existingSession = processes.get(sessionId) || storage.getSession(sessionId);
|
|
798
|
+
if (!existingSession) {
|
|
799
|
+
res.status(404).json({ error: "会话不存在。" });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const claudeSessionId = existingSession.claudeSessionId;
|
|
803
|
+
if (!claudeSessionId) {
|
|
804
|
+
res.status(400).json({ error: "此会话没有 Claude 会话 ID,无法恢复。" });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
const command = existingSession.command.trim();
|
|
808
|
+
if (!/^claude\b/.test(command)) {
|
|
809
|
+
res.status(400).json({ error: "只有 Claude 命令支持恢复功能。" });
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
const newMode = body.mode
|
|
813
|
+
? normalizeMode(body.mode, config.defaultMode)
|
|
814
|
+
: normalizeMode(existingSession.mode, config.defaultMode);
|
|
815
|
+
const resumeCommand = `${command} --resume ${claudeSessionId}`;
|
|
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 });
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
res.status(400).json({ error: getErrorMessage(error, "无法恢复会话。") });
|
|
822
|
+
}
|
|
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 恢复会话。") });
|
|
640
866
|
}
|
|
641
867
|
});
|
|
642
868
|
app.post("/api/sessions/:id/input", (req, res) => {
|
|
643
869
|
const body = req.body;
|
|
870
|
+
const sessionId = req.params.id;
|
|
871
|
+
const input = body.input ?? "";
|
|
872
|
+
const view = body.view;
|
|
873
|
+
console.error("[wand] Input request received", { sessionId, inputLength: input.length, view: view ?? "chat" });
|
|
644
874
|
try {
|
|
645
|
-
const snapshot = processes.sendInput(
|
|
875
|
+
const snapshot = processes.sendInput(sessionId, input, view);
|
|
876
|
+
console.error("[wand] Input request succeeded", { sessionId, status: snapshot.status, inputLength: input.length, view: view ?? "chat" });
|
|
646
877
|
res.json(snapshot);
|
|
647
878
|
}
|
|
648
879
|
catch (error) {
|
|
649
|
-
|
|
880
|
+
const response = getInputErrorResponse(error, sessionId);
|
|
881
|
+
console.error("[wand] Input request failed", {
|
|
882
|
+
sessionId, inputLength: input.length, view: view ?? "chat",
|
|
883
|
+
responseStatus: response.statusCode, responsePayload: response.payload,
|
|
884
|
+
error: getInputDebugMeta(error),
|
|
885
|
+
});
|
|
886
|
+
res.status(response.statusCode).json(response.payload);
|
|
650
887
|
}
|
|
651
888
|
});
|
|
652
889
|
app.post("/api/sessions/:id/resize", (req, res) => {
|
|
@@ -659,10 +896,35 @@ self.addEventListener('fetch', (event) => {
|
|
|
659
896
|
res.status(400).json({ error: getErrorMessage(error, "无法调整终端大小。") });
|
|
660
897
|
}
|
|
661
898
|
});
|
|
899
|
+
app.post("/api/sessions/:id/approve-permission", (req, res) => {
|
|
900
|
+
try {
|
|
901
|
+
res.json(processes.approvePermission(req.params.id));
|
|
902
|
+
}
|
|
903
|
+
catch (error) {
|
|
904
|
+
res.status(400).json({ error: getErrorMessage(error, "无法批准该授权请求。") });
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
app.post("/api/sessions/:id/deny-permission", (req, res) => {
|
|
908
|
+
try {
|
|
909
|
+
res.json(processes.denyPermission(req.params.id));
|
|
910
|
+
}
|
|
911
|
+
catch (error) {
|
|
912
|
+
res.status(400).json({ error: getErrorMessage(error, "无法拒绝该授权请求。") });
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
app.post("/api/sessions/:id/escalations/:requestId/resolve", (req, res) => {
|
|
916
|
+
try {
|
|
917
|
+
const { requestId } = req.params;
|
|
918
|
+
const body = req.body;
|
|
919
|
+
res.json(processes.resolveEscalation(req.params.id, requestId, body.resolution));
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
res.status(400).json({ error: getErrorMessage(error, "无法处理该授权请求。") });
|
|
923
|
+
}
|
|
924
|
+
});
|
|
662
925
|
app.post("/api/sessions/:id/stop", (req, res) => {
|
|
663
926
|
try {
|
|
664
|
-
|
|
665
|
-
res.json(snapshot);
|
|
927
|
+
res.json(processes.stop(req.params.id));
|
|
666
928
|
}
|
|
667
929
|
catch (error) {
|
|
668
930
|
res.status(400).json({ error: getErrorMessage(error, "无法停止会话。") });
|
|
@@ -677,8 +939,7 @@ self.addEventListener('fetch', (event) => {
|
|
|
677
939
|
res.status(400).json({ error: getErrorMessage(error, "无法删除会话。") });
|
|
678
940
|
}
|
|
679
941
|
});
|
|
680
|
-
|
|
681
|
-
// Create server (HTTP or HTTPS) - useHttps and protocol already defined above
|
|
942
|
+
// ── WebSocket broadcast layer ──
|
|
682
943
|
const server = useHttps
|
|
683
944
|
? (() => {
|
|
684
945
|
const ssl = ensureCertificates(resolveConfigDir(configPath));
|
|
@@ -686,129 +947,13 @@ self.addEventListener('fetch', (event) => {
|
|
|
686
947
|
})()
|
|
687
948
|
: createHttpServer(app);
|
|
688
949
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
// Output debounce cache - batch rapid output events per session
|
|
693
|
-
const outputDebounceCache = new Map();
|
|
694
|
-
// Process send queue for a WebSocket client
|
|
695
|
-
function processWsQueue(client) {
|
|
696
|
-
if (client.sendInProgress || client.sendQueue.length === 0 || client.backpressurePaused) {
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
client.sendInProgress = true;
|
|
700
|
-
const message = client.sendQueue.shift();
|
|
701
|
-
if (client.ws.readyState === WebSocket.OPEN) {
|
|
702
|
-
client.ws.send(message, (err) => {
|
|
703
|
-
client.sendInProgress = false;
|
|
704
|
-
if (err) {
|
|
705
|
-
// Error sending, drop message
|
|
706
|
-
return;
|
|
707
|
-
}
|
|
708
|
-
// Check backpressure threshold
|
|
709
|
-
const threshold = MAX_QUEUE_SIZE * 0.8;
|
|
710
|
-
if (client.backpressurePaused && client.sendQueue.length < threshold) {
|
|
711
|
-
client.backpressurePaused = false;
|
|
712
|
-
}
|
|
713
|
-
// Continue processing queue
|
|
714
|
-
processWsQueue(client);
|
|
715
|
-
});
|
|
716
|
-
}
|
|
717
|
-
else {
|
|
718
|
-
client.sendInProgress = false;
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
// 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
|
|
722
953
|
processes.on("process", (event) => {
|
|
723
|
-
|
|
724
|
-
if (event.type === "output") {
|
|
725
|
-
const existing = outputDebounceCache.get(event.sessionId);
|
|
726
|
-
if (existing) {
|
|
727
|
-
clearTimeout(existing.timer);
|
|
728
|
-
}
|
|
729
|
-
const timer = setTimeout(() => {
|
|
730
|
-
outputDebounceCache.delete(event.sessionId);
|
|
731
|
-
broadcastEvent(event);
|
|
732
|
-
}, OUTPUT_DEBOUNCE_MS);
|
|
733
|
-
outputDebounceCache.set(event.sessionId, { event, timer });
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
// Non-output events (started, ended, status) are sent immediately
|
|
737
|
-
broadcastEvent(event);
|
|
954
|
+
wsManager.emitEvent(event);
|
|
738
955
|
});
|
|
739
|
-
|
|
740
|
-
const message = JSON.stringify(event);
|
|
741
|
-
for (const client of wsClients) {
|
|
742
|
-
if (client.ws.readyState === WebSocket.OPEN) {
|
|
743
|
-
// Apply backpressure if queue is too large
|
|
744
|
-
if (client.sendQueue.length >= MAX_QUEUE_SIZE) {
|
|
745
|
-
client.backpressurePaused = true;
|
|
746
|
-
continue;
|
|
747
|
-
}
|
|
748
|
-
if (!client.backpressurePaused) {
|
|
749
|
-
client.sendQueue.push(message);
|
|
750
|
-
processWsQueue(client);
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
wss.on("connection", (ws, req) => {
|
|
756
|
-
const sessionToken = readSessionCookie(req);
|
|
757
|
-
if (!sessionToken || !validateSession(sessionToken)) {
|
|
758
|
-
ws.close(1008, "Unauthorized");
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
const client = {
|
|
762
|
-
ws,
|
|
763
|
-
sendQueue: [],
|
|
764
|
-
sendInProgress: false,
|
|
765
|
-
backpressurePaused: false,
|
|
766
|
-
lastOutputBySession: new Map()
|
|
767
|
-
};
|
|
768
|
-
wsClients.add(client);
|
|
769
|
-
ws.on("close", () => {
|
|
770
|
-
wsClients.delete(client);
|
|
771
|
-
});
|
|
772
|
-
ws.on("error", () => {
|
|
773
|
-
// Already closed, ignore
|
|
774
|
-
});
|
|
775
|
-
ws.on("message", (data) => {
|
|
776
|
-
try {
|
|
777
|
-
const msg = JSON.parse(data.toString());
|
|
778
|
-
// Handle subscribe/unsubscribe for specific sessions
|
|
779
|
-
if (msg.type === "subscribe" && msg.sessionId) {
|
|
780
|
-
// Client wants updates for a specific session
|
|
781
|
-
const snapshot = processes.get(msg.sessionId);
|
|
782
|
-
if (snapshot) {
|
|
783
|
-
// Send full session snapshot including messages for reconnection recovery
|
|
784
|
-
ws.send(JSON.stringify({
|
|
785
|
-
type: "init",
|
|
786
|
-
sessionId: msg.sessionId,
|
|
787
|
-
data: {
|
|
788
|
-
...snapshot,
|
|
789
|
-
// Ensure messages are included for chat mode recovery
|
|
790
|
-
messages: snapshot.messages,
|
|
791
|
-
// Include full output for terminal mode recovery
|
|
792
|
-
output: snapshot.output
|
|
793
|
-
}
|
|
794
|
-
}));
|
|
795
|
-
}
|
|
796
|
-
else {
|
|
797
|
-
// Session not found - might be deleted or never existed
|
|
798
|
-
ws.send(JSON.stringify({
|
|
799
|
-
type: "error",
|
|
800
|
-
sessionId: msg.sessionId,
|
|
801
|
-
error: "Session not found"
|
|
802
|
-
}));
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
catch {
|
|
807
|
-
// Ignore malformed messages
|
|
808
|
-
}
|
|
809
|
-
});
|
|
810
|
-
});
|
|
811
|
-
// Start server
|
|
956
|
+
// ── Start listening ──
|
|
812
957
|
await new Promise((resolve, reject) => {
|
|
813
958
|
server.listen(config.port, config.host, () => {
|
|
814
959
|
const listenAddr = config.host === "0.0.0.0" ? "0.0.0.0 (所有接口)" : config.host;
|
|
@@ -824,52 +969,9 @@ self.addEventListener('fetch', (event) => {
|
|
|
824
969
|
reject(err);
|
|
825
970
|
});
|
|
826
971
|
});
|
|
827
|
-
// Print security warnings
|
|
828
972
|
if (!storage.hasCustomPassword() && config.password === "change-me") {
|
|
829
973
|
wandWarn("正在使用默认密码(change-me),任何能访问本机的人都可以登录。", "修改方法:在界面右上角「设置」中修改密码,或运行:node dist/cli.js config:set password <你的新密码>");
|
|
830
974
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
if (!validateSession(readSessionCookie(req))) {
|
|
834
|
-
res.status(401).json({ error: "未授权,请先登录。" });
|
|
835
|
-
return;
|
|
836
|
-
}
|
|
837
|
-
next();
|
|
838
|
-
}
|
|
839
|
-
function normalizeMode(input, fallback) {
|
|
840
|
-
return isExecutionMode(input) ? input : fallback;
|
|
841
|
-
}
|
|
842
|
-
function readSessionCookie(req) {
|
|
843
|
-
const cookie = req.headers.cookie;
|
|
844
|
-
if (!cookie) {
|
|
845
|
-
return undefined;
|
|
846
|
-
}
|
|
847
|
-
const match = cookie
|
|
848
|
-
.split(";")
|
|
849
|
-
.map((part) => part.trim())
|
|
850
|
-
.find((part) => part.startsWith("wand_session="));
|
|
851
|
-
return match?.slice("wand_session=".length);
|
|
852
|
-
}
|
|
853
|
-
async function listPathSuggestions(input, fallbackCwd) {
|
|
854
|
-
const normalizedInput = input.trim();
|
|
855
|
-
const baseInput = normalizedInput || fallbackCwd;
|
|
856
|
-
const resolvedInput = path.resolve(process.cwd(), baseInput);
|
|
857
|
-
const endsWithSeparator = /[\\/]$/.test(normalizedInput);
|
|
858
|
-
let searchDir = resolvedInput;
|
|
859
|
-
let partialName = "";
|
|
860
|
-
if (!endsWithSeparator) {
|
|
861
|
-
searchDir = path.dirname(resolvedInput);
|
|
862
|
-
partialName = path.basename(resolvedInput);
|
|
863
|
-
}
|
|
864
|
-
const entries = await readdir(searchDir, { withFileTypes: true });
|
|
865
|
-
return entries
|
|
866
|
-
.filter((entry) => entry.isDirectory())
|
|
867
|
-
.filter((entry) => !partialName || entry.name.toLowerCase().startsWith(partialName.toLowerCase()))
|
|
868
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
869
|
-
.slice(0, 8)
|
|
870
|
-
.map((entry) => ({
|
|
871
|
-
path: path.join(searchDir, entry.name),
|
|
872
|
-
name: entry.name,
|
|
873
|
-
isDirectory: true
|
|
874
|
-
}));
|
|
975
|
+
// Start configured background sessions after the server is already reachable.
|
|
976
|
+
processes.runStartupCommands();
|
|
875
977
|
}
|