@co0ontty/wand 1.49.0 → 1.49.1-beta.gb7e61be

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "6066201e006fdce04120be0b3e0add7d68770375",
3
- "builtAt": "2026-06-02T01:47:00.843Z",
4
- "version": "1.49.0",
5
- "channel": "stable"
2
+ "commit": "b7e61be45180349fc6c31b05ead4290ca1c1781a",
3
+ "builtAt": "2026-06-03T15:02:04.068Z",
4
+ "version": "1.49.1-beta.gb7e61be",
5
+ "channel": "beta"
6
6
  }
package/dist/server.js CHANGED
@@ -595,7 +595,22 @@ async function resolveMacosDmgAsset(configDir, config) {
595
595
  fileStat,
596
596
  };
597
597
  }));
598
- candidates.sort((a, b) => b.fileStat.mtimeMs - a.fileStat.mtimeMs);
598
+ candidates.sort((a, b) => {
599
+ const va = extractMacosDmgVersion(a.entry.name);
600
+ const vb = extractMacosDmgVersion(b.entry.name);
601
+ if (va && vb) {
602
+ const cmp = compareSemver(vb, va);
603
+ if (cmp !== 0)
604
+ return cmp;
605
+ }
606
+ else if (va && !vb) {
607
+ return -1;
608
+ }
609
+ else if (!va && vb) {
610
+ return 1;
611
+ }
612
+ return b.fileStat.mtimeMs - a.fileStat.mtimeMs;
613
+ });
599
614
  const selected = candidates[0];
