@co0ontty/wand 4.48.0 → 4.49.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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "2c450db11f34504be1a3e73354679a0231a91c43",
3
- "builtAt": "2026-08-23T13:37:45.889Z",
4
- "version": "4.48.0",
2
+ "commit": "e521372a025630229f6976b2c6800084e851a8e7",
3
+ "builtAt": "2026-08-23T23:10:59.104Z",
4
+ "version": "4.49.0",
5
5
  "channel": "stable"
6
6
  }
package/dist/config.d.ts CHANGED
@@ -10,6 +10,12 @@ import type { WandStorage } from "./storage.js";
10
10
  export declare const PREFERENCE_KEYS: readonly ["defaultProvider", "defaultSessionKind", "defaultTaskWorktree", "defaultMode", "defaultCwd", "defaultModel", "defaultCodexModel", "defaultOpenCodeModel", "defaultGrokModel", "defaultQoderModel", "defaultPiModel", "commitCli", "commitModel", "commitAiSource", "systemAi", "defaultThinkingEffort", "structuredRunner", "language", "cardDefaults", "inheritEnv"];
11
11
  export type PreferenceKey = (typeof PREFERENCE_KEYS)[number];
12
12
  export declare function isPreferenceKey(key: string): key is PreferenceKey;
13
+ /**
14
+ * The user's login shell from passwd, then $SHELL, then a platform default.
15
+ * Launchd / systemd usually omit $SHELL, so falling back to /bin/bash made PTY
16
+ * sessions look like a generic sh even when the account default is zsh.
17
+ */
18
+ export declare function resolveDefaultShell(): string;
13
19
  export declare const defaultConfig: () => WandConfig;
14
20
  export declare function resolveConfigPath(inputPath?: string): string;
15
21
  export declare function resolveConfigDir(configPath: string): string;
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
4
5
  import path from "node:path";
5
6
  import process from "node:process";
6
7
  import { isRunningAsRoot } from "./env-utils.js";
