@co0ontty/wand 0.2.0 → 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/README.md +25 -5
- package/dist/acp-protocol.d.ts +67 -0
- package/dist/acp-protocol.js +291 -0
- package/dist/claude-pty-bridge.d.ts +139 -0
- package/dist/claude-pty-bridge.js +649 -0
- package/dist/claude-stream-adapter.d.ts +35 -0
- package/dist/claude-stream-adapter.js +153 -0
- package/dist/claude-structured-runner.d.ts +27 -0
- package/dist/claude-structured-runner.js +106 -0
- package/dist/config.js +2 -2
- package/dist/message-parser.js +12 -66
- package/dist/message-queue.d.ts +57 -0
- package/dist/message-queue.js +127 -0
- package/dist/process-manager.d.ts +32 -25
- package/dist/process-manager.js +503 -780
- package/dist/server.js +366 -51
- package/dist/session-lifecycle.d.ts +81 -0
- package/dist/session-lifecycle.js +176 -0
- package/dist/storage.js +12 -1
- package/dist/types.d.ts +105 -5
- package/dist/web-ui/content/scripts.js +2307 -658
- package/dist/web-ui/content/styles.css +5284 -2771
- package/dist/web-ui/index.js +8 -5
- package/dist/web-ui/scripts.js +8 -1
- package/package.json +3 -10
- package/dist/web-ui/utils.d.ts +0 -4
- package/dist/web-ui/utils.js +0 -12
- package/dist/web-ui.d.ts +0 -1
- package/dist/web-ui.js +0 -2
package/dist/server.js
CHANGED
|
@@ -1,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
|
|
212
|
-
{ src: "/icon-
|
|
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
|
|
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.
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
258
|
-
|
|
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(
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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 =
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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 =
|
|
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 =
|
|
665
|
+
const favorites = parseStoredPathList(stored);
|
|
452
666
|
// Check if already exists
|
|
453
|
-
if (favorites.some((f) => f.path ===
|
|
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:
|
|
459
|
-
name: name || path.basename(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
716
|
+
let recent = parseStoredPathList(stored);
|
|
498
717
|
// Remove existing entry for this path (to update position)
|
|
499
|
-
recent = recent.filter((r) => r.path !==
|
|
718
|
+
recent = recent.filter((r) => normalizeFolderPath(r.path) !== resolvedRecentPath);
|
|
500
719
|
// Add to front
|
|
501
720
|
const newRecent = {
|
|
502
|
-
path:
|
|
503
|
-
name: path.basename(
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|