600
615
  return {
601
616
  fileName: selected.entry.name,
@@ -807,6 +822,73 @@ function classifyFile(ext, baseName) {
807
822
  function mimeForExt(ext) {
808
823
  return MIME_BY_EXT[ext.toLowerCase()] || "application/octet-stream";
809
824
  }
825
+ function parseByteRange(rangeHeader, total) {
826
+ if (!rangeHeader)
827
+ return null;
828
+ const trimmed = rangeHeader.trim();
829
+ if (!trimmed.startsWith("bytes="))
830
+ return null;
831
+ const match = /^bytes=(\d*)-(\d*)$/.exec(trimmed);
832
+ if (!match || (match[1] === "" && match[2] === ""))
833
+ return "invalid";
834
+ let start;
835
+ let end;
836
+ if (match[1] === "") {
837
+ const suffixLength = Number(match[2]);
838
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0)
839
+ return "invalid";
840
+ start = Math.max(0, total - suffixLength);
841
+ end = total - 1;
842
+ }
843
+ else {
844
+ start = Number(match[1]);
845
+ end = match[2] === "" ? total - 1 : Math.min(Number(match[2]), total - 1);
846
+ }
847
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start > end || start >= total) {
848
+ return "invalid";
849
+ }
850
+ return { start, end };
851
+ }
852
+ function streamFileWithRange(req, res, options) {
853
+ res.setHeader("Content-Type", options.contentType);
854
+ if (options.disposition)
855
+ res.setHeader("Content-Disposition", options.disposition);
856
+ for (const [name, value] of Object.entries(options.headers ?? {})) {
857
+ res.setHeader(name, value);
858
+ }
859
+ res.setHeader("Accept-Ranges", "bytes");
860
+ if (options.size === 0) {
861
+ if (req.headers.range?.trim().startsWith("bytes=")) {
862
+ res.status(416).setHeader("Content-Range", "bytes */0").end();
863
+ return;
864
+ }
865
+ res.setHeader("Content-Length", "0");
866
+ res.end();
867
+ return;
868
+ }
869
+ const parsedRange = parseByteRange(req.headers.range, options.size);
870
+ if (parsedRange === "invalid") {
871
+ res.status(416).setHeader("Content-Range", `bytes */${options.size}`).end();
872
+ return;
873
+ }
874
+ const start = parsedRange?.start ?? 0;
875
+ const end = parsedRange?.end ?? options.size - 1;
876
+ if (parsedRange) {
877
+ res.status(206);
878
+ res.setHeader("Content-Range", `bytes ${start}-${end}/${options.size}`);
879
+ }
880
+ res.setHeader("Content-Length", String(end - start + 1));
881
+ const stream = createReadStream(options.filePath, { start, end });
882
+ stream.on("error", (err) => {
883
+ if (!res.headersSent) {
884
+ res.status(500).json({ error: getErrorMessage(err, options.readErrorMessage ?? "读取文件失败。") });
885
+ }
886
+ else {
887
+ res.destroy();
888
+ }
889
+ });
890
+ stream.pipe(res);
891
+ }
810
892
  export async function startServer(config, configPath) {
811
893
  // 关键:在创建 ProcessManager / 任何 spawn 之前先修 PATH。
812
894
  // 服务被注册为 systemd / launchd 时,unit 文件里的 PATH 是安装那一刻烧死的,
@@ -1021,53 +1103,13 @@ export async function startServer(config, configPath) {
1021
1103
  res.status(404).json({ error: "当前没有可下载的 APK 文件。" });
1022
1104
  return;
1023
1105
  }
1024
- const total = androidApk.size;
1025
- res.setHeader("Content-Type", "application/vnd.android.package-archive");
1026
- res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(androidApk.fileName)}"`);
1027
- // 声明支持断点续传 — 弱网/移动网络下中断后客户端可带 Range 续传, 而非从头重下。
1028
- res.setHeader("Accept-Ranges", "bytes");
1029
- // 解析 Range: bytes=start-end (含后缀范围 bytes=-N)。命中则返回 206 + 局部内容。
1030
- let start = 0;
1031
- let end = total - 1;
1032
- let isPartial = false;
1033
- const rangeHeader = req.headers.range;
1034
- if (rangeHeader) {
1035
- const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
1036
- if (match && (match[1] !== "" || match[2] !== "")) {
1037
- if (match[1] === "") {
1038
- // bytes=-N: 最后 N 字节
1039
- start = Math.max(0, total - Number(match[2]));
1040
- end = total - 1;
1041
- }
1042
- else {
1043
- start = Number(match[1]);
1044
- end = match[2] === "" ? total - 1 : Math.min(Number(match[2]), total - 1);
1045
- }
1046
- isPartial = true;
1047
- if (start > end || start < 0 || start >= total) {
1048
- res.status(416);
1049
- res.setHeader("Content-Range", `bytes */${total}`);
1050
- res.end();
1051
- return;
1052
- }
1053
- }
1054
- }
1055
- if (isPartial) {
1056
- res.status(206);
1057
- res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
1058
- }
1059
- res.setHeader("Content-Length", String(end - start + 1));
1060
- const stream = createReadStream(androidApk.filePath, { start, end });
1061
- stream.on("error", (err) => {
1062
- // 文件在 stat 之后被删/读错误时, 让客户端尽快感知断流, 而不是等满读超时。
1063
- if (!res.headersSent) {
1064
- res.status(500).json({ error: getErrorMessage(err, "读取 APK 文件失败。") });
1065
- }
1066
- else {
1067
- res.destroy();
1068
- }
1106
+ streamFileWithRange(req, res, {
1107
+ filePath: androidApk.filePath,
1108
+ size: androidApk.size,
1109
+ contentType: "application/vnd.android.package-archive",
1110
+ disposition: `attachment; filename="${encodeURIComponent(androidApk.fileName)}"`,
1111
+ readErrorMessage: "读取 APK 文件失败。",
1069
1112
  });
1070
- stream.pipe(res);
1071
1113
  });
1072
1114
  // ── macOS DMG update & download (no auth required) ──
1073
1115
  app.get("/api/macos-dmg-update", async (req, res) => {
@@ -1092,7 +1134,7 @@ export async function startServer(config, configPath) {
1092
1134
  source: latest.source,
1093
1135
  });
1094
1136
  });
