@co0ontty/wand 1.10.0 → 1.14.2
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 +48 -12
- package/dist/config.d.ts +2 -1
- package/dist/config.js +51 -0
- package/dist/message-truncator.d.ts +16 -0
- package/dist/message-truncator.js +76 -0
- package/dist/process-manager.d.ts +4 -0
- package/dist/process-manager.js +74 -21
- package/dist/server-session-routes.js +29 -1
- package/dist/server.js +276 -11
- package/dist/structured-session-manager.d.ts +2 -0
- package/dist/structured-session-manager.js +10 -0
- package/dist/types.d.ts +28 -0
- package/dist/web-ui/content/scripts.js +782 -67
- package/dist/web-ui/content/styles.css +160 -27
- package/dist/ws-broadcast.d.ts +3 -2
- package/dist/ws-broadcast.js +8 -2
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import express from "express";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
3
|
+
import { createReadStream, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
|
|
4
5
|
import { createServer as createHttpServer } from "node:http";
|
|
5
6
|
import { createServer as createHttpsServer } from "node:https";
|
|
6
7
|
import { exec, spawn } from "node:child_process";
|
|
@@ -11,7 +12,7 @@ import { WebSocketServer } from "ws";
|
|
|
11
12
|
import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
|
|
12
13
|
import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
|
|
13
14
|
import { ensureCertificates } from "./cert.js";
|
|
14
|
-
import { isExecutionMode, resolveConfigDir, saveConfig } from "./config.js";
|
|
15
|
+
import { isExecutionMode, normalizeCardDefaults, resolveConfigDir, saveConfig } from "./config.js";
|
|
15
16
|
import { ProcessManager } from "./process-manager.js";
|
|
16
17
|
import { StructuredSessionManager } from "./structured-session-manager.js";
|
|
17
18
|
import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
|
|
@@ -65,6 +66,65 @@ function compareSemver(a, b) {
|
|
|
65
66
|
}
|
|
66
67
|
return 0;
|
|
67
68
|
}
|
|
69
|
+
let cachedGitHubApk = null;
|
|
70
|
+
let gitHubApkCacheTs = 0;
|
|
71
|
+
const GITHUB_APK_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
|
|
72
|
+
async function fetchGitHubLatestApk(forceRefresh = false) {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
if (!forceRefresh && cachedGitHubApk && (now - gitHubApkCacheTs < GITHUB_APK_CACHE_TTL)) {
|
|
75
|
+
return cachedGitHubApk;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const apiUrl = PKG_REPO_URL.replace("github.com", "api.github.com/repos") + "/releases/latest";
|
|
79
|
+
const resp = await fetch(apiUrl, {
|
|
80
|
+
headers: { "Accept": "application/vnd.github.v3+json", "User-Agent": "wand-server" },
|
|
81
|
+
signal: AbortSignal.timeout(10000),
|
|
82
|
+
});
|
|
83
|
+
if (!resp.ok)
|
|
84
|
+
return cachedGitHubApk ?? null;
|
|
85
|
+
const release = await resp.json();
|
|
86
|
+
const apkAsset = release.assets.find(a => a.name.toLowerCase().endsWith(".apk"));
|
|
87
|
+
if (!apkAsset)
|
|
88
|
+
return cachedGitHubApk ?? null;
|
|
89
|
+
const version = extractAndroidApkVersion(release.tag_name) ?? release.tag_name.replace(/^v/, "");
|
|
90
|
+
cachedGitHubApk = {
|
|
91
|
+
version,
|
|
92
|
+
downloadUrl: apkAsset.browser_download_url,
|
|
93
|
+
fileName: apkAsset.name,
|
|
94
|
+
size: apkAsset.size,
|
|
95
|
+
};
|
|
96
|
+
gitHubApkCacheTs = now;
|
|
97
|
+
return cachedGitHubApk;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return cachedGitHubApk ?? null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function resolveLatestApkVersion(configDir, config) {
|
|
104
|
+
// Priority 1: local APK file
|
|
105
|
+
const localApk = await resolveAndroidApkAsset(configDir, config);
|
|
106
|
+
if (localApk && localApk.version) {
|
|
107
|
+
return {
|
|
108
|
+
version: localApk.version,
|
|
109
|
+
downloadUrl: localApk.downloadUrl,
|
|
110
|
+
fileName: localApk.fileName,
|
|
111
|
+
size: localApk.size,
|
|
112
|
+
source: "local",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// Priority 2: GitHub Release
|
|
116
|
+
const ghApk = await fetchGitHubLatestApk();
|
|
117
|
+
if (ghApk) {
|
|
118
|
+
return {
|
|
119
|
+
version: ghApk.version,
|
|
120
|
+
downloadUrl: ghApk.downloadUrl,
|
|
121
|
+
fileName: ghApk.fileName,
|
|
122
|
+
size: ghApk.size,
|
|
123
|
+
source: "github",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
68
128
|
function isExternalAvatarSource(value) {
|
|
69
129
|
return /^(https?:|data:)/i.test(value);
|
|
70
130
|
}
|
|
@@ -238,9 +298,99 @@ function readSessionCookie(req) {
|
|
|
238
298
|
const match = cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith("wand_session="));
|
|
239
299
|
return match?.slice("wand_session=".length);
|
|
240
300
|
}
|
|
301
|
+
// ── App connection token helpers ──
|
|
302
|
+
function generateAppToken(password, secret) {
|
|
303
|
+
return crypto.createHmac("sha256", secret).update(password).digest("hex");
|
|
304
|
+
}
|
|
305
|
+
function verifyAppToken(token, password, secret) {
|
|
306
|
+
const expected = generateAppToken(password, secret);
|
|
307
|
+
return crypto.timingSafeEqual(Buffer.from(token, "hex"), Buffer.from(expected, "hex"));
|
|
308
|
+
}
|
|
309
|
+
function encodeConnectCode(url, token) {
|
|
310
|
+
return Buffer.from(`${url}#${token}`).toString("base64");
|
|
311
|
+
}
|
|
312
|
+
function decodeConnectCode(code) {
|
|
313
|
+
try {
|
|
314
|
+
const decoded = Buffer.from(code, "base64").toString("utf8");
|
|
315
|
+
const hashIdx = decoded.lastIndexOf("#");
|
|
316
|
+
if (hashIdx < 1)
|
|
317
|
+
return null;
|
|
318
|
+
const url = decoded.substring(0, hashIdx);
|
|
319
|
+
const token = decoded.substring(hashIdx + 1);
|
|
320
|
+
if (!url.startsWith("http") || token.length < 16)
|
|
321
|
+
return null;
|
|
322
|
+
return { url, token };
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
241
328
|
function normalizeMode(input, fallback) {
|
|
242
329
|
return isExecutionMode(input) ? input : fallback;
|
|
243
330
|
}
|
|
331
|
+
function resolveAndroidApkDir(configDir, config) {
|
|
332
|
+
const configuredDir = config.android?.apkDir?.trim();
|
|
333
|
+
if (!configuredDir) {
|
|
334
|
+
return path.join(configDir, "android");
|
|
335
|
+
}
|
|
336
|
+
return path.isAbsolute(configuredDir) ? configuredDir : path.resolve(configDir, configuredDir);
|
|
337
|
+
}
|
|
338
|
+
function extractAndroidApkVersion(fileName) {
|
|
339
|
+
const nameWithoutExt = fileName.replace(/\.apk$/i, "");
|
|
340
|
+
const match = nameWithoutExt.match(/(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)/);
|
|
341
|
+
return match ? match[1] : null;
|
|
342
|
+
}
|
|
343
|
+
async function resolveAndroidApkAsset(configDir, config) {
|
|
344
|
+
if (config.android?.enabled !== true)
|
|
345
|
+
return null;
|
|
346
|
+
const apkDir = resolveAndroidApkDir(configDir, config);
|
|
347
|
+
await mkdir(apkDir, { recursive: true });
|
|
348
|
+
const configuredFile = config.android?.currentApkFile?.trim();
|
|
349
|
+
if (configuredFile) {
|
|
350
|
+
const filePath = path.join(apkDir, path.basename(configuredFile));
|
|
351
|
+
try {
|
|
352
|
+
const fileStat = await stat(filePath);
|
|
353
|
+
if (!fileStat.isFile())
|
|
354
|
+
return null;
|
|
355
|
+
return {
|
|
356
|
+
fileName: path.basename(filePath),
|
|
357
|
+
filePath,
|
|
358
|
+
size: fileStat.size,
|
|
359
|
+
updatedAt: fileStat.mtime.toISOString(),
|
|
360
|
+
version: extractAndroidApkVersion(path.basename(filePath)),
|
|
361
|
+
downloadUrl: "/android/download",
|
|
362
|
+
source: "local",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const entries = await readdir(apkDir, { withFileTypes: true });
|
|
370
|
+
const apkFiles = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".apk"));
|
|
371
|
+
if (apkFiles.length === 0)
|
|
372
|
+
return null;
|
|
373
|
+
const candidates = await Promise.all(apkFiles.map(async (entry) => {
|
|
374
|
+
const filePath = path.join(apkDir, entry.name);
|
|
375
|
+
const fileStat = await stat(filePath);
|
|
376
|
+
return {
|
|
377
|
+
entry,
|
|
378
|
+
filePath,
|
|
379
|
+
fileStat,
|
|
380
|
+
};
|
|
381
|
+
}));
|
|
382
|
+
candidates.sort((a, b) => b.fileStat.mtimeMs - a.fileStat.mtimeMs);
|
|
383
|
+
const selected = candidates[0];
|
|
384
|
+
return {
|
|
385
|
+
fileName: selected.entry.name,
|
|
386
|
+
filePath: selected.filePath,
|
|
387
|
+
size: selected.fileStat.size,
|
|
388
|
+
updatedAt: selected.fileStat.mtime.toISOString(),
|
|
389
|
+
version: extractAndroidApkVersion(selected.entry.name),
|
|
390
|
+
downloadUrl: "/android/download",
|
|
391
|
+
source: "local",
|
|
392
|
+
};
|
|
393
|
+
}
|
|
244
394
|
async function listPathSuggestions(input, fallbackCwd) {
|
|
245
395
|
const normalizedInput = input.trim();
|
|
246
396
|
const baseInput = normalizedInput || fallbackCwd;
|
|
@@ -415,13 +565,26 @@ export async function startServer(config, configPath) {
|
|
|
415
565
|
res.status(429).json({ error: "登录尝试次数过多,请在 15 分钟后再试。" });
|
|
416
566
|
return;
|
|
417
567
|
}
|
|
418
|
-
const { password } = req.body;
|
|
568
|
+
const { password, appToken } = req.body;
|
|
419
569
|
const dbPassword = storage.getPassword();
|
|
420
570
|
const effectivePassword = dbPassword ?? config.password;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
571
|
+
// App token login — derived from password, so password change invalidates it
|
|
572
|
+
let authenticated = false;
|
|
573
|
+
if (appToken) {
|
|
574
|
+
try {
|
|
575
|
+
authenticated = verifyAppToken(appToken, effectivePassword, config.appSecret ?? "");
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
authenticated = false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (!authenticated) {
|
|
582
|
+
if (password !== effectivePassword) {
|
|
583
|
+
recordFailedLogin(clientIp);
|
|
584
|
+
res.status(401).json({ error: "密码错误,请重试。" });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
authenticated = true;
|
|
425
588
|
}
|
|
426
589
|
resetRateLimit(clientIp);
|
|
427
590
|
const token = createSession();
|
|
@@ -447,6 +610,44 @@ export async function startServer(config, configPath) {
|
|
|
447
610
|
storage.setPassword(password);
|
|
448
611
|
res.json({ ok: true });
|
|
449
612
|
});
|
|
613
|
+
// ── Android APK update & download (no auth required) ──
|
|
614
|
+
app.get("/api/android-apk-update", async (req, res) => {
|
|
615
|
+
const currentVersion = req.query.currentVersion?.trim();
|
|
616
|
+
if (!currentVersion) {
|
|
617
|
+
res.status(400).json({ error: "Missing currentVersion query parameter." });
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const latest = await resolveLatestApkVersion(configDir, config);
|
|
621
|
+
if (!latest) {
|
|
622
|
+
res.json({ updateAvailable: false, currentVersion, latestVersion: null, downloadUrl: null, source: null });
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const updateAvailable = compareSemver(latest.version, currentVersion) > 0;
|
|
626
|
+
res.json({
|
|
627
|
+
updateAvailable,
|
|
628
|
+
currentVersion,
|
|
629
|
+
latestVersion: latest.version,
|
|
630
|
+
downloadUrl: updateAvailable ? latest.downloadUrl : null,
|
|
631
|
+
fileName: updateAvailable ? latest.fileName : null,
|
|
632
|
+
size: updateAvailable ? latest.size : null,
|
|
633
|
+
source: latest.source,
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
app.get("/android/download", async (_req, res) => {
|
|
637
|
+
const androidApk = await resolveAndroidApkAsset(configDir, config);
|
|
638
|
+
if (config.android?.enabled !== true) {
|
|
639
|
+
res.status(404).json({ error: "Android APK 下载未启用。" });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
if (!androidApk) {
|
|
643
|
+
res.status(404).json({ error: "当前没有可下载的 APK 文件。" });
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
res.setHeader("Content-Type", "application/vnd.android.package-archive");
|
|
647
|
+
res.setHeader("Content-Length", String(androidApk.size));
|
|
648
|
+
res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(androidApk.fileName)}"`);
|
|
649
|
+
createReadStream(androidApk.filePath).pipe(res);
|
|
650
|
+
});
|
|
450
651
|
app.use("/api", requireAuth);
|
|
451
652
|
// ── Config & Session info ──
|
|
452
653
|
app.get("/api/config", async (_req, res) => {
|
|
@@ -459,18 +660,28 @@ export async function startServer(config, configPath) {
|
|
|
459
660
|
commandPresets: config.commandPresets,
|
|
460
661
|
structuredRunners: [{ label: "Claude Structured", runner: "claude-cli-print" }],
|
|
461
662
|
structuredChatPersona,
|
|
663
|
+
cardDefaults: config.cardDefaults,
|
|
462
664
|
updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
|
|
463
665
|
latestVersion: cachedUpdateInfo?.latest ?? null,
|
|
464
666
|
currentVersion: PKG_VERSION,
|
|
465
667
|
});
|
|
466
668
|
});
|
|
467
669
|
// ── Settings endpoints ──
|
|
468
|
-
app.get("/api/settings", (_req, res) => {
|
|
670
|
+
app.get("/api/settings", async (_req, res) => {
|
|
469
671
|
const certPaths = {
|
|
470
672
|
keyPath: path.join(configDir, "server.key"),
|
|
471
673
|
certPath: path.join(configDir, "server.crt"),
|
|
472
674
|
};
|
|
473
675
|
const { password: _pw, ...safeConfig } = config;
|
|
676
|
+
const localApk = await resolveAndroidApkAsset(configDir, config);
|
|
677
|
+
const ghApk = await fetchGitHubLatestApk();
|
|
678
|
+
const apkDir = resolveAndroidApkDir(configDir, config);
|
|
679
|
+
// Backward-compatible: pick best available for hasApk/version/downloadUrl
|
|
680
|
+
const resolvedApk = localApk
|
|
681
|
+
? { hasApk: true, fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl, source: "local" }
|
|
682
|
+
: ghApk
|
|
683
|
+
? { hasApk: true, fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, updatedAt: null, downloadUrl: ghApk.downloadUrl, source: "github" }
|
|
684
|
+
: null;
|
|
474
685
|
res.json({
|
|
475
686
|
version: PKG_VERSION,
|
|
476
687
|
packageName: PKG_NAME,
|
|
@@ -480,8 +691,55 @@ export async function startServer(config, configPath) {
|
|
|
480
691
|
hasCert: existsSync(certPaths.keyPath) && existsSync(certPaths.certPath),
|
|
481
692
|
updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
|
|
482
693
|
latestVersion: cachedUpdateInfo?.latest ?? null,
|
|
694
|
+
androidApk: {
|
|
695
|
+
enabled: config.android?.enabled === true,
|
|
696
|
+
apkDir,
|
|
697
|
+
hasApk: resolvedApk?.hasApk ?? false,
|
|
698
|
+
fileName: resolvedApk?.fileName ?? null,
|
|
699
|
+
version: resolvedApk?.version ?? null,
|
|
700
|
+
size: resolvedApk?.size ?? null,
|
|
701
|
+
updatedAt: resolvedApk?.updatedAt ?? null,
|
|
702
|
+
downloadUrl: resolvedApk?.downloadUrl ?? null,
|
|
703
|
+
source: resolvedApk?.source ?? null,
|
|
704
|
+
local: localApk ? { fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl } : null,
|
|
705
|
+
github: ghApk ? { fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, downloadUrl: ghApk.downloadUrl } : null,
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
app.get("/api/android-apk", async (_req, res) => {
|
|
710
|
+
const localApk = await resolveAndroidApkAsset(configDir, config);
|
|
711
|
+
const ghApk = await fetchGitHubLatestApk();
|
|
712
|
+
const apkDir = resolveAndroidApkDir(configDir, config);
|
|
713
|
+
const resolvedApk = localApk
|
|
714
|
+
? { hasApk: true, fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl, source: "local" }
|
|
715
|
+
: ghApk
|
|
716
|
+
? { hasApk: true, fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, updatedAt: null, downloadUrl: ghApk.downloadUrl, source: "github" }
|
|
717
|
+
: null;
|
|
718
|
+
res.json({
|
|
719
|
+
enabled: config.android?.enabled === true,
|
|
720
|
+
apkDir,
|
|
721
|
+
hasApk: resolvedApk?.hasApk ?? false,
|
|
722
|
+
fileName: resolvedApk?.fileName ?? null,
|
|
723
|
+
version: resolvedApk?.version ?? null,
|
|
724
|
+
size: resolvedApk?.size ?? null,
|
|
725
|
+
updatedAt: resolvedApk?.updatedAt ?? null,
|
|
726
|
+
downloadUrl: resolvedApk?.downloadUrl ?? null,
|
|
727
|
+
source: resolvedApk?.source ?? null,
|
|
728
|
+
local: localApk ? { fileName: localApk.fileName, version: localApk.version, size: localApk.size, updatedAt: localApk.updatedAt, downloadUrl: localApk.downloadUrl } : null,
|
|
729
|
+
github: ghApk ? { fileName: ghApk.fileName, version: ghApk.version, size: ghApk.size, downloadUrl: ghApk.downloadUrl } : null,
|
|
483
730
|
});
|
|
484
731
|
});
|
|
732
|
+
app.get("/api/app-connect-code", requireAuth, (req, res) => {
|
|
733
|
+
const dbPassword = storage.getPassword();
|
|
734
|
+
const effectivePassword = dbPassword ?? config.password;
|
|
735
|
+
const protocol = useHttps ? "https" : "http";
|
|
736
|
+
const host = req.headers.host || `${config.host}:${config.port}`;
|
|
737
|
+
const serverUrl = `${protocol}://${host}`;
|
|
738
|
+
const appSecret = config.appSecret ?? "";
|
|
739
|
+
const token = generateAppToken(effectivePassword, appSecret);
|
|
740
|
+
const code = encodeConnectCode(serverUrl, token);
|
|
741
|
+
res.json({ code });
|
|
742
|
+
});
|
|
485
743
|
app.post("/api/settings/config", async (req, res) => {
|
|
486
744
|
const body = req.body;
|
|
487
745
|
const allowedFields = ["host", "port", "https", "defaultMode", "defaultCwd", "shell", "language"];
|
|
@@ -521,14 +779,21 @@ export async function startServer(config, configPath) {
|
|
|
521
779
|
changed = true;
|
|
522
780
|
}
|
|
523
781
|
}
|
|
782
|
+
// Handle cardDefaults separately (nested object, no restart needed)
|
|
783
|
+
if (body.cardDefaults !== undefined) {
|
|
784
|
+
config.cardDefaults = normalizeCardDefaults(body.cardDefaults);
|
|
785
|
+
changed = true;
|
|
786
|
+
}
|
|
524
787
|
if (!changed) {
|
|
525
788
|
res.status(400).json({ error: "没有可更新的配置字段。" });
|
|
526
789
|
return;
|
|
527
790
|
}
|
|
791
|
+
// cardDefaults-only changes don't need restart
|
|
792
|
+
const restartRequired = allowedFields.some((f) => f in body && body[f] !== undefined);
|
|
528
793
|
try {
|
|
529
794
|
await saveConfig(configPath, config);
|
|
530
795
|
const { password: _pw, ...safeConfig } = config;
|
|
531
|
-
res.json({ ok: true, config: safeConfig, restartRequired
|
|
796
|
+
res.json({ ok: true, config: safeConfig, restartRequired });
|
|
532
797
|
}
|
|
533
798
|
catch (error) {
|
|
534
799
|
res.status(500).json({ error: getErrorMessage(error, "保存配置失败。") });
|
|
@@ -878,7 +1143,7 @@ export async function startServer(config, configPath) {
|
|
|
878
1143
|
})()
|
|
879
1144
|
: createHttpServer(app);
|
|
880
1145
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
881
|
-
const wsManager = new WsBroadcastManager(wss);
|
|
1146
|
+
const wsManager = new WsBroadcastManager(wss, () => config.cardDefaults ?? {});
|
|
882
1147
|
wsManager.setup((id) => structuredSessions.get(id) ?? processes.get(id));
|
|
883
1148
|
// Wire process events to WebSocket broadcast
|
|
884
1149
|
processes.on("process", (event) => {
|
|
@@ -16,6 +16,8 @@ export declare class StructuredSessionManager {
|
|
|
16
16
|
constructor(storage: WandStorage, config: WandConfig);
|
|
17
17
|
setEventEmitter(emitEvent: (event: ProcessEvent) => void): void;
|
|
18
18
|
list(): SessionSnapshot[];
|
|
19
|
+
/** Return lightweight snapshots for the session list (no output/messages). */
|
|
20
|
+
listSlim(): SessionSnapshot[];
|
|
19
21
|
get(id: string): SessionSnapshot | null;
|
|
20
22
|
createSession(options: CreateStructuredSessionOptions): SessionSnapshot;
|
|
21
23
|
sendMessage(id: string, input: string): Promise<SessionSnapshot>;
|
|
@@ -80,6 +80,16 @@ export class StructuredSessionManager {
|
|
|
80
80
|
.map(withSummary)
|
|
81
81
|
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
82
82
|
}
|
|
83
|
+
/** Return lightweight snapshots for the session list (no output/messages). */
|
|
84
|
+
listSlim() {
|
|
85
|
+
return Array.from(this.sessions.values())
|
|
86
|
+
.map((s) => {
|
|
87
|
+
const enriched = withSummary(s);
|
|
88
|
+
const { output: _o, messages: _m, ...slim } = enriched;
|
|
89
|
+
return { ...slim, output: "" };
|
|
90
|
+
})
|
|
91
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
92
|
+
}
|
|
83
93
|
get(id) {
|
|
84
94
|
const s = this.sessions.get(id);
|
|
85
95
|
return s ? withSummary(s) : null;
|
package/dist/types.d.ts
CHANGED
|
@@ -47,6 +47,23 @@ export interface StructuredChatPersonaConfig {
|
|
|
47
47
|
user?: StructuredChatPersonaRoleConfig;
|
|
48
48
|
assistant?: StructuredChatPersonaRoleConfig;
|
|
49
49
|
}
|
|
50
|
+
export interface CardExpandDefaults {
|
|
51
|
+
/** Edit/Write/MultiEdit diff cards (default: false) */
|
|
52
|
+
editCards?: boolean;
|
|
53
|
+
/** Read/Glob/Grep/WebFetch/WebSearch inline tools (default: false) */
|
|
54
|
+
inlineTools?: boolean;
|
|
55
|
+
/** Bash terminal output (default: false) */
|
|
56
|
+
terminal?: boolean;
|
|
57
|
+
/** Thinking blocks (default: false) */
|
|
58
|
+
thinking?: boolean;
|
|
59
|
+
/** Tool groups (default: false) */
|
|
60
|
+
toolGroup?: boolean;
|
|
61
|
+
}
|
|
62
|
+
export interface AndroidApkConfig {
|
|
63
|
+
enabled?: boolean;
|
|
64
|
+
apkDir?: string;
|
|
65
|
+
currentApkFile?: string;
|
|
66
|
+
}
|
|
50
67
|
export interface WandConfig {
|
|
51
68
|
host: string;
|
|
52
69
|
port: number;
|
|
@@ -64,6 +81,11 @@ export interface WandConfig {
|
|
|
64
81
|
shortcutLogMaxBytes?: number;
|
|
65
82
|
/** Preferred response language for Claude (e.g. "中文", "English"). Empty string means no override. */
|
|
66
83
|
language?: string;
|
|
84
|
+
/** Per-instance secret for app connection code encryption. Auto-generated on first run. */
|
|
85
|
+
appSecret?: string;
|
|
86
|
+
android?: AndroidApkConfig;
|
|
87
|
+
/** Default expand/collapse state for card types in structured chat view */
|
|
88
|
+
cardDefaults?: CardExpandDefaults;
|
|
67
89
|
}
|
|
68
90
|
interface WorktreeInfo {
|
|
69
91
|
branch: string;
|
|
@@ -167,6 +189,10 @@ export interface ToolResultBlock {
|
|
|
167
189
|
[key: string]: unknown;
|
|
168
190
|
}>;
|
|
169
191
|
is_error?: boolean;
|
|
192
|
+
/** When true, content has been truncated for transport. Client should fetch full content via API. */
|
|
193
|
+
_truncated?: boolean;
|
|
194
|
+
/** Original content size in bytes, provided when truncated. */
|
|
195
|
+
_originalSize?: number;
|
|
170
196
|
}
|
|
171
197
|
export type ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock;
|
|
172
198
|
export interface ConversationTurn {
|
|
@@ -247,6 +273,8 @@ export interface SessionSnapshot {
|
|
|
247
273
|
};
|
|
248
274
|
/** 会话摘要:从首条用户消息或当前任务提取 */
|
|
249
275
|
summary?: string;
|
|
276
|
+
/** 当前正在执行的任务标题(用于会话列表展示) */
|
|
277
|
+
currentTaskTitle?: string;
|
|
250
278
|
}
|
|
251
279
|
export type SessionLifecycleState = "initializing" | "running" | "idle" | "thinking" | "waiting-input" | "archived";
|
|
252
280
|
export interface SessionLifecycle {
|