@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.
Files changed (48) hide show
  1. package/README.md +25 -5
  2. package/dist/acp-protocol.d.ts +67 -0
  3. package/dist/acp-protocol.js +291 -0
  4. package/dist/avatar.d.ts +14 -0
  5. package/dist/avatar.js +110 -0
  6. package/dist/claude-pty-bridge.d.ts +137 -0
  7. package/dist/claude-pty-bridge.js +619 -0
  8. package/dist/claude-stream-adapter.d.ts +35 -0
  9. package/dist/claude-stream-adapter.js +153 -0
  10. package/dist/claude-structured-runner.d.ts +27 -0
  11. package/dist/claude-structured-runner.js +106 -0
  12. package/dist/cli.d.ts +1 -1
  13. package/dist/cli.js +10 -2
  14. package/dist/config.js +8 -4
  15. package/dist/message-parser.js +16 -150
  16. package/dist/message-queue.d.ts +57 -0
  17. package/dist/message-queue.js +127 -0
  18. package/dist/middleware/path-safety.d.ts +6 -0
  19. package/dist/middleware/path-safety.js +19 -0
  20. package/dist/middleware/rate-limit.d.ts +8 -0
  21. package/dist/middleware/rate-limit.js +37 -0
  22. package/dist/process-manager.d.ts +82 -27
  23. package/dist/process-manager.js +1445 -822
  24. package/dist/pty-text-utils.d.ts +13 -0
  25. package/dist/pty-text-utils.js +84 -0
  26. package/dist/pwa.d.ts +5 -0
  27. package/dist/pwa.js +118 -0
  28. package/dist/server.js +511 -409
  29. package/dist/session-lifecycle.d.ts +81 -0
  30. package/dist/session-lifecycle.js +181 -0
  31. package/dist/session-logger.d.ts +13 -3
  32. package/dist/session-logger.js +56 -5
  33. package/dist/storage.d.ts +9 -0
  34. package/dist/storage.js +73 -7
  35. package/dist/types.d.ts +112 -6
  36. package/dist/web-ui/content/icon-192.png +0 -0
  37. package/dist/web-ui/content/icon-512.png +0 -0
  38. package/dist/web-ui/content/scripts.js +3770 -852
  39. package/dist/web-ui/content/styles.css +5505 -2779
  40. package/dist/web-ui/index.js +8 -5
  41. package/dist/web-ui/scripts.js +8 -1
  42. package/dist/ws-broadcast.d.ts +27 -0
  43. package/dist/ws-broadcast.js +160 -0
  44. package/package.json +2 -9
  45. package/dist/web-ui/utils.d.ts +0 -4
  46. package/dist/web-ui/utils.js +0 -12
  47. package/dist/web-ui.d.ts +0 -1
  48. 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, WebSocket } from "ws";
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
- * Check if a directory is inside a git repository
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
- // Parse staged/unstaged changes
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
- // Parse untracked files
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 (error) {
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
- // Normalize path separators for cross-platform compatibility
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
- // Simple in-memory rate limiter for login attempts
129
- const loginAttempts = new Map();
130
- const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
131
- const RATE_LIMIT_MAX = 10; // 10 attempts per window
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
- record.count++;
146
+ next();
148
147
  }
149
- function resetRateLimit(ip) {
150
- loginAttempts.delete(ip);
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 cleanupRateLimiter() {
153
- const now = Date.now();
154
- for (const [ip, record] of loginAttempts.entries()) {
155
- if (now > record.resetAt) {
156
- loginAttempts.delete(ip);
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
- // Cleanup rate limiter every 5 minutes
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 wandInfo(message) {
184
- process.stdout.write(`ℹ️ [wand] ${message}\n`);
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 processes = new ProcessManager(config, storage, resolveConfigDir(configPath));
191
- const useHttps = config.https !== false; // Default to true
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.resolve(process.cwd(), "node_modules/xterm")));
195
- app.use("/vendor/xterm-addon-fit", express.static(path.resolve(process.cwd(), "node_modules/@xterm/addon-fit")));
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.type("json").send(JSON.stringify({
202
- name: "Wand Console",
203
- short_name: "Wand",
204
- description: "Local CLI Console for Vibe Coding",
205
- start_url: "/",
206
- display: "standalone",
207
- background_color: "#f6f1e8",
208
- theme_color: "#c5653d",
209
- orientation: "any",
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(iconSvg);
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(iconSvg);
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.type("javascript").send(`
238
- const CACHE_NAME = 'wand-v1';
239
- const STATIC_ASSETS = [
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.startsWith(allowedBase)) {
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
- // Folder picker API - starts from /tmp by default, supports navigation
375
- app.get("/api/folders", async (req, res) => {
376
- const q = typeof req.query.q === "string" ? req.query.q : "/tmp";
377
- const targetPath = path.resolve(q);
378
- // Security check: prevent accessing sensitive system paths
379
- const blockedPaths = ['/etc', '/root', '/boot'];
380
- for (const blocked of blockedPaths) {
381
- if (targetPath.startsWith(blocked)) {
382
- res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
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 === 'ENOENT') {
586
+ if (error.code === "ENOENT") {
418
587
  res.status(404).json({ error: "路径不存在:" + q, currentPath: q, items: [] });
419
588
  }
420
- else if (error.code === 'EACCES') {
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
- // Quick paths API - returns common paths for quick access
429
- app.get("/api/quick-paths", async (req, res) => {
430
- const home = process.env.HOME || process.env.USERPROFILE || '/home';
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 = stored ? JSON.parse(stored) : [];
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 = stored ? JSON.parse(stored) : [];
452
- // Check if already exists
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: favPath,
459
- name: name || path.basename(favPath),
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 = stored ? JSON.parse(stored) : [];
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 = stored ? JSON.parse(stored) : [];
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 = stored ? JSON.parse(stored) : [];
498
- // Remove existing entry for this path (to update position)
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: usedPath,
503
- name: path.basename(usedPath),
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 = path.resolve(inputPath);
521
- const stats = await import("node:fs/promises").then(fs => fs.stat(resolvedPath));
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 (permError) {
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.startsWith(allowedBase)) {
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 // Lower score = better match (appears earlier in name)
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
- app.get("/api/sessions/:id", (req, res) => {
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(req.params.id, body.input ?? "", body.view);
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
- res.status(400).json({ error: getErrorMessage(error, "会话已结束,请启动新会话。") });
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
- const snapshot = processes.stop(req.params.id);
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
- await processes.runStartupCommands();
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 wsClients = new Set();
690
- const MAX_QUEUE_SIZE = 500; // Max messages in queue before applying backpressure
691
- const OUTPUT_DEBOUNCE_MS = 50; // Debounce PTY output updates to reduce flicker
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
- // Debounce output events to reduce flicker during rapid streaming
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
- function broadcastEvent(event) {
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
- function requireAuth(req, res, next) {
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
  }