1095
- app.get("/macos/download", async (_req, res) => {
1137
+ app.get("/macos/download", async (req, res) => {
1096
1138
  if (config.macos?.enabled !== true) {
1097
1139
  res.status(404).json({ error: "macOS DMG 下载未启用。" });
1098
1140
  return;
@@ -1102,10 +1144,13 @@ export async function startServer(config, configPath) {
1102
1144
  res.status(404).json({ error: "当前没有可下载的 DMG 文件。" });
1103
1145
  return;
1104
1146
  }
1105
- res.setHeader("Content-Type", "application/x-apple-diskimage");
1106
- res.setHeader("Content-Length", String(macosDmg.size));
1107
- res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(macosDmg.fileName)}"`);
1108
- createReadStream(macosDmg.filePath).pipe(res);
1147
+ streamFileWithRange(req, res, {
1148
+ filePath: macosDmg.filePath,
1149
+ size: macosDmg.size,
1150
+ contentType: "application/x-apple-diskimage",
1151
+ disposition: `attachment; filename="${encodeURIComponent(macosDmg.fileName)}"`,
1152
+ readErrorMessage: "读取 DMG 文件失败。",
1153
+ });
1109
1154
  });
1110
1155
  // Public probe so the unauthenticated browser does not log a 401 on /api/config
1111
1156
  app.get("/api/session-check", (req, res) => {
@@ -1773,41 +1818,17 @@ export async function startServer(config, configPath) {
1773
1818
  const disposition = asDownload
1774
1819
  ? `attachment; filename*=UTF-8''${encodedName}`
1775
1820
  : `inline; filename*=UTF-8''${encodedName}`;
1776
- const total = fileStat.size;
1777
- const range = req.headers.range;
1778
- // Safe to cache raw bytes briefly inside the user's browser.
1779
- res.setHeader("Cache-Control", "private, max-age=60");
1780
- res.setHeader("Content-Type", contentType);
1781
- res.setHeader("Content-Disposition", disposition);
1782
- res.setHeader("Accept-Ranges", "bytes");
1783
- res.setHeader("X-Content-Type-Options", "nosniff");
1784
- if (range && /^bytes=/.test(range)) {
1785
- const match = /^bytes=(\d*)-(\d*)$/.exec(range);
1786
- if (!match) {
1787
- res.status(416).setHeader("Content-Range", `bytes */${total}`).end();
1788
- return;
1789
- }
1790
- const startStr = match[1];
1791
- const endStr = match[2];
1792
- let start = startStr === "" ? 0 : parseInt(startStr, 10);
1793
- let end = endStr === "" ? total - 1 : parseInt(endStr, 10);
1794
- if (Number.isNaN(start) || Number.isNaN(end) || start > end || start < 0 || end >= total) {
1795
- res.status(416).setHeader("Content-Range", `bytes */${total}`).end();
1796
- return;
1797
- }
1798
- const chunkSize = end - start + 1;
1799
- res.status(206);
1800
- res.setHeader("Content-Range", `bytes ${start}-${end}/${total}`);
1801
- res.setHeader("Content-Length", String(chunkSize));
1802
- const stream = createReadStream(resolvedPath, { start, end });
1803
- stream.on("error", () => res.destroy());
1804
- stream.pipe(res);
1805
- return;
1806
- }
1807
- res.setHeader("Content-Length", String(total));
1808
- const stream = createReadStream(resolvedPath);
1809
- stream.on("error", () => res.destroy());
1810
- stream.pipe(res);
1821
+ streamFileWithRange(req, res, {
1822
+ filePath: resolvedPath,
1823
+ size: fileStat.size,
1824
+ contentType,
1825
+ disposition,
1826
+ headers: {
1827
+ "Cache-Control": "private, max-age=60",
1828
+ "X-Content-Type-Options": "nosniff",
1829
+ },
1830
+ readErrorMessage: "Failed to read file",
1831
+ });
1811
1832
  }
1812
1833
  catch (error) {
1813
1834
  res.status(400).json({ error: getErrorMessage(error, "Failed to read file") });
@@ -1837,10 +1858,11 @@ export async function startServer(config, configPath) {
1837
1858
  res.json({ currentPath: targetPath, items });
1838
1859
  }
1839
1860
  catch (error) {
1840
- if (error.code === "ENOENT") {
1861
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
1862
+ if (code === "ENOENT") {
1841
1863
  res.status(404).json({ error: "路径不存在:" + q, currentPath: q, items: [] });
1842
1864
  }
1843
- else if (error.code === "EACCES") {
1865
+ else if (code === "EACCES") {
1844
1866
  res.status(403).json({ error: "权限不足,无法访问:" + q, currentPath: q, items: [] });
1845
1867
  }
1846
1868
  else {
@@ -149,6 +149,8 @@ export declare class StructuredSessionManager {
149
149
  private runClaudeSdkStreaming;
150
150
  private extractAssistantMessage;
151
151
  private compactContentBlocks;
152
+ private buildCompletedAssistantMessages;
153
+ private resolveQueuedMessagesAfterInterrupt;
152
154
  private normalizeToolInput;
153
155
  private normalizeToolResultContent;
154
156
  private extractCodexText;
@@ -1315,17 +1315,7 @@ export class StructuredSessionManager {
1315
1315
  reject(new Error(errorText));
1316
1316
  return;
1317
1317
  }
1318
- const assistantTurn = {
1319
- role: "assistant",
1320
- content: this.compactContentBlocks([...turnState.blocks], turnState.result),
1321
- usage: turnState.usage,
1322
- };
1323
- const msgs = [...(current.messages ?? [])];
1324
- const lastMsg = msgs[msgs.length - 1];
1325
- if (lastMsg && lastMsg.role === "assistant")
1326
- msgs[msgs.length - 1] = assistantTurn;
1327
- else
1328
- msgs.push(assistantTurn);
1318
+ const msgs = this.buildCompletedAssistantMessages(current, turnState);
1329
1319
  const keepRunning = !!interruptPrompt;
1330
1320
  const finished = {
1331
1321
  ...current,
@@ -1335,7 +1325,7 @@ export class StructuredSessionManager {
1335
1325
  output: turnState.result,
1336
1326
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1337
1327
  messages: msgs,
1338
- queuedMessages: interruptPrompt && !this.preserveQueueOnInterrupt.has(sessionId) ? [] : current.queuedMessages,
1328
+ queuedMessages: this.resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt),
1339
1329
  pendingEscalation: null,
1340
1330
  permissionBlocked: false,
1341
1331
  structuredState: {
@@ -1843,22 +1833,7 @@ export class StructuredSessionManager {
1843
1833
  reject(new Error(errorText));
1844
1834
  return;
1845
1835
  }
1846
- // Build the final assistant turn.
1847
- const finalContent = this.compactContentBlocks([...turnState.blocks], turnState.result);
1848
- const assistantTurn = {
1849
- role: "assistant",
1850
- content: finalContent,
1851
- usage: turnState.usage,
1852
- };
1853
- // Ensure the final messages list has the completed assistant turn.
1854
- const msgs = [...(current.messages ?? [])];
1855
- const lastMsg = msgs[msgs.length - 1];
1856
- if (lastMsg && lastMsg.role === "assistant") {
1857
- msgs[msgs.length - 1] = assistantTurn;
1858
- }
1859
- else {
1860
- msgs.push(assistantTurn);
1861
- }
1836
+ const msgs = this.buildCompletedAssistantMessages(current, turnState);
1862
1837
  // 被 AskUserQuestion 检测或用户中断主动 kill 时,保持 status="running"
1863
1838
  // 让 UI 不跳到"已停止"。inFlight=false 才能触发后续 sendMessage。
1864
1839
  const interruptPrompt = this.interruptedWith.get(sessionId);
@@ -1871,7 +1846,7 @@ export class StructuredSessionManager {
1871
1846
  output: turnState.result,
1872
1847
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1873
1848
  messages: msgs,
1874
- queuedMessages: interruptPrompt && !this.preserveQueueOnInterrupt.has(sessionId) ? [] : current.queuedMessages,
1849
+ queuedMessages: this.resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt),
1875
1850
  pendingEscalation: null,
1876
1851
  permissionBlocked: false,
1877
1852
  structuredState: {
@@ -2352,19 +2327,7 @@ export class StructuredSessionManager {
2352
2327
  sessionId: turnState.sessionId,
2353
2328
  });
2354
2329
  const interruptedByUser = this.interruptedWith.has(sessionId);
2355
- // Build final assistant turn
2356
- const finalContent = this.compactContentBlocks([...turnState.blocks], turnState.result);
2357
- const assistantTurn = {
2358
- role: "assistant",
2359
- content: finalContent,
2360
- usage: turnState.usage,
2361
- };
2362
- const msgs = [...(current.messages ?? [])];
2363
- const lastMsg = msgs[msgs.length - 1];
2364
- if (lastMsg && lastMsg.role === "assistant")
2365
- msgs[msgs.length - 1] = assistantTurn;
2366
- else
2367
- msgs.push(assistantTurn);
2330
+ const msgs = this.buildCompletedAssistantMessages(current, turnState);
2368
2331
  const interruptPrompt = this.interruptedWith.get(sessionId);
2369
2332
  const keepRunning = killedForAskUserQuestion || !!interruptPrompt;
2370
2333
  const finished = {
@@ -2375,7 +2338,7 @@ export class StructuredSessionManager {
2375
2338
  output: turnState.result,
2376
2339
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
2377
2340
  messages: msgs,
2378
- queuedMessages: interruptPrompt && !this.preserveQueueOnInterrupt.has(sessionId) ? [] : current.queuedMessages,
2341
+ queuedMessages: this.resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt),
2379
2342
  pendingEscalation: null,
2380
2343
  permissionBlocked: false,
2381
2344
  structuredState: {
@@ -2496,6 +2459,25 @@ export class StructuredSessionManager {
2496
2459
  }
2497
2460
  return compacted;
2498
2461
  }
2462
+ buildCompletedAssistantMessages(current, turnState) {
2463
+ const assistantTurn = {
2464
+ role: "assistant",
2465
+ content: this.compactContentBlocks([...turnState.blocks], turnState.result),
2466
+ usage: turnState.usage,
2467
+ };
2468
+ const msgs = [...(current.messages ?? [])];
2469
+ const lastMsg = msgs[msgs.length - 1];
2470
+ if (lastMsg && lastMsg.role === "assistant")
2471
+ msgs[msgs.length - 1] = assistantTurn;
2472
+ else
2473
+ msgs.push(assistantTurn);
2474
+ return msgs;
2475
+ }
2476
+ resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt) {
2477
+ if (interruptPrompt && !this.preserveQueueOnInterrupt.has(sessionId))
2478
+ return [];
2479
+ return current.queuedMessages;
2480
+ }
2499
2481
  normalizeToolInput(input) {
2500
2482
  if (!input || typeof input !== "object" || Array.isArray(input)) {
2501
2483
  return {};
package/dist/types.d.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  export type SessionKind = "pty" | "structured";
2
- export type SessionCreateKind = "pty" | "structured";
3
2
  export type SessionProvider = "claude" | "codex";
4
3
  export type SessionRunner = "claude-cli" | "claude-cli-print" | "claude-sdk" | "codex-cli-exec" | "pty";
5
4
  export type ExecutionMode = "assist" | "agent" | "agent-max" | "default" | "auto-edit" | "full-access" | "native" | "managed";
@@ -310,10 +309,6 @@ export interface FilePreviewResponse {
310
309
  /** File content; only present when kind === "text". */
311
310
  content?: string;
312
311
  }
313
- export interface ChatMessage {
314
- role: "user" | "assistant";
315
- content: string;
316
- }
317
312
  /**
318
313
  * Meta marker attached to blocks emitted by a Task-spawned subagent. Present
319
314
  * on every block (text / thinking / tool_use / tool_result) whose origin is a
@@ -10,9 +10,11 @@ function sanitizeFilename(name) {
10
10
  export function registerUploadRoutes(app, processes) {
11
11
  const storage = multer.diskStorage({
12
12
  destination(_req, _file, cb) {
13
- const sessionId = _req.params?.id;
14
- const session = sessionId ? processes.get(sessionId) : null;
15
- const cwd = session?.cwd || "/tmp";
13
+ const cwd = _req.uploadCwd;
14
+ if (!cwd) {
15
+ cb(new Error("会话不存在。"), "");
16
+ return;
17
+ }
16
18
  const uploadDir = path.join(cwd, ".wand-uploads");
17
19
  if (!existsSync(uploadDir)) {
18
20
  mkdirSync(uploadDir, { recursive: true });
@@ -30,13 +32,17 @@ export function registerUploadRoutes(app, processes) {
30
32
  storage,
31
33
  limits: { fileSize: MAX_FILE_SIZE, files: MAX_FILES },
32
34
  });
33
- app.post("/api/sessions/:id/upload", upload.array("files", MAX_FILES), (req, res) => {
35
+ function requireUploadSession(req, res, next) {
34
36
  const sessionId = req.params.id;
35
37
  const session = processes.get(sessionId);
36
38
  if (!session) {
37
39
  res.status(404).json({ error: "会话不存在。" });
38
40
  return;
39
41
  }
42
+ req.uploadCwd = session.cwd || "/tmp";
43
+ next();
44
+ }
45
+ app.post("/api/sessions/:id/upload", requireUploadSession, upload.array("files", MAX_FILES), (req, res) => {
40
46
  const files = req.files || [];
41
47
  if (files.length === 0) {
42
48
  res.status(400).json({ error: "未收到文件。" });