@@ -50,6 +51,28 @@ function preferenceStorageKey(key) {
50
51
  export function isPreferenceKey(key) {
51
52
  return PREFERENCE_KEY_SET.has(key);
52
53
  }
54
+ /**
55
+ * The user's login shell from passwd, then $SHELL, then a platform default.
56
+ * Launchd / systemd usually omit $SHELL, so falling back to /bin/bash made PTY
57
+ * sessions look like a generic sh even when the account default is zsh.
58
+ */
59
+ export function resolveDefaultShell() {
60
+ if (process.platform === "win32") {
61
+ return process.env.COMSPEC || "cmd.exe";
62
+ }
63
+ try {
64
+ const loginShell = os.userInfo().shell?.trim();
65
+ if (loginShell)
66
+ return loginShell;
67
+ }
68
+ catch {
69
+ // userInfo() throws when the process has no passwd entry.
70
+ }
71
+ const envShell = process.env.SHELL?.trim();
72
+ if (envShell)
73
+ return envShell;
74
+ return process.platform === "darwin" ? "/bin/zsh" : "/bin/bash";
75
+ }
53
76
  export const defaultConfig = () => ({
54
77
  host: "127.0.0.1",
55
78
  port: 8443,
@@ -64,7 +87,7 @@ export const defaultConfig = () => ({
64
87
  // 注意:defaultMode 是偏好字段,只存 SQLite、不写 config.json(见 stripPreferenceFields),
65
88
  // 这里仅作为「用户从未在设置里显式选过模式」时的回落值——显式选择始终优先。
66
89
  defaultMode: isRunningAsRoot() ? "default" : "managed",
67
- shell: process.env.SHELL || "/bin/bash",
90
+ shell: resolveDefaultShell(),
68
91
  defaultCwd: process.cwd(),
69
92
  startupCommands: [],
70
93
  allowedCommandPrefixes: [],
@@ -16,7 +16,13 @@ export interface ResolvedDistributionAsset {
16
16
  size: number;
17
17
  source: "local" | "github";
18
18
  releaseNotes?: string;
19
- /** 本地分发文件的 SHA-256(hex);GitHub 来源不提供,客户端据此跳过校验。 */
19
+ /** 本地文件哈希或 GitHub Release asset.digest;客户端下载后校验。 */
20
+ sha256?: string;
21
+ }
22
+ export interface GitHubApkDownload {
23
+ remoteUrl: string;
24
+ fileName: string;
25
+ size: number;
20
26
  sha256?: string;
21
27
  }
22
28
  export interface DistributionSettings {
@@ -37,6 +43,15 @@ export interface DistributionManagerOptions {
37
43
  * 不该出现在 Android 的更新弹窗。保留分隔线前的变更摘要,并兼容旧版正文。
38
44
  */
39
45
  export declare function extractUpdateSummary(releaseBody: string): string;
46
+ /**
47
+ * GitHub Release 文件名带 semver build metadata(`wand-v4.48.0+202608232136.apk`)。
48
+ * `+` 在 content URI / 部分 OEM 安装器里会被当成空格,下载成功后无法拉起安装。
49
+ * 落盘和对外 fileName 都改成 `-`,版本比较仍用原始 GitHub 文件名提取的 version。
50
+ */
51
+ export declare function sanitizeApkFileName(fileName: string): string;
52
+ /** 解析 GitHub Release asset.digest(`sha256:<hex>`);格式不对返回 undefined。 */
53
+ export declare function parseGithubDigest(digest: string | undefined): string | undefined;
54
+ export declare function githubApkProxyPath(channel?: ApkUpdateChannel): string;
40
55
  export declare class DistributionManager {
41
56
  private readonly options;
42
57
  private readonly fetchImpl;
@@ -44,6 +59,11 @@ export declare class DistributionManager {
44
59
  private readonly githubCache;
45
60
  constructor(options: DistributionManagerOptions);
46
61
  resolveLatestApk(channel: ApkUpdateChannel): Promise<ResolvedDistributionAsset | null>;
62
+ /**
63
+ * 给 `/android/download?source=github` 用:原始 GitHub URL 只留在服务端,
64
+ * 客户端只拿到同源代理地址。fileName 已去掉 `+`。
65
+ */
66
+ resolveGitHubApkDownload(): Promise<GitHubApkDownload | null>;
47
67
  resolveAndroidDownload(channel?: ApkUpdateChannel): Promise<LocalDistributionAsset | null>;
48
68
  /**
49
69
  * 清理 Android 分发目录,只保留最新的一个 APK。在设备开始下载 Beta 包时
@@ -12,6 +12,30 @@ export function extractUpdateSummary(releaseBody) {
12
12
  const summary = releaseBody.split(/\r?\n---\s*(?:\r?\n|$)/, 1)[0]?.trim() ?? "";
13
13
  return summary.slice(0, 500);
14
14
  }
15
+ /**
16
+ * GitHub Release 文件名带 semver build metadata(`wand-v4.48.0+202608232136.apk`)。
17
+ * `+` 在 content URI / 部分 OEM 安装器里会被当成空格,下载成功后无法拉起安装。
18
+ * 落盘和对外 fileName 都改成 `-`,版本比较仍用原始 GitHub 文件名提取的 version。
19
+ */
20
+ export function sanitizeApkFileName(fileName) {
21
+ const base = fileName.replace(/^.*[/\\]/, "").trim() || "wand-update.apk";
22
+ const replaced = base.replaceAll("+", "-").replace(/[^A-Za-z0-9._-]+/g, "-");
23
+ if (!replaced.toLowerCase().endsWith(".apk"))
24
+ return `${replaced || "wand-update"}.apk`;
25
+ return replaced;
26
+ }
27
+ /** 解析 GitHub Release asset.digest(`sha256:<hex>`);格式不对返回 undefined。 */
28
+ export function parseGithubDigest(digest) {
29
+ if (!digest)
30
+ return undefined;
31
+ const match = /^sha256:([a-fA-F0-9]{64})$/.exec(digest.trim());
32
+ return match ? match[1].toLowerCase() : undefined;
33
+ }
34
+ export function githubApkProxyPath(channel) {
35
+ return channel
36
+ ? `/android/download?channel=${channel}&source=github`
37
+ : "/android/download?source=github";
38
+ }
15
39
  function asRecord(value) {
16
40
  return value && typeof value === "object" ? value : null;
17
41
  }
@@ -70,7 +94,14 @@ export class DistributionManager {
70
94
  size: localApk.size,
71
95
  source: "local",
72
96
  } : null;
73
- const github = githubApk ? { ...githubApk, source: "github" } : null;
97
+ const github = githubApk ? {
98
+ ...githubApk,
99
+ source: "github",
100
+ // 手机不要直连 GitHub CDN:检查接口能通只说明 wand server 能访问 api.github.com,
101
+ // objects/release-assets.githubusercontent.com 在移动网上经常被重置。
102
+ fileName: sanitizeApkFileName(githubApk.fileName),
103
+ downloadUrl: githubApkProxyPath(channel),
104
+ } : null;
74
105
  let winner;
75
106
  if (local && github) {
76
107
  winner = compareApkInstallOrder(github.version, local.version) > 0 ? github : local;
@@ -78,7 +109,7 @@ export class DistributionManager {
78
109
  else {
79
110
  winner = local ?? github;
80
111
  }
81
- // 本地分发的 APK 附带 SHA-256,客户端下载后校验(GitHub 来源走正规 TLS,不提供)。
112
+ // 本地分发现算 SHA-256GitHub 来源用 Release digest,随 spread 带上。
82
113
  if (winner?.source === "local" && localApk) {
83
114
  try {
84
115
  const fileStat = await stat(localApk.filePath);
@@ -93,6 +124,21 @@ export class DistributionManager {
93
124
  }
94
125
  return winner;
95
126
  }
127
+ /**
128
+ * 给 `/android/download?source=github` 用:原始 GitHub URL 只留在服务端,
129
+ * 客户端只拿到同源代理地址。fileName 已去掉 `+`。
130
+ */
131
+ async resolveGitHubApkDownload() {
132
+ const asset = await this.fetchGitHubAsset(".apk");
133
+ if (!asset)
134
+ return null;
135
+ return {
136
+ remoteUrl: asset.downloadUrl,
137
+ fileName: sanitizeApkFileName(asset.fileName),
138
+ size: asset.size,
139
+ sha256: asset.sha256,
140
+ };
141
+ }
96
142
  async resolveAndroidDownload(channel = "beta") {
97
143
  await this.refreshConfig();
98
144
  const { config, configDir } = this.options;
@@ -376,11 +422,13 @@ export class DistributionManager {
376
422
  const version = extractArtifactVersion(hit.asset.name, extension)
377
423
  ?? extractArtifactVersion(hit.tagName, extension)
378
424
  ?? hit.tagName.replace(/^v/, "");
425
+ const sha256 = parseGithubDigest(hit.asset.digest);
379
426
  const asset = {
380
427
  version,
381
428
  downloadUrl: hit.asset.browser_download_url,
382
429
  fileName: hit.asset.name,
383
430
  size: hit.asset.size,
431
+ ...(sha256 ? { sha256 } : {}),
384
432
  ...(extension === ".apk" && hit.body ? { releaseNotes: extractUpdateSummary(hit.body) } : {}),
385
433
  };
386
434
  this.githubCache.set(extension, { asset, timestamp: this.now() });
@@ -409,11 +457,18 @@ export class DistributionManager {
409
457
  return null;
410
458
  }
411
459
  buildSettings(kind, directory, enabled, local, github) {
412
- const selected = local
460
+ let selected = local
413
461
  ? { ...local, source: "local" }
414
462
  : github
415
463
  ? { ...github, updatedAt: null, source: "github" }
416
464
  : null;
465
+ if (kind === "apk" && selected?.source === "github" && github) {
466
+ selected = {
467
+ ...selected,
468
+ fileName: sanitizeApkFileName(github.fileName),
469
+ downloadUrl: githubApkProxyPath(),
470
+ };
471
+ }
417
472
  const hasKey = kind === "apk" ? "hasApk" : kind === "dmg" ? "hasDmg" : "hasIpa";
418
473
  const dirKey = kind === "apk" ? "apkDir" : kind === "dmg" ? "dmgDir" : "ipaDir";
419
474
  return {
@@ -14,7 +14,7 @@ import { buildLanguageDirective, buildManagedAutonomyDirective } from "./languag
14
14
  import { prepareSessionWorktree } from "./git-worktree.js";
15
15
  import { getProviderCommandSessionId, getProviderResumeCommandSessionId } from "./resume-policy.js";
16
16
  import { normalizeThinkingEffort, thinkingEffortToClaudeCliEffort, thinkingEffortToClaudeSlashEffort, thinkingEffortToCodexReasoningEffort, thinkingEffortToOpenCodeVariant, thinkingEffortToPiLevel } from "./structured-provider-common.js";
17
- import { SessionTopicCoordinator } from "./session-topic.js";
17
+ import { SessionTopicCoordinator, shouldGenerateSessionTopicFromPtyInput } from "./session-topic.js";
18
18
  import { getErrorMessage } from "./error-utils.js";
19
19
  import { resolveSystemAiContext } from "./session-ai-context.js";
20
20
  import { resolveSessionCwd } from "./session-cwd.js";
@@ -1116,7 +1116,10 @@ export class ProcessManager extends EventEmitter {
1116
1116
  env: buildChildEnv(this.config.inheritEnv !== false, {
1117
1117
  WAND_MODE: effectiveMode,
1118
1118
  WAND_AUTO_CONFIRM: record.autoApprovePermissions ? "1" : "0",
1119
- WAND_AUTO_EDIT: effectiveMode === "auto-edit" ? "1" : "0"
1119
+ WAND_AUTO_EDIT: effectiveMode === "auto-edit" ? "1" : "0",
1120
+ SHELL: this.config.shell,
1121
+ LANG: process.env.LANG || process.env.LC_ALL || process.env.LC_CTYPE || "C.UTF-8",
1122
+ LC_CTYPE: process.env.LC_CTYPE || process.env.LANG || process.env.LC_ALL || "C.UTF-8",
1120
1123
  }),
1121
1124
  name: "xterm-256color",
1122
1125
  // 使用 record 上由前端协商好的真实尺寸,避免"先 120 列、几百毫秒后再 resize"
@@ -1494,7 +1497,7 @@ export class ProcessManager extends EventEmitter {
1494
1497
  console.error(`[ProcessManager] Rejecting input: session ${id} has no PTY`);
1495
1498
  throw new SessionInputError("Session is not running.", "SESSION_NO_PTY", id, record.status);
1496
1499
  }
1497
- if (view !== "terminal")
1500
+ if (shouldGenerateSessionTopicFromPtyInput(view, shortcutKey))
1498
1501
  this.maybeGenerateSessionTopic(id, input);
1499
1502
  // Log shortcut key interactions for auto-confirm and mode analysis
1500
1503
  if (shortcutKey) {
@@ -91,6 +91,15 @@ export class PtyCliExitMarker {
91
91
  return { data: combined, exitCode: null };
92
92
  }
93
93
  }
94
+ function buildTerminalRestoreCommand() {
95
+ // Provider TUIs often die on Ctrl+C without leaving the alternate screen or
96
+ // resetting mouse / bracketed-paste / application-keypad modes. Restore a
97
+ // normal interactive tty before handing the session to the user's shell.
98
+ return [
99
+ "printf '\\033[?1049l\\033[?25h\\033[m\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1006l\\033[?2004l\\r\\n'",
100
+ "stty sane 2>/dev/null || :",
101
+ ].join("; ");
102
+ }
94
103
  function buildPosixProviderShellCommand(command, shell, marker) {
95
104
  // The non-default SIGINT trap keeps the launcher shell alive when Ctrl+C
96
105
  // terminates its foreground CLI. External commands reset caught traps to the
@@ -102,7 +111,10 @@ function buildPosixProviderShellCommand(command, shell, marker) {
102
111
  "trap ':' INT",
103
112
  `if ${command}; then __wand_cli_status=0; else __wand_cli_status=$?; fi`,
104
113
  `printf '\\036WAND_CLI_EXIT:${marker.token}:%s\\037' "$__wand_cli_status"`,
105
- `exec ${quotePosixShell(shell)} -l`,
114
+ buildTerminalRestoreCommand(),
115
+ // -i forces an interactive prompt even if tty detection is confused after a
116
+ // TUI teardown; -l matches Terminal.app / iTerm login-shell startup files.
117
+ `exec ${quotePosixShell(shell)} -il`,
106
118
  ].join("; ");
107
119
  }
108
120
  /**
@@ -114,7 +126,7 @@ export function buildPtyShellLaunchPlan(options) {
114
126
  const platform = options.platform ?? os.platform();
115
127
  if (options.bareShell) {
116
128
  return {
117
- shellArgs: platform === "win32" ? [] : ["-l"],
129
+ shellArgs: platform === "win32" ? [] : ["-il"],
118
130
  cliExitMarker: null,
119
131
  };
120
132
  }
@@ -130,7 +142,7 @@ export function buildPtyShellLaunchPlan(options) {
130
142
  const marker = new PtyCliExitMarker(options.markerToken);
131
143
  return {
132
144
  // Interactive + login initialization matches a real terminal before the
133
- // provider starts; `exec <shell> -l` becomes the persistent prompt after it exits.
145
+ // provider starts; `exec <shell> -il` becomes the persistent prompt after it exits.
134
146
  shellArgs: ["-lic", buildPosixProviderShellCommand(options.command, options.shell, marker)],
135
147
  cliExitMarker: marker,
136
148
  };
@@ -1,4 +1,5 @@
1
1
  import type { Express, RequestHandler } from "express";
2
+ import type { GitHubApkDownload } from "./distribution-manager.js";
2
3
  import type { ModelCatalogService } from "./models.js";
3
4
  import type { PackageUpdateInfo, UpdateChannel } from "./npm-update-utils.js";
4
5
  import { type ProviderCliUpdateStatus } from "./provider-cli-updater.js";
@@ -21,6 +22,8 @@ interface ResolvedUpdateAsset {
21
22
  export interface PublicUpdateRoutesDependencies {
22
23
  resolveLatestApk(channel: "stable" | "beta"): Promise<ResolvedUpdateAsset | null>;
23
24
  resolveAndroidDownload(channel: "stable" | "beta"): Promise<DownloadAsset | null>;
25
+ /** GitHub 来源由服务端代拉,避免手机直连 CDN。测试 stub 可不实现。 */
26
+ resolveGitHubApkDownload?(): Promise<GitHubApkDownload | null>;
24
27
  /** Beta 包下发时清理分发目录,只保留最新一个 APK(可选,测试 stub 可不实现)。 */
25
28
  pruneAndroidApkDirectory?(keepFileName?: string): Promise<{
26
29
  deleted: string[];
@@ -1,9 +1,52 @@
1
+ import { Readable } from "node:stream";
2
+ import { pipeline } from "node:stream/promises";
1
3
  import { getErrorMessage } from "./error-utils.js";
2
4
  import { asyncRoute } from "./express-async.js";
3
5
  import { checkProviderCliUpdates, updateProviderClis, verifyProviderCliUpdateResults, } from "./provider-cli-updater.js";
4
6
  import { streamFileWithRange } from "./server-file-routes.js";
5
7
  import { compareApkInstallOrder, compareSemver } from "./version-utils.js";
6
8
  import { canUseDetachedUpdateHelper, startDetachedUpdateHelper } from "./update-helper.js";
9
+ const GITHUB_PROXY_TIMEOUT_MS = 5 * 60 * 1000;
10
+ async function proxyGitHubApk(_req, res, asset, fetchImpl = fetch) {
11
+ let remote;
12
+ try {
13
+ remote = await fetchImpl(asset.remoteUrl, {
14
+ headers: {
15
+ "User-Agent": "wand-server",
16
+ Accept: "application/octet-stream",
17
+ },
18
+ redirect: "follow",
19
+ signal: AbortSignal.timeout(GITHUB_PROXY_TIMEOUT_MS),
20
+ });
21
+ }
22
+ catch (error) {
23
+ res.status(502).json({ error: getErrorMessage(error, "从 GitHub 下载安装包失败。") });
24
+ return;
25
+ }
26
+ if (!remote.ok || !remote.body) {
27
+ res.status(502).json({ error: `从 GitHub 下载安装包失败(HTTP ${remote.status})。` });
28
+ return;
29
+ }
30
+ const remoteLength = Number(remote.headers.get("content-length"));
31
+ const size = Number.isFinite(remoteLength) && remoteLength > 0 ? remoteLength : asset.size;
32
+ res.status(200);
33
+ res.setHeader("Content-Type", "application/vnd.android.package-archive");
34
+ res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(asset.fileName)}"`);
35
+ if (size > 0)
36
+ res.setHeader("Content-Length", String(size));
37
+ if (asset.sha256)
38
+ res.setHeader("X-APK-Sha256", asset.sha256);
39
+ try {
40
+ await pipeline(Readable.fromWeb(remote.body), res);
41
+ }
42
+ catch (error) {
43
+ if (!res.headersSent) {
44
+ res.status(502).json({ error: getErrorMessage(error, "从 GitHub 下载安装包失败。") });
45
+ return;
46
+ }
47
+ res.destroy();
48
+ }
49
+ }
7
50
  export function registerPublicUpdateRoutes(app, deps) {
8
51
  app.get("/api/android-apk-update", asyncRoute(async (req, res) => {
9
52
  const currentVersion = typeof req.query.currentVersion === "string" ? req.query.currentVersion.trim() : "";
@@ -31,11 +74,20 @@ export function registerPublicUpdateRoutes(app, deps) {
31
74
  source: latest.source,
32
75
  channel,
33
76
  releaseNotes: updateAvailable ? (latest.releaseNotes ?? null) : null,
34
- // 本地分发时为 hex SHA-256,Android 客户端下载后校验;GitHub 来源为 null。
77
+ // 本地文件哈希或 GitHub Release digest;客户端下载后校验。
35
78
  sha256: updateAvailable ? (latest.sha256 ?? null) : null,
36
79
  });
37
80
  }));
38
81
  app.get("/android/download", asyncRoute(async (req, res) => {
82
+ if (req.query.source === "github") {
83
+ const remote = deps.resolveGitHubApkDownload ? await deps.resolveGitHubApkDownload() : null;
84
+ if (!remote) {
85
+ res.status(404).json({ error: "当前没有可下载的 GitHub APK 文件。" });
86
+ return;
87
+ }
88
+ await proxyGitHubApk(req, res, remote);
89
+ return;
90
+ }
39
91
  const channel = req.query.channel === "beta" ? "beta" : "stable";
40
92
  const asset = await deps.resolveAndroidDownload(channel);
41
93
  if (!asset) {
@@ -285,6 +285,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
285
285
  provider: session.provider,
286
286
  sessionKind: session.sessionKind,
287
287
  runner: session.runner,
288
+ command: session.command,
288
289
  title: resolveSessionDisplayTitle(session),
289
290
  status: session.status,
290
291
  cwd: session.cwd,
@@ -1,5 +1,10 @@
1
1
  import { type QuickCommitAiOptions } from "./git-quick-commit.js";
2
2
  import type { ConversationTurn } from "./types.js";
3
+ /**
4
+ * PTY 终端视图里,只有底部输入框整段提交(shortcutKey=enter_text)才总结标题。
5
+ * 逐键 pty_input / 方向键 / 单独回车不能触发,避免把按键当主题。
6
+ */
7
+ export declare function shouldGenerateSessionTopicFromPtyInput(view?: "chat" | "terminal", shortcutKey?: string): boolean;
3
8
  export interface SessionTopic {
4
9
  title: string;
5
10
  description: string;
@@ -1,5 +1,12 @@
1
1
  import { callConfiguredAiText } from "./git-quick-commit.js";
2
2
  const MAX_PROMPT_LENGTH = 12_000;
3
+ /**
4
+ * PTY 终端视图里,只有底部输入框整段提交(shortcutKey=enter_text)才总结标题。
5
+ * 逐键 pty_input / 方向键 / 单独回车不能触发,避免把按键当主题。
6
+ */
7
+ export function shouldGenerateSessionTopicFromPtyInput(view, shortcutKey) {
8
+ return view !== "terminal" || shortcutKey === "enter_text";
9
+ }
3
10
  function cleanTopicText(value, maxLength) {
4
11
  if (typeof value !== "string")
5
12
  return "";