@co0ontty/wand 0.2.1 → 0.3.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,5 +1,5 @@
1
1
  import express from "express";
2
- import { readdir } from "node:fs/promises";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
3
  import { createServer as createHttpServer } from "node:http";
4
4
  import { createServer as createHttpsServer } from "node:https";
5
5
  import { exec } from "node:child_process";
@@ -11,13 +11,60 @@ const execAsync = promisify(exec);
11
11
  import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
12
12
  import { ensureCertificates } from "./cert.js";
13
13
  import { isExecutionMode, resolveConfigDir } from "./config.js";
14
- import { ProcessManager } from "./process-manager.js";
14
+ import { ProcessManager, SessionInputError } from "./process-manager.js";
15
15
  import { resolveDatabasePath, WandStorage } from "./storage.js";
16
- import { renderApp } from "./web-ui.js";
16
+ import { renderApp } from "./web-ui/index.js";
17
17
  import { parseMessages } from "./message-parser.js";
18
18
  function getErrorMessage(error, fallback) {
19
19
  return error instanceof Error ? error.message : fallback;
20
20
  }
21
+ function getInputErrorResponse(error, sessionId) {
22
+ if (error instanceof SessionInputError) {
23
+ const statusCode = error.code === "SESSION_NOT_FOUND" ? 404 : 409;
24
+ return {
25
+ statusCode,
26
+ payload: {
27
+ error: error.message,
28
+ errorCode: error.code,
29
+ sessionId,
30
+ sessionStatus: error.sessionStatus ?? null
31
+ }
32
+ };
33
+ }
34
+ return {
35
+ statusCode: 400,
36
+ payload: {
37
+ error: getErrorMessage(error, "会话已结束,请启动新会话。"),
38
+ errorCode: "INPUT_SEND_FAILED",
39
+ sessionId,
40
+ sessionStatus: null
41
+ }
42
+ };
43
+ }
44
+ function getInputDebugMeta(error) {
45
+ if (error instanceof Error) {
46
+ return {
47
+ name: error.name,
48
+ message: error.message,
49
+ stack: error.stack
50
+ };
51
+ }
52
+ return { error };
53
+ }
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
+ }
21
68
  /**
22
69
  * Check if a directory is inside a git repository
23
70
  */
@@ -199,25 +246,41 @@ export async function startServer(config, configPath) {
199
246
  // PWA manifest
200
247
  app.get("/manifest.json", (_req, res) => {
201
248
  res.type("json").send(JSON.stringify({
249
+ id: "/",
250
+ scope: "/",
202
251
  name: "Wand Console",
203
252
  short_name: "Wand",
204
253
  description: "Local CLI Console for Vibe Coding",
205
254
  start_url: "/",
206
255
  display: "standalone",
256
+ display_override: ["standalone", "minimal-ui", "browser"],
207
257
  background_color: "#f6f1e8",
208
258
  theme_color: "#c5653d",
209
259
  orientation: "any",
210
260
  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" }
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" }
213
264
  ],
214
265
  categories: ["developer tools", "productivity"],
215
266
  shortcuts: [
216
267
  { name: "New Session", short_name: "New", url: "/?action=new", description: "Start a new CLI session" }
217
- ]
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
+ }
218
282
  }));
219
283
  });
220
- // PWA icons (SVG data URL converted to simple PNG-like response)
221
284
  const iconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
222
285
  <defs><linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
223
286
  <stop offset="0%" style="stop-color:#d77a52"/>
