@co0ontty/wand 0.3.0 → 0.4.0

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