@@ -226,51 +289,102 @@ export async function startServer(config, configPath) {
226
289
  <rect width="192" height="192" rx="38" fill="url(#g)"/>
227
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>
228
291
  </svg>`;
229
- app.get("/icon-192.png", (_req, res) => {
292
+ app.get("/icon.svg", (_req, res) => {
230
293
  res.type("svg").send(iconSvg);
231
294
  });
295
+ app.get("/icon-192.png", (_req, res) => {
296
+ res.redirect(302, "/icon.svg");
297
+ });
232
298
  app.get("/icon-512.png", (_req, res) => {
233
- res.type("svg").send(iconSvg);
299
+ res.redirect(302, "/icon.svg");
234
300
  });
235
301
  // Service Worker for offline support
236
302
  app.get("/sw.js", (_req, res) => {
237
303
  res.type("javascript").send(`
238
- const CACHE_NAME = 'wand-v1';
304
+ const STATIC_CACHE = 'wand-static-v2';
305
+ const RUNTIME_CACHE = 'wand-runtime-v2';
306
+ const APP_SHELL = '/';
239
307
  const STATIC_ASSETS = [
240
- '/',
308
+ APP_SHELL,
309
+ '/manifest.json',
310
+ '/icon.svg',
241
311
  '/vendor/xterm/css/xterm.css',
242
312
  '/vendor/xterm/lib/xterm.js',
243
313
  '/vendor/xterm-addon-fit/lib/addon-fit.js'
244
314
  ];
245
315
 
246
316
  self.addEventListener('install', (event) => {
247
- event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)));
317
+ event.waitUntil(caches.open(STATIC_CACHE).then((cache) => cache.addAll(STATIC_ASSETS)));
248
318
  self.skipWaiting();
249
319
  });
250
320
 
251
321
  self.addEventListener('activate', (event) => {
252
- event.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))));
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
+ );
253
329
  self.clients.claim();
254
330
  });
255
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
+
256
345
  self.addEventListener('fetch', (event) => {
257
- const url = new URL(event.request.url);
258
- // API calls should always go to network
346
+ const request = event.request;
347
+ const url = new URL(request.url);
348
+
349
+ if (request.method !== 'GET') {
350
+ return;
351
+ }
352
+
259
353
  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' } })));
354
+ event.respondWith(
355
+ fetch(request).catch(() => new Response(JSON.stringify({ error: 'Offline' }), {
356
+ status: 503,
357
+ headers: { 'Content-Type': 'application/json' }
358
+ }))
359
+ );
261
360
  return;
262
361
  }
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('/'))));
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
+ );
271
382
  });
272
383
  `);
273
384
  });
385
+ app.get("/offline", (_req, res) => {
386
+ res.type("html").send(renderApp(configPath));
387
+ });
274
388
  app.post("/api/login", (req, res) => {
275
389
  const clientIp = req.ip || req.socket.remoteAddress || "unknown";
276
390
  if (!checkRateLimit(clientIp)) {
@@ -340,7 +454,7 @@ self.addEventListener('fetch', (event) => {
340
454
  const targetPath = path.resolve(process.cwd(), q);
341
455
  // Security check: ensure the resolved path is within the current working directory
342
456
  const allowedBase = process.cwd();
343
- if (!targetPath.startsWith(allowedBase)) {
457
+ if (!isPathWithinBase(targetPath, allowedBase)) {
344
458
  res.status(403).json({ error: "访问被拒绝:路径必须在项目目录内。" });
345
459
  return;
346
460
  }
@@ -371,17 +485,100 @@ self.addEventListener('fetch', (event) => {
371
485
  res.status(400).json({ error: getErrorMessage(error, "无法读取目录。可能原因:路径不存在或权限不足。") });
372
486
  }
373
487
  });
488
+ // File preview API - reads file contents with size limit
489
+ const MAX_FILE_SIZE = 512 * 1024; // 512KB limit
490
+ app.get("/api/file-preview", async (req, res) => {
491
+ const filePath = typeof req.query.path === "string" ? req.query.path : "";
492
+ if (!filePath) {
493
+ res.status(400).json({ error: "Missing path parameter" });
494
+ return;
495
+ }
496
+ const resolvedPath = path.resolve(filePath);
497
+ const allowedBase = process.cwd();
498
+ if (!isPathWithinBase(resolvedPath, allowedBase)) {
499
+ res.status(403).json({ error: "Access denied" });
500
+ return;
501
+ }
502
+ try {
503
+ const fileStat = await stat(resolvedPath);
504
+ if (fileStat.isDirectory()) {
505
+ res.status(400).json({ error: "Cannot preview a directory" });
506
+ return;
507
+ }
508
+ if (fileStat.size > MAX_FILE_SIZE) {
509
+ res.status(413).json({ error: "File too large", truncated: true, size: fileStat.size, maxSize: MAX_FILE_SIZE });
510
+ return;
511
+ }
512
+ const ext = path.extname(filePath).toLowerCase();
513
+ const previewableExts = [
514
+ // Markdown
515
+ ".md", ".markdown", ".mdown", ".mkd", ".mkdn",
516
+ // Code
517
+ ".ts", ".tsx", ".js", ".jsx", ".json", ".html", ".css", ".scss", ".less",
518
+ ".py", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".hpp",
519
+ ".cs", ".swift", ".kt", ".scala", ".php", ".sh", ".bash", ".zsh",
520
+ ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
521
+ ".xml", ".sql", ".graphql", ".proto",
522
+ ".dockerfile", ".gitignore", ".env", ".editorconfig",
523
+ ".mdx", ".vue", ".svelte",
524
+ // Text
525
+ ".txt", ".log", ".diff", ".patch"
526
+ ];
527
+ const isText = previewableExts.includes(ext) ||
528
+ ext === "" ||
529
+ [".gitignore", "dockerfile", ".env.local", ".env.development"].some(e => filePath.toLowerCase().endsWith(e));
530
+ if (!isText) {
531
+ res.status(415).json({ error: "Unsupported file type", ext });
532
+ return;
533
+ }
534
+ const content = await readFile(resolvedPath, "utf-8");
535
+ 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
+ });
544
+ }
545
+ catch (error) {
546
+ res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
547
+ }
548
+ });
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
+ }
374
575
  // Folder picker API - starts from /tmp by default, supports navigation
375
576
  app.get("/api/folders", async (req, res) => {
376
577
  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: "访问被拒绝:无法访问系统敏感目录。" });
383
- return;
384
- }
578
+ const targetPath = normalizeFolderPath(q);
579
+ if (isBlockedFolderPath(targetPath)) {
580
+ res.status(403).json({ error: "访问被拒绝:无法访问系统敏感目录。" });
581
+ return;
385
582
  }
386
583
  try {
387
584
  const entries = await readdir(targetPath, { withFileTypes: true });
@@ -436,10 +633,22 @@ self.addEventListener('fetch', (event) => {
436
633
  ];
437
634
  res.json(quickPaths);
438
635
  });
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
+ }
439
648
  app.get("/api/favorite-paths", (_req, res) => {
440
649
  const stored = storage.getConfigValue("favorite_paths");
441
- const favorites = stored ? JSON.parse(stored) : [];
442
- res.json(favorites);
650
+ const favorites = parseStoredPathList(stored);
651
+ res.json(favorites.filter((favorite) => !isBlockedFolderPath(normalizeFolderPath(favorite.path))));
443
652
  });
444
653
  app.post("/api/favorite-paths", (req, res) => {
445
654
  const { path: favPath, name, icon } = req.body;
@@ -447,16 +656,21 @@ self.addEventListener('fetch', (event) => {
447
656
  res.status(400).json({ error: "路径不能为空。" });
448
657
  return;
449
658
  }
659
+ const resolvedFavoritePath = normalizeFolderPath(favPath);
660
+ if (isBlockedFolderPath(resolvedFavoritePath)) {
661
+ res.status(403).json({ error: "访问被拒绝:无法收藏系统敏感目录。" });
662
+ return;
663
+ }
450
664
  const stored = storage.getConfigValue("favorite_paths");
451
- const favorites = stored ? JSON.parse(stored) : [];
665
+ const favorites = parseStoredPathList(stored);
452
666
  // Check if already exists
453
- if (favorites.some((f) => f.path === favPath)) {
667
+ if (favorites.some((f) => normalizeFolderPath(f.path) === resolvedFavoritePath)) {
454
668
  res.status(400).json({ error: "该路径已在收藏列表中。" });
455
669
  return;
456
670
  }
457
671
  const newFavorite = {
458
- path: favPath,
459
- name: name || path.basename(favPath),
672
+ path: resolvedFavoritePath,
673
+ name: name || path.basename(resolvedFavoritePath),
460
674
  icon: icon || "⭐",
461
675
  addedAt: new Date().toISOString()
462
676
  };
@@ -471,7 +685,7 @@ self.addEventListener('fetch', (event) => {
471
685
  return;
472
686
  }
473
687
  const stored = storage.getConfigValue("favorite_paths");
474
- const favorites = stored ? JSON.parse(stored) : [];
688
+ const favorites = parseStoredPathList(stored);
475
689
  const index = favorites.findIndex((f) => f.path === delPath);
476
690
  if (index === -1) {
477
691
  res.status(404).json({ error: "未找到该收藏路径。" });
@@ -484,8 +698,8 @@ self.addEventListener('fetch', (event) => {
484
698
  const MAX_RECENT_PATHS = 10;
485
699
  app.get("/api/recent-paths", (_req, res) => {
486
700
  const stored = storage.getConfigValue("recent_paths");
487
- const recent = stored ? JSON.parse(stored) : [];
488
- res.json(recent);
701
+ const recent = parseStoredPathList(stored);
702
+ res.json(recent.filter((item) => !isBlockedFolderPath(normalizeFolderPath(item.path))));
489
703
  });
490
704
  app.post("/api/recent-paths", (req, res) => {
491
705
  const { path: usedPath } = req.body;
@@ -493,14 +707,19 @@ self.addEventListener('fetch', (event) => {
493
707
  res.status(400).json({ error: "路径不能为空。" });
494
708
  return;
495
709
  }
710
+ const resolvedRecentPath = normalizeFolderPath(usedPath);
711
+ if (isBlockedFolderPath(resolvedRecentPath)) {
712
+ res.status(403).json({ error: "访问被拒绝:无法保存系统敏感目录。" });
713
+ return;
714
+ }
496
715
  const stored = storage.getConfigValue("recent_paths");
497
- let recent = stored ? JSON.parse(stored) : [];
716
+ let recent = parseStoredPathList(stored);
498
717
  // Remove existing entry for this path (to update position)
499
- recent = recent.filter((r) => r.path !== usedPath);
718
+ recent = recent.filter((r) => normalizeFolderPath(r.path) !== resolvedRecentPath);
500
719
  // Add to front
501
720
  const newRecent = {
502
- path: usedPath,
503
- name: path.basename(usedPath),
721
+ path: resolvedRecentPath,
722
+ name: path.basename(resolvedRecentPath),
504
723
  lastUsedAt: new Date().toISOString()
505
724
  };
506
725
  recent.unshift(newRecent);
@@ -517,7 +736,11 @@ self.addEventListener('fetch', (event) => {
517
736
  return;
518
737
  }
519
738
  try {
520
- const resolvedPath = path.resolve(inputPath);
739
+ const resolvedPath = normalizeFolderPath(inputPath);
740
+ if (isBlockedFolderPath(resolvedPath)) {
741
+ res.json({ valid: false, error: "访问被拒绝:无法访问系统敏感目录。", resolvedPath });
742
+ return;
743
+ }
521
744
  const stats = await import("node:fs/promises").then(fs => fs.stat(resolvedPath));
522
745
  if (!stats.isDirectory()) {
523
746
  res.json({ valid: false, error: "路径不是目录", resolvedPath });
@@ -554,7 +777,7 @@ self.addEventListener('fetch', (event) => {
554
777
  // Security check: ensure cwd is within allowed base
555
778
  const allowedBase = process.cwd();
556
779
  const resolvedCwd = path.resolve(allowedBase, cwd);
557
- if (!resolvedCwd.startsWith(allowedBase)) {
780
+ if (!isPathWithinBase(resolvedCwd, allowedBase)) {
558
781
  res.status(403).json({ error: "访问被拒绝:路径必须在项目目录内。" });
559
782
  return;
560
783
  }
@@ -614,7 +837,7 @@ self.addEventListener('fetch', (event) => {
614
837
  return;
615
838
  }
616
839
  if (req.query.format === "chat") {
617
- // Prefer structured messages from JSON chat mode, fall back to PTY parsing
840
+ // Prefer PTY-derived structured messages, fall back to parsing raw output
618
841
  const messages = snapshot.messages && snapshot.messages.length > 0
619
842
  ? snapshot.messages
620
843
  : parseMessages(snapshot.output);
@@ -639,14 +862,77 @@ self.addEventListener('fetch', (event) => {
639
862
  res.status(400).json({ error: getErrorMessage(error, "无法启动命令。请检查命令是否正确安装。") });
640
863
  }
641
864
  });
865
+ // Resume a session with a different mode (e.g., switch from terminal to chat)
866
+ app.post("/api/sessions/:id/resume", (req, res) => {
867
+ const sessionId = req.params.id;
868
+ const body = req.body;
869
+ try {
870
+ const existingSession = processes.get(sessionId);
871
+ if (!existingSession) {
872
+ res.status(404).json({ error: "会话不存在。" });
873
+ return;
874
+ }
875
+ if (existingSession.status !== "running") {
876
+ res.status(400).json({ error: "会话已结束,无法恢复。" });
877
+ return;
878
+ }
879
+ // Get the Claude session ID for resuming
880
+ const claudeSessionId = existingSession.claudeSessionId;
881
+ if (!claudeSessionId) {
882
+ res.status(400).json({ error: "此会话没有 Claude 会话 ID,无法恢复。" });
883
+ return;
884
+ }
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) {
891
+ res.status(400).json({ error: "只有 Claude 命令支持恢复功能。" });
892
+ return;
893
+ }
894
+ // Create a new session with --resume flag
895
+ 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);
900
+ }
901
+ catch (error) {
902
+ res.status(400).json({ error: getErrorMessage(error, "无法恢复会话。") });
903
+ }
904
+ });
642
905
  app.post("/api/sessions/:id/input", (req, res) => {
643
906
  const body = req.body;
907
+ const sessionId = req.params.id;
908
+ const input = body.input ?? "";
909
+ const view = body.view;
910
+ console.error("[wand] Input request received", {
911
+ sessionId,
912
+ inputLength: input.length,
913
+ view: view ?? "chat"
914
+ });
644
915
  try {
645
- const snapshot = processes.sendInput(req.params.id, body.input ?? "", body.view);
916
+ 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
+ });
646
923
  res.json(snapshot);
647
924
  }
648
925
  catch (error) {
649
- res.status(400).json({ error: getErrorMessage(error, "会话已结束,请启动新会话。") });
926
+ const response = getInputErrorResponse(error, sessionId);
927
+ 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)
934
+ });
935
+ res.status(response.statusCode).json(response.payload);
650
936
  }
651
937
  });
652
938
  app.post("/api/sessions/:id/resize", (req, res) => {
@@ -659,6 +945,35 @@ self.addEventListener('fetch', (event) => {
659
945
  res.status(400).json({ error: getErrorMessage(error, "无法调整终端大小。") });
660
946
  }
661
947
  });
948
+ app.post("/api/sessions/:id/approve-permission", (req, res) => {
949
+ try {
950
+ const snapshot = processes.approvePermission(req.params.id);
951
+ res.json(snapshot);
952
+ }
953
+ catch (error) {
954
+ res.status(400).json({ error: getErrorMessage(error, "无法批准该授权请求。") });
955
+ }
956
+ });
957
+ app.post("/api/sessions/:id/deny-permission", (req, res) => {
958
+ try {
959
+ const snapshot = processes.denyPermission(req.params.id);
960
+ res.json(snapshot);
961
+ }
962
+ catch (error) {
963
+ res.status(400).json({ error: getErrorMessage(error, "无法拒绝该授权请求。") });
964
+ }
965
+ });
966
+ app.post("/api/sessions/:id/escalations/:requestId/resolve", (req, res) => {
967
+ try {
968
+ const { requestId } = req.params;
969
+ const body = req.body;
970
+ const snapshot = processes.resolveEscalation(req.params.id, requestId, body.resolution);
971
+ res.json(snapshot);
972
+ }
973
+ catch (error) {
974
+ res.status(400).json({ error: getErrorMessage(error, "无法处理该授权请求。") });
975
+ }
976
+ });
662
977
  app.post("/api/sessions/:id/stop", (req, res) => {
663
978
  try {
664
979
  const snapshot = processes.stop(req.params.id);
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Session Lifecycle Manager
3
+ * Inspired by Happy's session lifecycle management
4
+ */
5
+ import type { SessionLifecycleState, SessionLifecycle } from "./types.js";
6
+ export interface SessionLifecycleEvents {
7
+ onStateChange?: (sessionId: string, oldState: SessionLifecycleState, newState: SessionLifecycleState) => void;
8
+ onIdle?: (sessionId: string) => void;
9
+ onArchived?: (sessionId: string, reason: string) => void;
10
+ }
11
+ export declare class SessionLifecycleManager {
12
+ private sessions;
13
+ private events;
14
+ private idleTimeout;
15
+ private archiveTimeout;
16
+ private checkInterval;
17
+ constructor(events?: SessionLifecycleEvents, options?: {
18
+ idleTimeout?: number;
19
+ archiveTimeout?: number;
20
+ });
21
+ /**
22
+ * Register a new session
23
+ */
24
+ register(sessionId: string, initialState?: SessionLifecycleState): void;
25
+ /**
26
+ * Update session state
27
+ */
28
+ setState(sessionId: string, newState: SessionLifecycleState): void;
29
+ /**
30
+ * Update last activity timestamp
31
+ */
32
+ touch(sessionId: string): void;
33
+ /**
34
+ * Mark session as thinking
35
+ */
36
+ startThinking(sessionId: string): void;
37
+ /**
38
+ * Mark session as done thinking
39
+ */
40
+ stopThinking(sessionId: string): void;
41
+ /**
42
+ * Mark session as waiting for input
43
+ */
44
+ waitingInput(sessionId: string): void;
45
+ /**
46
+ * Archive a session
47
+ */
48
+ archive(sessionId: string, reason: string, by?: "user" | "timeout" | "error"): void;
49
+ /**
50
+ * Unregister a session
51
+ */
52
+ unregister(sessionId: string): void;
53
+ /**
54
+ * Get session lifecycle
55
+ */
56
+ get(sessionId: string): SessionLifecycle | undefined;
57
+ /**
58
+ * Get all sessions
59
+ */
60
+ getAll(): Map<string, SessionLifecycle>;
61
+ /**
62
+ * Get sessions by state
63
+ */
64
+ getByState(state: SessionLifecycleState): string[];
65
+ /**
66
+ * Start periodic check for idle/archived sessions
67
+ */
68
+ private startPeriodicCheck;
69
+ /**
70
+ * Stop periodic check
71
+ */
72
+ stopPeriodicCheck(): void;
73
+ /**
74
+ * Check sessions for idle/archived status
75
+ */
76
+ private checkSessions;
77
+ /**
78
+ * Cleanup all sessions
79
+ */
80
+ cleanup(): void;
81
+ }