@co0ontty/wand 1.32.2 → 1.33.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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 运行时 PATH 自修复。
3
+ *
4
+ * 背景:用户把 wand 装成 systemd / launchd 服务时,service unit 文件里的 PATH
5
+ * 是"安装那一刻"的快照(见 src/tui/commands.ts:buildServicePath)。之后:
6
+ * - 用户切 node 版本(nvm/fnm/volta),claude 装到新的 bin 目录;
7
+ * - 或 `npm install -g @co0ontty/wand` 重装,但没重新跑 `wand service:install`,
8
+ * install.sh 只 `systemctl start`,unit 里的 PATH 一直是老的;
9
+ * - 或 claude 装到 `~/.bun/bin`、`~/.local/bin` 等当时不在 PATH 里的位置。
10
+ *
11
+ * 服务进程的 process.env.PATH 就是这份陈旧快照。process-manager 给 PTY 子进程
12
+ * 用 buildChildEnv(inheritEnv=true) 时把这份 PATH 直接透传,于是 spawn 出来的
13
+ * shell 里 `claude` 找不到 → "command not found"。
14
+ *
15
+ * 修复分两层(都在 startServer() 开头跑):
16
+ * 1. repairRuntimePath()(同步、cheap):扫常见工具链 bin 目录,把存在但 PATH
17
+ * 里缺的"追加"到 process.env.PATH 末尾。
18
+ * 2. deepRepairRuntimePath()(异步、贵一点):起一个 login shell 拉用户实际的
19
+ * PATH(覆盖 ~/.bashrc、~/.zshrc、nvm/fnm/volta 的 shell-init 等所有动态
20
+ * 逻辑),把里面 PATH 里我们还没的目录"前插"到 process.env.PATH 前面。这
21
+ * 是真正能修好"unit 里 PATH 太旧、用户 shell 里却好好的"这种场景的关键。
22
+ *
23
+ * 设计决策:
24
+ * - 同步那层只追加不前插:尊重用户已有顺序;异步那层愿意前插,因为它拿到的是
25
+ * 用户实际"会用的"PATH,比 unit 里的更可信。
26
+ * - 只加 existsSync 真实存在的:避免 PATH 里塞一堆死路径。
27
+ * - 用 path.delimiter:Windows 用 `;`,POSIX 用 `:`,跨平台安全。
28
+ * - 不重写 service unit:那需要 sudo,且语义太重;只在 install.sh 主动调
29
+ * `wand service:install` 时才重新烧 PATH。
30
+ * - login shell 用 -lc 跑,4 秒超时;失败就静默走同步路径,不阻塞启动。
31
+ *
32
+ * WAND_PATH_REPAIR_DISABLE=1 可以彻底关掉同步那层(极端情况下用户想完全控制 PATH 时用)。
33
+ * WAND_PATH_REPAIR_DEEP_DISABLE=1 只关掉 login shell 探测,但保留同步追加。
34
+ */
35
+ export interface PathRepairResult {
36
+ /** 实际新追加进 PATH 的目录(按追加顺序)。 */
37
+ added: string[];
38
+ /** 关键命令的解析结果(PATH 修复后 `which` 出来的)。null = 没找到。 */
39
+ resolved: Record<string, string | null>;
40
+ /** 修复后的 PATH 整体;调试用。 */
41
+ finalPath: string;
42
+ /** login shell 探测阶段的状态:success / disabled / failed / skipped。 */
43
+ deepProbe: "success" | "disabled" | "failed" | "skipped";
44
+ /** 异步阶段产生的告警信息(login shell 超时、解析失败等)。 */
45
+ warnings: string[];
46
+ }
47
+ /**
48
+ * 把候选目录中"存在 + 未在 PATH 中"的追加到 process.env.PATH。
49
+ *
50
+ * 不会去重已经在 PATH 里的项(即使顺序不理想也保持原样),只往末尾补。
51
+ */
52
+ export declare function repairRuntimePath(): PathRepairResult;
53
+ /**
54
+ * 在 repairRuntimePath() 同步追加完之后,再用 login shell 拉一份用户实际的 PATH
55
+ * 合并进来。这是真正能修好"unit 里 PATH 太旧、用户 shell 里 claude 好好的"那种
56
+ * 升级回归的关键——同步阶段只能扫已知路径,login shell 才能拿到 nvm/fnm/volta
57
+ * 这种动态注入的 PATH。
58
+ *
59
+ * 行为:
60
+ * - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex(4s 超时)
61
+ * - 把 login shell PATH 里我们还没收录的目录 **前插** 到 process.env.PATH(注意
62
+ * 是前插,不是追加 —— 它比 unit 里的更可信)
63
+ * - 失败时静默走同步那一版结果
64
+ *
65
+ * 接受一个已经跑过同步阶段的 result,在上面 mutate。
66
+ */
67
+ export declare function deepRepairRuntimePath(result: PathRepairResult, opts?: {
68
+ shell?: string;
69
+ timeoutMs?: number;
70
+ }): Promise<PathRepairResult>;
71
+ /**
72
+ * 把修复结果格式化为一行可读摘要(startServer 启动日志用)。
73
+ *
74
+ * 例:
75
+ * `[wand] PATH augmented (+3 dirs); claude=/usr/local/bin/claude, codex=<missing>`
76
+ * `[wand] PATH already complete; claude=/Users/foo/.bun/bin/claude`
77
+ */
78
+ export declare function formatPathRepairSummary(result: PathRepairResult): string;
@@ -0,0 +1,374 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ /** 关键的 CLI 工具,会被诊断输出。 */
7
+ const PROBE_COMMANDS = ["claude", "codex"];
8
+ const DEEP_PROBE_TIMEOUT_MS = 4000;
9
+ /**
10
+ * 构造候选 bin 目录列表(按优先级,前面的更可信)。
11
+ *
12
+ * 顺序原则:
13
+ * 1. 当前 node 的 bin 目录(claude 大概率就装这里:`npm install -g claude` 把 bin
14
+ * 放在与 node 同 prefix 的 bin 目录);
15
+ * 2. 用户级常见 npm-global / 语言工具链路径;
16
+ * 3. Homebrew(macOS);
17
+ * 4. /usr/local/... 等系统标准路径兜底。
18
+ */
19
+ function candidateBinDirs() {
20
+ const home = os.homedir();
21
+ const nodeBinDir = path.dirname(process.execPath);
22
+ const candidates = [
23
+ nodeBinDir,
24
+ path.join(home, ".npm-global", "bin"),
25
+ path.join(home, ".local", "bin"),
26
+ path.join(home, "bin"),
27
+ path.join(home, ".bun", "bin"),
28
+ path.join(home, ".volta", "bin"),
29
+ path.join(home, ".cargo", "bin"),
30
+ path.join(home, ".deno", "bin"),
31
+ path.join(home, ".pnpm"),
32
+ path.join(home, "Library", "pnpm"), // pnpm 在 macOS 默认这里
33
+ ];
34
+ // nvm / fnm / n 这种多版本管理器:扫最新几个 node 版本的 bin 目录加进去。
35
+ // 不会拿单一"激活"版本(service 进程拿不到 shell init),所以宁可多塞几个;
36
+ // 重复目录会在 repairRuntimePath() 的 Set 里被去重。
37
+ for (const dir of scanNodeVersionManagerBins(home))
38
+ candidates.push(dir);
39
+ if (process.platform === "darwin") {
40
+ candidates.push("/opt/homebrew/bin", "/opt/homebrew/sbin");
41
+ }
42
+ candidates.push("/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin");
43
+ return candidates;
44
+ }
45
+ /**
46
+ * 扫各种 node 版本管理器的 bin 目录。返回的路径都不带 versions/<v>/bin 检查——
47
+ * 调用方负责 existsSync 过滤。
48
+ *
49
+ * 覆盖:
50
+ * - nvm: ~/.nvm/versions/node/<v>/bin
51
+ * - fnm: ~/.local/share/fnm/node-versions/<v>/installation/bin 或
52
+ * ~/Library/Application Support/fnm/node-versions/<v>/installation/bin
53
+ * - n(tj/n): /usr/local/n/versions/node/<v>/bin
54
+ */
55
+ function scanNodeVersionManagerBins(home) {
56
+ const results = [];
57
+ // nvm
58
+ const nvmBase = path.join(home, ".nvm", "versions", "node");
59
+ for (const v of listLatestVersions(nvmBase, 4)) {
60
+ results.push(path.join(nvmBase, v, "bin"));
61
+ }
62
+ // fnm(Linux 默认 / macOS 默认)
63
+ const fnmCandidates = [
64
+ path.join(home, ".local", "share", "fnm", "node-versions"),
65
+ path.join(home, "Library", "Application Support", "fnm", "node-versions"),
66
+ ];
67
+ for (const fnmBase of fnmCandidates) {
68
+ for (const v of listLatestVersions(fnmBase, 4)) {
69
+ results.push(path.join(fnmBase, v, "installation", "bin"));
70
+ }
71
+ }
72
+ // tj/n
73
+ const nBase = "/usr/local/n/versions/node";
74
+ for (const v of listLatestVersions(nBase, 4)) {
75
+ results.push(path.join(nBase, v, "bin"));
76
+ }
77
+ return results;
78
+ }
79
+ /** 列出 base 下"看起来像 node 版本号"的目录,按 semver 降序取前 N 个。 */
80
+ function listLatestVersions(base, limit) {
81
+ if (!existsSync(base))
82
+ return [];
83
+ try {
84
+ const entries = readdirSync(base);
85
+ return entries
86
+ .filter((e) => /^v?\d+\.\d+\.\d+/.test(e))
87
+ .sort((a, b) => semverCompare(b, a))
88
+ .slice(0, limit);
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ }
94
+ function semverCompare(a, b) {
95
+ const pa = a.replace(/^v/, "").split(/[.\-+]/).map((x) => Number(x) || 0);
96
+ const pb = b.replace(/^v/, "").split(/[.\-+]/).map((x) => Number(x) || 0);
97
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
98
+ const da = pa[i] ?? 0;
99
+ const db = pb[i] ?? 0;
100
+ if (da !== db)
101
+ return da - db;
102
+ }
103
+ return 0;
104
+ }
105
+ /**
106
+ * 把候选目录中"存在 + 未在 PATH 中"的追加到 process.env.PATH。
107
+ *
108
+ * 不会去重已经在 PATH 里的项(即使顺序不理想也保持原样),只往末尾补。
109
+ */
110
+ export function repairRuntimePath() {
111
+ if (process.env.WAND_PATH_REPAIR_DISABLE === "1") {
112
+ return {
113
+ added: [],
114
+ resolved: probeCommands(),
115
+ finalPath: process.env.PATH ?? "",
116
+ deepProbe: "disabled",
117
+ warnings: [],
118
+ };
119
+ }
120
+ const delim = path.delimiter;
121
+ const currentPath = process.env.PATH ?? "";
122
+ const existing = new Set(currentPath
123
+ .split(delim)
124
+ .map((seg) => seg.trim())
125
+ .filter((seg) => seg.length > 0));
126
+ const added = [];
127
+ for (const dir of candidateBinDirs()) {
128
+ if (!dir || existing.has(dir))
129
+ continue;
130
+ // existsSync 不抛错,权限不够 / 路径里有 EACCES 都返回 false,安全。
131
+ let ok = false;
132
+ try {
133
+ ok = existsSync(dir);
134
+ }
135
+ catch {
136
+ ok = false;
137
+ }
138
+ if (!ok)
139
+ continue;
140
+ existing.add(dir);
141
+ added.push(dir);
142
+ }
143
+ if (added.length > 0) {
144
+ const suffix = added.join(delim);
145
+ process.env.PATH = currentPath
146
+ ? `${currentPath}${delim}${suffix}`
147
+ : suffix;
148
+ }
149
+ return {
150
+ added,
151
+ resolved: probeCommands(),
152
+ finalPath: process.env.PATH ?? "",
153
+ deepProbe: "skipped",
154
+ warnings: [],
155
+ };
156
+ }
157
+ /**
158
+ * 在 repairRuntimePath() 同步追加完之后,再用 login shell 拉一份用户实际的 PATH
159
+ * 合并进来。这是真正能修好"unit 里 PATH 太旧、用户 shell 里 claude 好好的"那种
160
+ * 升级回归的关键——同步阶段只能扫已知路径,login shell 才能拿到 nvm/fnm/volta
161
+ * 这种动态注入的 PATH。
162
+ *
163
+ * 行为:
164
+ * - 跑 `${shell} -l -c '...'` 拉 $PATH 和 command -v claude/codex(4s 超时)
165
+ * - 把 login shell PATH 里我们还没收录的目录 **前插** 到 process.env.PATH(注意
166
+ * 是前插,不是追加 —— 它比 unit 里的更可信)
167
+ * - 失败时静默走同步那一版结果
168
+ *
169
+ * 接受一个已经跑过同步阶段的 result,在上面 mutate。
170
+ */
171
+ export async function deepRepairRuntimePath(result, opts = {}) {
172
+ if (process.env.WAND_PATH_REPAIR_DISABLE === "1") {
173
+ result.deepProbe = "disabled";
174
+ return result;
175
+ }
176
+ if (process.env.WAND_PATH_REPAIR_DEEP_DISABLE === "1") {
177
+ result.deepProbe = "disabled";
178
+ return result;
179
+ }
180
+ const shell = pickProbeShell(opts.shell);
181
+ if (!shell) {
182
+ result.warnings.push("跳过 login shell 探测:未找到可用 shell");
183
+ result.deepProbe = "skipped";
184
+ return result;
185
+ }
186
+ let probe;
187
+ try {
188
+ probe = await probeLoginShell(shell, opts.timeoutMs ?? DEEP_PROBE_TIMEOUT_MS);
189
+ }
190
+ catch (err) {
191
+ result.warnings.push(`login shell 探测失败 (${shell}): ${err instanceof Error ? err.message : String(err)}`);
192
+ result.deepProbe = "failed";
193
+ return result;
194
+ }
195
+ const delim = path.delimiter;
196
+ const existing = new Set((process.env.PATH ?? "")
197
+ .split(delim)
198
+ .map((seg) => seg.trim())
199
+ .filter((seg) => seg.length > 0));
200
+ // login shell 报告的所有 PATH 段 + claude/codex 解析出的目录都纳入候选。
201
+ const fromShell = [];
202
+ for (const seg of probe.path.split(delim).map((s) => s.trim()).filter(Boolean)) {
203
+ fromShell.push(seg);
204
+ }
205
+ if (probe.claude)
206
+ fromShell.push(path.dirname(probe.claude));
207
+ if (probe.codex)
208
+ fromShell.push(path.dirname(probe.codex));
209
+ const additions = [];
210
+ for (const dir of fromShell) {
211
+ if (!dir || existing.has(dir))
212
+ continue;
213
+ let ok = false;
214
+ try {
215
+ ok = existsSync(dir);
216
+ }
217
+ catch {
218
+ ok = false;
219
+ }
220
+ if (!ok)
221
+ continue;
222
+ existing.add(dir);
223
+ additions.push(dir);
224
+ }
225
+ if (additions.length > 0) {
226
+ // 前插:login shell 的 PATH 优先级比 unit 写死的高,让 claude 解析到用户期望的版本。
227
+ const prefix = additions.join(delim);
228
+ const currentPath = process.env.PATH ?? "";
229
+ process.env.PATH = currentPath ? `${prefix}${delim}${currentPath}` : prefix;
230
+ result.added.push(...additions);
231
+ }
232
+ result.finalPath = process.env.PATH ?? "";
233
+ // 修复完再 probe 一次,让 resolved 字段反映最终能找到的位置(之前 sync 阶段
234
+ // 可能 claude 还是 missing,login shell 注入 nvm 目录后这次能命中)。
235
+ result.resolved = probeCommands();
236
+ result.deepProbe = "success";
237
+ return result;
238
+ }
239
+ function pickProbeShell(configured) {
240
+ const candidates = [
241
+ configured,
242
+ process.env.SHELL,
243
+ "/bin/bash",
244
+ "/usr/bin/bash",
245
+ "/bin/zsh",
246
+ "/usr/bin/zsh",
247
+ "/bin/sh",
248
+ ].filter((s) => typeof s === "string" && s.length > 0);
249
+ for (const c of candidates) {
250
+ if (existsSync(c))
251
+ return c;
252
+ }
253
+ return null;
254
+ }
255
+ /**
256
+ * 用 login shell 跑一段最小脚本,拿到用户实际的 PATH 和 claude/codex 解析路径。
257
+ * 用 \x1f(ASCII Unit Separator)作字段分隔符避免和路径里的字符冲突。
258
+ */
259
+ function probeLoginShell(shell, timeoutMs) {
260
+ const script = `printf 'PATH\\x1f%s\\n' "$PATH"; ` +
261
+ `printf 'CLAUDE\\x1f%s\\n' "$(command -v claude 2>/dev/null)"; ` +
262
+ `printf 'CODEX\\x1f%s\\n' "$(command -v codex 2>/dev/null)"`;
263
+ return new Promise((resolve, reject) => {
264
+ const child = spawn(shell, ["-l", "-c", script], {
265
+ env: {
266
+ ...process.env,
267
+ // 防止 PROMPT_COMMAND / PS1 等钩子往 stdout 喷东西干扰解析
268
+ PS1: "",
269
+ PROMPT_COMMAND: "",
270
+ },
271
+ stdio: ["ignore", "pipe", "pipe"],
272
+ });
273
+ let stdout = "";
274
+ let stderr = "";
275
+ let settled = false;
276
+ const finalize = (fn) => {
277
+ if (settled)
278
+ return;
279
+ settled = true;
280
+ fn();
281
+ };
282
+ const timer = setTimeout(() => {
283
+ try {
284
+ child.kill("SIGTERM");
285
+ }
286
+ catch { /* noop */ }
287
+ finalize(() => reject(new Error(`login shell probe timed out after ${timeoutMs}ms`)));
288
+ }, timeoutMs);
289
+ child.stdout?.on("data", (b) => { stdout += b.toString(); });
290
+ child.stderr?.on("data", (b) => { stderr += b.toString(); });
291
+ child.on("error", (err) => {
292
+ clearTimeout(timer);
293
+ finalize(() => reject(err));
294
+ });
295
+ child.on("close", (code) => {
296
+ clearTimeout(timer);
297
+ finalize(() => {
298
+ if (!stdout && code !== 0) {
299
+ reject(new Error(`login shell exited ${code}: ${stderr.trim().slice(0, 200)}`));
300
+ return;
301
+ }
302
+ const out = { path: "", claude: null, codex: null };
303
+ for (const line of stdout.split("\n")) {
304
+ const idx = line.indexOf("\x1f");
305
+ if (idx < 0)
306
+ continue;
307
+ const key = line.slice(0, idx);
308
+ const val = line.slice(idx + 1).trim();
309
+ if (!val)
310
+ continue;
311
+ if (key === "PATH")
312
+ out.path = val;
313
+ else if (key === "CLAUDE")
314
+ out.claude = val;
315
+ else if (key === "CODEX")
316
+ out.codex = val;
317
+ }
318
+ resolve(out);
319
+ });
320
+ });
321
+ });
322
+ }
323
+ /** 用系统 `which` / `where` 查询关键命令。失败/没找到统一返回 null。 */
324
+ function probeCommands() {
325
+ const out = {};
326
+ for (const name of PROBE_COMMANDS) {
327
+ out[name] = whichSync(name);
328
+ }
329
+ return out;
330
+ }
331
+ function whichSync(cmd) {
332
+ // Windows 用 `where`,POSIX 用 `which`;都走 process.env.PATH,因此一定要在
333
+ // repairRuntimePath() 追加完 PATH 之后再调。
334
+ const tool = process.platform === "win32" ? "where" : "which";
335
+ try {
336
+ const res = spawnSync(tool, [cmd], { encoding: "utf8" });
337
+ if (res.status !== 0)
338
+ return null;
339
+ const first = (res.stdout || "").split(/\r?\n/).find((line) => line.trim().length > 0);
340
+ return first ? first.trim() : null;
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ }
346
+ /**
347
+ * 把修复结果格式化为一行可读摘要(startServer 启动日志用)。
348
+ *
349
+ * 例:
350
+ * `[wand] PATH augmented (+3 dirs); claude=/usr/local/bin/claude, codex=<missing>`
351
+ * `[wand] PATH already complete; claude=/Users/foo/.bun/bin/claude`
352
+ */
353
+ export function formatPathRepairSummary(result) {
354
+ const parts = [];
355
+ if (result.added.length > 0) {
356
+ parts.push(`PATH augmented (+${result.added.length} dirs: ${result.added.join(", ")})`);
357
+ }
358
+ else {
359
+ parts.push("PATH already complete");
360
+ }
361
+ const probes = [];
362
+ for (const [name, resolved] of Object.entries(result.resolved)) {
363
+ probes.push(`${name}=${resolved ?? "<missing>"}`);
364
+ }
365
+ if (probes.length > 0)
366
+ parts.push(probes.join(", "));
367
+ if (result.deepProbe === "failed" && result.warnings.length > 0) {
368
+ parts.push(`deep-probe: ${result.warnings[0]}`);
369
+ }
370
+ else if (result.deepProbe === "success") {
371
+ parts.push("deep-probe: ok");
372
+ }
373
+ return parts.join("; ");
374
+ }
package/dist/server.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ProcessManager } from "./process-manager.js";
2
2
  import { StructuredSessionManager } from "./structured-session-manager.js";
3
3
  import { WandStorage } from "./storage.js";
4
+ import { type PathRepairResult } from "./path-repair.js";
4
5
  import { WandConfig } from "./types.js";
5
6
  /** Persist a cwd to recent paths. Used by both REST and session creation hooks. */
6
7
  export declare function recordRecentPath(storage: WandStorage, cwd: string | undefined | null): void;
@@ -18,6 +19,7 @@ export interface ServerHandle {
18
19
  httpsEnabled: boolean;
19
20
  version: string;
20
21
  orphanRecoveredCount: number;
22
+ pathRepair: PathRepairResult;
21
23
  close(): Promise<void>;
22
24
  }
23
25
  export declare function startServer(config: WandConfig, configPath: string): Promise<ServerHandle>;
package/dist/server.js CHANGED
@@ -25,6 +25,7 @@ import { installPackageGloballyAsync } from "./npm-update-utils.js";
25
25
  import { registerUploadRoutes } from "./upload-routes.js";
26
26
  import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
27
27
  import { resolveDatabasePath, WandStorage } from "./storage.js";
28
+ import { deepRepairRuntimePath, formatPathRepairSummary, repairRuntimePath } from "./path-repair.js";
28
29
  import { isLogBusActive, wandTuiLog } from "./tui/log-bus.js";
29
30
  import { renderApp } from "./web-ui/index.js";
30
31
  import { WsBroadcastManager } from "./ws-broadcast.js";
@@ -734,6 +735,27 @@ function mimeForExt(ext) {
734
735
  return MIME_BY_EXT[ext.toLowerCase()] || "application/octet-stream";
735
736
  }
736
737
  export async function startServer(config, configPath) {
738
+ // 关键:在创建 ProcessManager / 任何 spawn 之前先修 PATH。
739
+ // 服务被注册为 systemd / launchd 时,unit 文件里的 PATH 是安装那一刻烧死的,
740
+ // 之后用户切 node 版本 / 重装 wand / 把 claude 装到新位置都不会更新 unit,
741
+ // 服务进程的 process.env.PATH 就长期 stale。这里追加常见工具链 bin 目录,
742
+ // 让 spawn 出的 PTY 子进程能找到 claude / codex。详见 src/path-repair.ts。
743
+ const pathRepair = repairRuntimePath();
744
+ // 同步追加完后还有一层兜底:起 login shell 拉用户实际 $PATH,把 nvm / fnm /
745
+ // volta 这类动态注入的目录也合并进来。失败会静默走 sync 结果,不阻塞启动。
746
+ try {
747
+ await deepRepairRuntimePath(pathRepair, { shell: config.shell });
748
+ }
749
+ catch {
750
+ // deepRepairRuntimePath 内部已经 catch 了所有异常并写到 result.warnings;
751
+ // 这里只是兜底,避免任何意外 throw 阻断启动。
752
+ }
753
+ if (pathRepair.added.length > 0
754
+ || Object.values(pathRepair.resolved).some((v) => v === null)
755
+ || pathRepair.warnings.length > 0) {
756
+ // 有改动 / 有命令没解析到 / 有告警时才打 log,避免正常启动刷屏。
757
+ process.stdout.write(`[wand] ${formatPathRepairSummary(pathRepair)}\n`);
758
+ }
737
759
  const app = express();
738
760
  const storage = new WandStorage(resolveDatabasePath(configPath));
739
761
  setAuthStorage(storage);
@@ -2031,6 +2053,7 @@ export async function startServer(config, configPath) {
2031
2053
  httpsEnabled: useHttps,
2032
2054
  version: PKG_VERSION,
2033
2055
  orphanRecoveredCount: processes.getOrphanRecoveredCount(),
2056
+ pathRepair,
2034
2057
  close,
2035
2058
  };
2036
2059
  }
@@ -152,6 +152,17 @@ export declare class StructuredSessionManager {
152
152
  private extractCodexText;
153
153
  private extractCodexItemBlock;
154
154
  private upsertCodexBlock;
155
+ /**
156
+ * 组装结构化 runner 退出失败时的可读错误字符串。
157
+ *
158
+ * 痛点:之前 claude -p / codex exec 异常退出只把"stderr.trim() || `... exited
159
+ * with code N`"塞给 UI。如果 stderr 是空的,用户在前端只能看到 "EXIT 1" 这种
160
+ * 没有任何上下文的串,根本不知道是网络错误、参数错误还是 binary 找不着。
161
+ *
162
+ * 这里固定把"provider + 退出码 / 信号"放在最前面,再把 stderr / NDJSON 错误
163
+ * 事件 / 最后一段 stdout 之类的上下文跟在后面,方便定位。
164
+ */
165
+ private formatStructuredExitError;
155
166
  private finishStructuredFailure;
156
167
  private extractModelName;
157
168
  private extractUsage;
@@ -1297,9 +1297,17 @@ export class StructuredSessionManager {
1297
1297
  closedAt: new Date().toISOString(),
1298
1298
  spawnError: error.message,
1299
1299
  });
1300
- reject(error);
1300
+ // spawn 直接失败(最常见是 ENOENT —— PATH 里找不到 codex 可执行文件)。
1301
+ // 之前只 reject(error),外层 catch 会把 error.message 直接当 lastError,
1302
+ // 用户看到的就是裸的 "spawn codex ENOENT",没法快速反应。这里加一层
1303
+ // 包装把上下文(runner 名 + 常见排查建议)拼好。
1304
+ const nodeErr = error;
1305
+ const hint = nodeErr.code === "ENOENT"
1306
+ ? "(PATH 中找不到 codex 可执行文件;请确认 codex 已安装,或重跑 `wand service:install` 刷新服务的 PATH)"
1307
+ : "";
1308
+ reject(new Error(`codex exec 启动失败:${error.message}${hint}`));
1301
1309
  });
1302
- child.on("close", (code) => {
1310
+ child.on("close", (code, signal) => {
1303
1311
  this.pendingChildren.delete(sessionId);
1304
1312
  this.lastStreamSaveAt.delete(sessionId);
1305
1313
  if (lineBuf.trim()) {
@@ -1329,11 +1337,12 @@ export class StructuredSessionManager {
1329
1337
  // codex 把模型/网络/沙箱等错误写到 stdout 的 NDJSON 流(type: error / turn.failed),
1330
1338
  // 而不是 stderr。我们以 turn.failed 的 message 为准,其次是最后一个 error 事件。
1331
1339
  const codexFailed = codexTurnFailed !== null;
1332
- if ((codexFailed || (code !== 0 && code !== null)) && !interruptedByUser) {
1333
- const errorText = (codexTurnFailed && codexTurnFailed.trim())
1334
- || (codexErrors.length > 0 ? codexErrors[codexErrors.length - 1] : "")
1335
- || stderr.trim()
1336
- || `codex exec exited with code ${code}`;
1340
+ if ((codexFailed || (code !== 0 && code !== null) || signal) && !interruptedByUser) {
1341
+ const errorText = this.formatStructuredExitError("codex exec", code, signal, {
1342
+ stderr,
1343
+ primary: codexTurnFailed,
1344
+ extras: codexErrors,
1345
+ });
1337
1346
  const exitForSnapshot = typeof code === "number" ? code : 1;
1338
1347
  const failed = this.finishStructuredFailure(current, exitForSnapshot, errorText, turnState);
1339
1348
  this.sessions.set(sessionId, failed);
@@ -1754,9 +1763,16 @@ export class StructuredSessionManager {
1754
1763
  }
1755
1764
  };
1756
1765
  let stderr = "";
1766
+ // 兜底:当 stderr 是空、JSON 也没解析到任何错误事件时,把最后一段非空
1767
+ // stdout 文本作为上下文塞给错误信息。claude -p 偶尔会把 fatal error 以
1768
+ // 纯文本(非 JSON)打到 stdout 然后非零退出,之前的实现会丢掉这部分。
1769
+ let lastRawStdoutChunk = "";
1757
1770
  child.stdout?.on("data", (chunk) => {
1758
1771
  const text = chunk.toString();
1759
1772
  this.logger?.appendStructuredStdout(sessionId, text);
1773
+ const trimmed = text.trim();
1774
+ if (trimmed)
1775
+ lastRawStdoutChunk = trimmed.slice(-1024);
1760
1776
  lineBuf += text;
1761
1777
  const lines = lineBuf.split("\n");
1762
1778
  // Keep the last (possibly incomplete) segment in the buffer.
@@ -1782,9 +1798,14 @@ export class StructuredSessionManager {
1782
1798
  closedAt: new Date().toISOString(),
1783
1799
  spawnError: error.message,
1784
1800
  });
1785
- reject(error);
1801
+ // 同 codex 那边:spawn ENOENT 最常见,提示用户去 service:install 刷 PATH。
1802
+ const nodeErr = error;
1803
+ const hint = nodeErr.code === "ENOENT"
1804
+ ? "(PATH 中找不到 claude 可执行文件;请确认 claude 已安装,或重跑 `wand service:install` 刷新服务的 PATH)"
1805
+ : "";
1806
+ reject(new Error(`claude -p 启动失败:${error.message}${hint}`));
1786
1807
  });
1787
- child.on("close", (code) => {
1808
+ child.on("close", (code, signal) => {
1788
1809
  this.pendingChildren.delete(sessionId);
1789
1810
  this.lastStreamSaveAt.delete(sessionId);
1790
1811
  this.logger?.appendStructuredSpawn(sessionId, {
@@ -1812,8 +1833,14 @@ export class StructuredSessionManager {
1812
1833
  // 可能以非零 exit code 退出(内部 handler 调了 exit(1))。这种情况属于正常
1813
1834
  // 中断流程,不应走失败路径——后续 interruptedWith 逻辑会发送新消息。
1814
1835
  const interruptedByUser = this.interruptedWith.has(sessionId);
1815
- if (code !== 0 && code !== null && !interruptedByUser) {
1816
- const errorText = stderr.trim() || `claude -p exited with code ${code}`;
1836
+ const failedExit = (code !== null && code !== 0) || signal !== null;
1837
+ if (failedExit && !interruptedByUser) {
1838
+ const errorText = this.formatStructuredExitError("claude -p", code, signal, {
1839
+ stderr,
1840
+ // claude -p 没有 codex 那种独立的 turn.failed 事件,所以 primary 留空;
1841
+ // 退路是 stderr / stdoutTail。
1842
+ stdoutTail: lastRawStdoutChunk,
1843
+ });
1817
1844
  const failureTurn = {
1818
1845
  role: "assistant",
1819
1846
  content: [{ type: "text", text: `结构化会话执行失败:${errorText}` }],
@@ -1826,10 +1853,12 @@ export class StructuredSessionManager {
1826
1853
  else {
1827
1854
  msgs.push(failureTurn);
1828
1855
  }
1856
+ // 仅 signal 终止时 code 为 null;用 1 占位,让 UI 的"exitCode !== 0"判定也能命中。
1857
+ const exitForSnapshot = typeof code === "number" ? code : 1;
1829
1858
  const failed = {
1830
1859
  ...current,
1831
1860
  status: "failed",
1832
- exitCode: code,
1861
+ exitCode: exitForSnapshot,
1833
1862
  endedAt: new Date().toISOString(),
1834
1863
  output: errorText,
1835
1864
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
@@ -2587,6 +2616,36 @@ export class StructuredSessionManager {
2587
2616
  }
2588
2617
  blocks.push(block);
2589
2618
  }
2619
+ /**
2620
+ * 组装结构化 runner 退出失败时的可读错误字符串。
2621
+ *
2622
+ * 痛点:之前 claude -p / codex exec 异常退出只把"stderr.trim() || `... exited
2623
+ * with code N`"塞给 UI。如果 stderr 是空的,用户在前端只能看到 "EXIT 1" 这种
2624
+ * 没有任何上下文的串,根本不知道是网络错误、参数错误还是 binary 找不着。
2625
+ *
2626
+ * 这里固定把"provider + 退出码 / 信号"放在最前面,再把 stderr / NDJSON 错误
2627
+ * 事件 / 最后一段 stdout 之类的上下文跟在后面,方便定位。
2628
+ */
2629
+ formatStructuredExitError(provider, code, signal, options = {}) {
2630
+ const head = signal
2631
+ ? `${provider} terminated by signal ${signal}${code !== null ? ` (code ${code})` : ""}`
2632
+ : code !== null
2633
+ ? `${provider} exited with code ${code}`
2634
+ : `${provider} exited (unknown status)`;
2635
+ const primary = options.primary?.trim();
2636
+ const stderrTrim = options.stderr?.trim() ?? "";
2637
+ const lastExtra = options.extras && options.extras.length > 0
2638
+ ? options.extras[options.extras.length - 1].trim()
2639
+ : "";
2640
+ const stdoutTail = options.stdoutTail?.trim() ?? "";
2641
+ // 选第一个非空的"详情"作为正文展示,剩下的不再追加避免太长。
2642
+ const detail = primary || lastExtra || stderrTrim || stdoutTail;
2643
+ if (!detail)
2644
+ return head;
2645
+ // 控制长度,避免大段 stderr 撑爆 UI;保留尾部信息(最近的更相关)。
2646
+ const trimmed = detail.length > 2048 ? `...${detail.slice(-2048)}` : detail;
2647
+ return `${head}\n${trimmed}`;
2648
+ }
2590
2649
  finishStructuredFailure(current, code, errorText, turnState) {
2591
2650
  const failureTurn = {
2592
2651
  role: "assistant",
@@ -135,6 +135,7 @@
135
135
  // askuserquestion 菜单多份叠加的最强候选根因。
136
136
  syncOutputBuffer: null,
137
137
  syncOutputDeadline: 0,
138
+ syncFramingResidue: false,
138
139
  lastChunkAt: 0,
139
140
  terminalHealthTimer: null,
140
141
  lastTerminalResyncAt: 0,
@@ -147,12 +148,14 @@
147
148
  // scroll 动画),多次调用用 Math.max 合并、不会被短窗口缩短。
148
149
  terminalProgrammaticScrollUntil: 0,
149
150
  terminalScrollIdleTimer: null,
150
- terminalScrollIdleMs: 1800,
151
151
  terminalScrollThreshold: 12,
152
152
  showTerminalJumpToBottom: false,
153
153
  terminalViewportEl: null,
154
154
  terminalViewportScrollHandler: null,
155
155
  terminalViewportTouchHandler: null,
156
+ terminalViewportTouchStartHandler: null,
157
+ terminalTouchStartY: 0,
158
+ terminalComposing: false,
156
159
  resizeObserver: null,
157
160
  resizeHandler: null,
158
161
  resizeTimer: null,
@@ -1916,9 +1919,14 @@
1916
1919
  if (selectedSession.provider === "claude" && selectedSession.claudeSessionId) {
1917
1920
  bits += '<span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">' + iconSvg("cloud", { size: 11, strokeWidth: 1.7, cls: "claude-session-id-icon" }) + '<span class="claude-session-id-text">' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span></span>';
1918
1921
  }
1919
- if (!isStructuredSession(selectedSession) && selectedSession.exitCode !== undefined && selectedSession.exitCode !== null) {
1920
- if (bits) bits += '<span class="session-info-separator">|</span>';
1921
- bits += '<span id="session-exit-display" class="session-exit-display">退出码=' + selectedSession.exitCode + '</span>';
1922
+ // 非结构化会话:进程退出后展示退出码(哪怕 0,告诉用户已正常结束)。
1923
+ // 结构化会话:只在退出码非 0(即真有失败)时展示,避免成功的多轮对话也挂个 "退出码=0" 误导。
1924
+ if (selectedSession.exitCode !== undefined && selectedSession.exitCode !== null) {
1925
+ var showExit = !isStructuredSession(selectedSession) || selectedSession.exitCode !== 0;
1926
+ if (showExit) {
1927
+ if (bits) bits += '<span class="session-info-separator">|</span>';
1928
+ bits += '<span id="session-exit-display" class="session-exit-display">退出码=' + selectedSession.exitCode + '</span>';
1929
+ }
1922
1930
  }
1923
1931
  return bits ? '<div class="input-session-info-bar">' + bits + '</div>' : '';
1924
1932
  })()
@@ -6174,12 +6182,25 @@
6174
6182
  inputBox.addEventListener("keydown", handleInputBoxKeydown);
6175
6183
  inputBox.addEventListener("paste", handleInputPaste);
6176
6184
  inputBox.addEventListener("input", function() {
6185
+ // INPUT-3: IME 组字期间不把半成品发给 PTY,等 compositionend 再统一发。
6186
+ if (state.terminalComposing) return;
6177
6187
  if (handleInteractiveTextInput(inputBox)) {
6178
6188
  return;
6179
6189
  }
6180
6190
  refreshInputBoxState(inputBox);
6181
6191
  setDraftValue(inputBox.value, true);
6182
6192
  });
6193
+ // INPUT-3: 交互模式 IME 组字承接。compositionstart 起置位标志让 input
6194
+ // handler 静默;compositionend 取最终组字结果发 PTY 并清空。非交互模式不
6195
+ // 介入,正常的中文聊天输入不受影响。
6196
+ inputBox.addEventListener("compositionstart", function() {
6197
+ if (state.terminalInteractive) state.terminalComposing = true;
6198
+ });
6199
+ inputBox.addEventListener("compositionend", function() {
6200
+ if (!state.terminalComposing) return;
6201
+ state.terminalComposing = false;
6202
+ if (state.terminalInteractive) handleInteractiveTextInput(inputBox);
6203
+ });
6183
6204
  inputBox.addEventListener("focus", function() {
6184
6205
  // Close drawer when user focuses input to avoid backdrop blocking clicks
6185
6206
  closeSessionsDrawer();
@@ -7127,7 +7148,10 @@
7127
7148
  var shouldShow = !!state.selectedId
7128
7149
  && state.currentView === "terminal"
7129
7150
  && !state.terminalAutoFollow
7130
- && !isTerminalNearBottom();
7151
+ // SCROLL-2: 隐藏判据用严格 2px(isTerminalAtBottom) 而非 12px。否则距底
7152
+ // 3–12px 区间 autoFollow 恒 false(scroll handler 只在 ≤2px 才恢复)却又
7153
+ // 因 isTerminalNearBottom()=true 隐藏按钮 → 既不跟随又无回底入口的死区。
7154
+ && !isTerminalAtBottom();
7131
7155
  state.showTerminalJumpToBottom = shouldShow;
7132
7156
  if (button) {
7133
7157
  button.classList.toggle("visible", shouldShow);
@@ -7175,22 +7199,19 @@
7175
7199
  }
7176
7200
  }
7177
7201
 
7178
- function scheduleTerminalResumeFollow() {
7179
- clearTerminalScrollIdleTimer();
7180
- updateTerminalJumpToBottomButton();
7181
- state.terminalScrollIdleTimer = setTimeout(function() {
7182
- state.terminalScrollIdleTimer = null;
7183
- state.terminalAutoFollow = true;
7184
- if (!isTerminalNearBottom()) {
7185
- scrollTerminalToBottom(true);
7186
- }
7187
- updateTerminalJumpToBottomButton();
7188
- }, state.terminalScrollIdleMs);
7189
- }
7190
-
7191
7202
  function setTerminalManualScrollActive() {
7192
7203
  state.terminalAutoFollow = false;
7193
7204
  clearTerminalScrollIdleTimer();
7205
+ // SCROLL-1: 用户一旦表达"看历史"意图,立刻作废任何在途的程序性拽底。
7206
+ // 否则一个已排队、尚未 fire 的 wterm rAF _doRender 仍读着旧的
7207
+ // _shouldScrollToBottom=true 把视口拽回底,而那次拽底触发的 scroll 事件正好
7208
+ // 落在 120ms 程序窗口内被 scroll handler early-return 吞掉——上滚意图被悄悄
7209
+ // 撤回。这里同步按掉 wterm 的跟随意图、并清零程序窗口,让紧随的真实 scroll
7210
+ // 事件能被 handler 正常复判。
7211
+ state.terminalProgrammaticScrollUntil = 0;
7212
+ if (state.terminal && "_shouldScrollToBottom" in state.terminal) {
7213
+ state.terminal._shouldScrollToBottom = false;
7214
+ }
7194
7215
  updateTerminalJumpToBottomButton();
7195
7216
  }
7196
7217
 
@@ -7441,9 +7462,17 @@
7441
7462
 
7442
7463
  function createWideParserState() { return { mode: "normal" }; }
7443
7464
 
7465
+ // PERF-1: 整块纯 ASCII 且无 ESC ⇒ 无宽字符、无 ANSI 序列,可跳过逐字符扫描。
7466
+ function isAsciiNonEscape(s) {
7467
+ return !/[^\x00-\x7f]/.test(s) && s.indexOf("\x1b") === -1;
7468
+ }
7469
+
7444
7470
  function widePadAnsi(data, st) {
7445
7471
  if (!data) return "";
7446
7472
  var s = String(data);
7473
+ // PERF-1: 不在 ANSI 解析中间态、且整块纯 ASCII 无转义时原样返回,省下逐字符
7474
+ // 拼接与 U+2060 注入。Claude 流式输出与全量重放大量命中此快路径。
7475
+ if (st.mode === "normal" && isAsciiNonEscape(s)) return s;
7447
7476
  var out = "";
7448
7477
  for (var i = 0; i < s.length; i++) {
7449
7478
  var code = s.charCodeAt(i);
@@ -7530,6 +7559,9 @@
7530
7559
  // 护栏:超长/超时强制 flush,避免永久卡死
7531
7560
  out += state.syncOutputBuffer;
7532
7561
  state.syncOutputBuffer = null;
7562
+ // FLICKER: 这是 NEW-A 唯一"失手"路径——半个 ?2026 帧被透传给 wterm,
7563
+ // 可能渲染错位。打标记让 R6 chunk 兜底 resync 一次(且仅此时触发)。
7564
+ state.syncFramingResidue = true;
7533
7565
  }
7534
7566
  return out;
7535
7567
  }
@@ -7629,10 +7661,17 @@
7629
7661
  var node = anchor && anchor.nodeType === 3 ? anchor.parentNode : anchor;
7630
7662
  var output = document.getElementById("output");
7631
7663
  if (!output || !node || !output.contains(node)) return;
7664
+ // COPY-1: 终端每行被 wterm 补齐到整列宽(空 cell 输出真实空格 + white-space:pre),
7665
+ // 选中复制会带一长串行尾空格;同时宽字符后插了零宽填充符 U+2060。两者一起清:
7666
+ // 逐行剥 filler + trimEnd。只要选区落在 #output 内就处理(不再要求"含 filler
7667
+ // 才改写",否则纯 ASCII 行的尾随空格漏网)。
7632
7668
  var text = sel.toString();
7633
- if (text.indexOf(WAND_WIDE_FILLER) === -1) return;
7669
+ var cleaned = text.split("\n").map(function(line) {
7670
+ return line.split(WAND_WIDE_FILLER).join("").replace(/[ \t]+$/, "");
7671
+ }).join("\n");
7672
+ if (cleaned === text) return; // 无可清理内容,交回浏览器默认复制
7634
7673
  if (e.clipboardData) {
7635
- e.clipboardData.setData("text/plain", text.split(WAND_WIDE_FILLER).join(""));
7674
+ e.clipboardData.setData("text/plain", cleaned);
7636
7675
  e.preventDefault();
7637
7676
  }
7638
7677
  });
@@ -7755,6 +7794,11 @@
7755
7794
  var RESYNC_BUDGET_WINDOW_MS = 5000;
7756
7795
  var RESYNC_BUDGET_MAX = 12;
7757
7796
  var RESYNC_WARN_COOLDOWN_MS = 30000;
7797
+ // RENDER-1: softResync 自身的重放(wandTerminalWrite 末尾会调 maybeScheduleResyncForChunk)
7798
+ // 不应再触发新一轮 resync,否则从 health-check/onResize/刷新/重连等路径进来时,
7799
+ // 整段含 CSI 的 replaySource 会让单次 resync 被放大成 2~3 次全量重放。重放期间置位
7800
+ // 此标志,maybeScheduleResyncForChunk 开头据此短路。
7801
+ var _resyncInProgress = false;
7758
7802
  function softResyncTerminal(options) {
7759
7803
  if (!state.terminal || !state.terminalOutput) return false;
7760
7804
  var opts = options || {};
@@ -7767,8 +7811,13 @@
7767
7811
  var replaySource = marker > 0 ? state.terminalOutput.slice(marker) : state.terminalOutput;
7768
7812
  var bufLen = replaySource.length;
7769
7813
  var startedAt = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
7770
- resetTerminal();
7771
- wandTerminalWrite(state.terminal, replaySource);
7814
+ _resyncInProgress = true;
7815
+ try {
7816
+ resetTerminal();
7817
+ wandTerminalWrite(state.terminal, replaySource);
7818
+ } finally {
7819
+ _resyncInProgress = false;
7820
+ }
7772
7821
  state.lastTerminalResyncAt = Date.now();
7773
7822
  maybeScrollTerminalToBottom("output");
7774
7823
  if (!opts.skipFit) ensureTerminalFit("refresh");
@@ -7819,6 +7868,14 @@
7819
7868
  var _resyncChunkLastAt = 0;
7820
7869
  var _resyncChunkTailTimer = null;
7821
7870
  function maybeScheduleResyncForChunk(chunk) {
7871
+ if (_resyncInProgress) return; // RENDER-1: 屏蔽 softResync 自身重放触发的递归
7872
+ // FLICKER: R6 chunk 热路径 resync 仅在 NEW-A 失手时兜底——即 ?2026 残帧被
7873
+ // processSyncOutputFraming 超时/超长强制 flush 透传给 wterm 之后。正常完整包帧
7874
+ // 的重绘(菜单/todo/thinking spinner)已被 NEW-A 原子化、wterm 渲染正确,无需
7875
+ // resync。旧实现对每个含 CSI 的 chunk 都触发,使 thinking 期间每 ~1.5s 一次
7876
+ // resetTerminal→空白帧→重画,终端区持续闪烁。残帧标记在此消费一次(仅此触发)。
7877
+ if (!state.syncFramingResidue) return;
7878
+ state.syncFramingResidue = false;
7822
7879
  if (!chunk || typeof chunk !== "string") return;
7823
7880
  if (chunk.indexOf("\x1b[") === -1) return;
7824
7881
  if (!IN_PLACE_REDRAW_RE.test(chunk)) return;
@@ -8048,15 +8105,32 @@
8048
8105
  }
8049
8106
  setTerminalManualScrollActive();
8050
8107
  };
8051
- state.terminalViewportTouchHandler = function() {
8052
- setTerminalManualScrollActive();
8108
+ // SCROLL-3: 触摸只在"看历史"方向(手指下拉、clientY 增大)才下台跟随;
8109
+ // 手指上滑(朝新内容/底部)不关跟随,交给 scroll handler 在到底时恢复。
8110
+ // 终端非 column-reverse:手指下拉=内容下移=看上方历史=上滚意图,与 wheel
8111
+ // 的 deltaY<0 对称。原实现任何 touchmove 都关跟随,移动端在底部轻微回弹
8112
+ // 就丢跟随。
8113
+ state.terminalViewportTouchStartHandler = function(e) {
8114
+ if (e.touches && e.touches.length === 1) {
8115
+ state.terminalTouchStartY = e.touches[0].clientY;
8116
+ }
8117
+ };
8118
+ state.terminalViewportTouchHandler = function(e) {
8119
+ if (!e.touches || e.touches.length !== 1) return;
8120
+ if (typeof state.terminalTouchStartY !== "number") return;
8121
+ if (e.touches[0].clientY - state.terminalTouchStartY > 4) {
8122
+ setTerminalManualScrollActive();
8123
+ }
8053
8124
  };
8054
8125
  viewport.addEventListener("scroll", state.terminalViewportScrollHandler, { passive: true });
8126
+ viewport.addEventListener("touchstart", state.terminalViewportTouchStartHandler, { passive: true });
8055
8127
  viewport.addEventListener("touchmove", state.terminalViewportTouchHandler, { passive: true });
8056
8128
  }
8057
8129
 
8130
+ // SCROLL-5: 只在上滚(朝历史,deltaY<0)时下台跟随;向下滚(想回去)交给
8131
+ // scroll handler 在真正到底时恢复,避免"远离底部时向下滚也被一直按住不跟随"。
8058
8132
  state.terminalWheelHandler = function(e) {
8059
- if (!isTerminalNearBottom() || e.deltaY < 0) {
8133
+ if (e.deltaY < 0) {
8060
8134
  setTerminalManualScrollActive();
8061
8135
  }
8062
8136
  e.stopPropagation();
@@ -8074,7 +8148,13 @@
8074
8148
  wandTerminalWrite(term, "点击上方「新对话」开始你的第一次对话。\r\n");
8075
8149
  }
8076
8150
 
8077
- state.terminalClickHandler = focusInputBox;
8151
+ // COPY-4: 有终端选区时不抢焦点到输入框,否则打断双击选词/三击选行后的复制
8152
+ // (焦点与后续 Ctrl+C 目标被夺走)。wterm 自带 _onClickFocus 有同款护栏。
8153
+ // 透传 event 给 focusInputBox(skipMobile),保留"移动端点终端不自动弹键盘"。
8154
+ state.terminalClickHandler = function(e) {
8155
+ if (hasActiveTerminalSelection()) return;
8156
+ focusInputBox(e);
8157
+ };
8078
8158
  container.addEventListener("click", state.terminalClickHandler);
8079
8159
  updateTerminalJumpToBottomButton();
8080
8160
  initTerminalResizeHandle();
@@ -11426,29 +11506,57 @@
11426
11506
 
11427
11507
  if (event.key === "Escape") {
11428
11508
  event.preventDefault();
11429
- queueDirectInput(getControlInput("escape"), "escape");
11509
+ var escSess = getSelectedSession();
11510
+ if (isStructuredSession(escSess)) {
11511
+ // INPUT-2: 结构化会话没有 PTY,Esc 不能把 \x1b 当消息发给 Claude。
11512
+ // 用户语义 Esc=中断:正在生成时等同点"停止"按钮,否则无操作。
11513
+ if (escSess && escSess.structuredState && escSess.structuredState.inFlight) {
11514
+ stopSession();
11515
+ }
11516
+ } else {
11517
+ queueDirectInput(getControlInput("escape"), "escape");
11518
+ }
11430
11519
  return;
11431
11520
  }
11432
11521
 
11433
11522
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "c") {
11434
- // Allow copy when text is selected; otherwise send SIGINT to terminal
11435
- var inputBox = document.getElementById("input-box");
11436
- var hasSelection = inputBox && (inputBox.selectionStart !== inputBox.selectionEnd);
11437
- if (hasSelection) {
11523
+ // COPY-2: 有选区(输入框内或终端输出区)时放行浏览器原生复制,而不是发
11524
+ // SIGINT。原实现只看输入框选区,漏了文档级终端选区。
11525
+ var inputBoxC = document.getElementById("input-box");
11526
+ var hasSelectionC = (inputBoxC && inputBoxC.selectionStart !== inputBoxC.selectionEnd)
11527
+ || hasActiveTerminalSelection();
11528
+ if (hasSelectionC) {
11438
11529
  return; // Let browser handle copy
11439
11530
  }
11531
+ var ccSess = getSelectedSession();
11532
+ if (isStructuredSession(ccSess)) {
11533
+ // INPUT-2: 结构化会话不把 SIGINT(\x03) 当消息发给 Claude。Ctrl+C 视为
11534
+ // 中断当前生成(等同停止按钮),非生成态则无操作。
11535
+ event.preventDefault();
11536
+ if (ccSess && ccSess.structuredState && ccSess.structuredState.inFlight) {
11537
+ stopSession();
11538
+ }
11539
+ return;
11540
+ }
11440
11541
  event.preventDefault();
11441
11542
  queueDirectInput(getControlInput("ctrl_c"), "ctrl_c");
11442
11543
  return;
11443
11544
  }
11444
11545
 
11445
11546
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "d") {
11446
- // Allow copy when text is selected; otherwise send EOF to terminal
11547
+ // COPY-2: 有选区(输入框内或终端输出区)时放行浏览器复制。
11447
11548
  var inputBox2 = document.getElementById("input-box");
11448
- var hasSelection2 = inputBox2 && (inputBox2.selectionStart !== inputBox2.selectionEnd);
11549
+ var hasSelection2 = (inputBox2 && (inputBox2.selectionStart !== inputBox2.selectionEnd))
11550
+ || hasActiveTerminalSelection();
11449
11551
  if (hasSelection2) {
11450
11552
  return; // Let browser handle copy
11451
11553
  }
11554
+ var cdSess = getSelectedSession();
11555
+ if (isStructuredSession(cdSess)) {
11556
+ // INPUT-2: 结构化会话吞掉 Ctrl+D(EOF 对 Claude 对话无意义,别当消息发)
11557
+ event.preventDefault();
11558
+ return;
11559
+ }
11452
11560
  event.preventDefault();
11453
11561
  queueDirectInput(getControlInput("ctrl_d"), "ctrl_d");
11454
11562
  return;
@@ -11456,6 +11564,11 @@
11456
11564
 
11457
11565
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "l") {
11458
11566
  event.preventDefault();
11567
+ var clSess = getSelectedSession();
11568
+ if (isStructuredSession(clSess)) {
11569
+ // INPUT-2: 结构化会话吞掉 Ctrl+L(清屏对 Claude 对话无意义)
11570
+ return;
11571
+ }
11459
11572
  queueDirectInput(getControlInput("ctrl_l"), "ctrl_l");
11460
11573
  return;
11461
11574
  }
@@ -13542,10 +13655,12 @@
13542
13655
  composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
13543
13656
  composer.disabled = !structured && !!selectedSession && !isRunning && !canResumeOnSend;
13544
13657
  composer.setAttribute("aria-disabled", composer.disabled ? "true" : "false");
13545
- // 终端交互模式下按键由 document capture phase 透传到 PTY;用
13546
- // readOnly 而非 disabled 防止 IME 组合输入等边界场景下字符同时
13547
- // 落到 textarea,又保留 focus 能力。
13548
- composer.readOnly = !!state.terminalInteractive;
13658
+ // INPUT-3: 交互模式不再设 readOnly。readOnly textarea 上 IME 根本不会
13659
+ // 激活(compositionstart 不触发),导致中文/日文等组字输入彻底打不出。普通
13660
+ // 字符 keydown 已由 capture 阶段的 captureTerminalInput preventDefault 拦截、
13661
+ // 不会落进 textarea;唯独 IME 组字期间 capture 放行(isComposing),字符临时
13662
+ // 落入 textarea,由 compositionend 取最终文本发 PTY 后清空。
13663
+ composer.readOnly = false;
13549
13664
  composer.classList.toggle("is-terminal-passthrough", !!state.terminalInteractive);
13550
13665
  }
13551
13666
  var sendBtn = document.getElementById("send-input-button");
@@ -13561,15 +13676,40 @@
13561
13676
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
13562
13677
  }
13563
13678
 
13679
+ // COPY-2/COPY-4: 是否存在落在终端输出区(#output)内的活动文本选区。用于:
13680
+ // 有选区时 Ctrl+C 放行浏览器原生复制而非发 SIGINT;click 不抢焦点以免打断
13681
+ // 双击选词/三击选行后的复制。
13682
+ function hasActiveTerminalSelection() {
13683
+ var sel = window.getSelection && window.getSelection();
13684
+ if (!sel || sel.isCollapsed) return false;
13685
+ var output = document.getElementById("output");
13686
+ if (!output) return false;
13687
+ var node = sel.anchorNode;
13688
+ if (node && node.nodeType === 3) node = node.parentNode;
13689
+ return !!(node && output.contains(node));
13690
+ }
13691
+
13564
13692
  function captureTerminalInput(event) {
13565
13693
  if (!shouldCaptureTerminalEvent(event)) return;
13694
+ // INPUT-1: 放行 Cmd/Meta 组合键给浏览器(复制/粘贴/刷新/切标签)。PTY 用
13695
+ // Ctrl 不用 Cmd,拦下来既破坏 macOS 原生快捷键,又会把裸字母(Cmd+X→'x')
13696
+ // 误塞进 PTY。
13697
+ if (event.metaKey) return;
13566
13698
  var key = keyFromKeyboardEvent(event);
13567
13699
  if (!key) return;
13568
- event.preventDefault();
13569
13700
  var mods = getModifierStateFromEvent(event, key);
13570
13701
  if (isModifierKey(key)) return;
13702
+ // COPY-2: 有选区时 Ctrl+C 放行浏览器原生复制,而不是发 SIGINT(0x03) 把进程
13703
+ // 杀了还复制不到。无选区的 Ctrl+C 仍透传给 PTY。
13704
+ if (mods.ctrl && key.length === 1 && key.toLowerCase() === "c" && hasActiveTerminalSelection()) {
13705
+ return;
13706
+ }
13571
13707
  var sequence = buildPtySequence(key, mods);
13572
- if (sequence) sendTerminalSequence(sequence, key);
13708
+ // INPUT-4: 只有真正要发给 PTY 的键才 preventDefault;空序列(F5/F12/死键等)
13709
+ // 放行给浏览器,避免"既没发 PTY 又吞掉浏览器默认行为"。
13710
+ if (!sequence) return;
13711
+ event.preventDefault();
13712
+ sendTerminalSequence(sequence, key);
13573
13713
  }
13574
13714
 
13575
13715
  function handleMiniKeyboardClick(event) {
@@ -15250,7 +15390,11 @@
15250
15390
  var now = Date.now();
15251
15391
  var chunkPause = state.lastChunkAt > 0 && (now - state.lastChunkAt > 300);
15252
15392
  var resyncDue = (now - state.lastTerminalResyncAt) > 30000;
15253
- if (resyncDue && (chunkPause || selectedSession.status !== "running") && state.terminalOutput) {
15393
+ // RENDER-2: 仅在"自上次 resync 以来确有新输出"时才重放。静止/已结束会话
15394
+ // buffer 不再变化,30s 周期 resync 是纯无用功,还会把 cursor-home 中间帧
15395
+ // 重堆进 scrollback、扰动滚动位置。lastChunkAt>lastTerminalResyncAt 即脏。
15396
+ var dirtySinceResync = state.lastChunkAt > state.lastTerminalResyncAt;
15397
+ if (resyncDue && dirtySinceResync && (chunkPause || selectedSession.status !== "running") && state.terminalOutput) {
15254
15398
  softResyncTerminal();
15255
15399
  }
15256
15400
  }, 5000);
@@ -15306,6 +15450,9 @@
15306
15450
  if (state.terminalViewportTouchHandler) {
15307
15451
  state.terminalViewportEl.removeEventListener("touchmove", state.terminalViewportTouchHandler);
15308
15452
  }
15453
+ if (state.terminalViewportTouchStartHandler) {
15454
+ state.terminalViewportEl.removeEventListener("touchstart", state.terminalViewportTouchStartHandler);
15455
+ }
15309
15456
  }
15310
15457
  if (output) {
15311
15458
  if (state.terminalWheelHandler) {
@@ -15318,13 +15465,24 @@
15318
15465
  state.terminalViewportEl = null;
15319
15466
  state.terminalViewportScrollHandler = null;
15320
15467
  state.terminalViewportTouchHandler = null;
15468
+ state.terminalViewportTouchStartHandler = null;
15321
15469
  state.terminalWheelHandler = null;
15322
15470
  state.terminalClickHandler = null;
15471
+ // LIFE-1: 清理 initTerminalScrollbar 注册的 hide-timer 与拖拽状态。否则
15472
+ // hide-timer 闭包引用游离节点(置 El=null 拦不住它),且若拖拽中途 teardown,
15473
+ // 残留 dragging=true 会让新会话 scrollbar 的 scheduleHideScrollbar 被永久抑制
15474
+ // (开头 if(dragging)return)→ 滚动条出现后再也不自动消失。
15475
+ if (state.terminalScrollbarHideTimer) {
15476
+ clearTimeout(state.terminalScrollbarHideTimer);
15477
+ state.terminalScrollbarHideTimer = null;
15478
+ }
15323
15479
  if (state.terminalScrollbarEl && state.terminalScrollbarEl.parentNode) {
15324
15480
  state.terminalScrollbarEl.parentNode.removeChild(state.terminalScrollbarEl);
15325
15481
  }
15326
15482
  state.terminalScrollbarEl = null;
15327
15483
  state.terminalScrollbarThumbEl = null;
15484
+ state.terminalScrollbarDragging = false;
15485
+ state.terminalScrollbarRafPending = false;
15328
15486
  if (state.terminal) {
15329
15487
  state.terminal.destroy();
15330
15488
  state.terminal = null;
@@ -15349,6 +15507,7 @@
15349
15507
  // 会让新会话的首批 PTY 字节全部被吞进 buffer 等永远不会来的 end。
15350
15508
  state.syncOutputBuffer = null;
15351
15509
  state.syncOutputDeadline = 0;
15510
+ state.syncFramingResidue = false;
15352
15511
  state.terminalSessionId = null;
15353
15512
  state.terminalOutput = "";
15354
15513
  state.terminalOutputMarker = 0; // R8: teardown 时重置 /clear marker
@@ -15371,6 +15530,11 @@
15371
15530
  _resyncStatsWindowStart = 0;
15372
15531
  _resyncStatsCount = 0;
15373
15532
  _resyncLastWarnAt = 0;
15533
+ _resyncInProgress = false;
15534
+ // SIZE-2: lastResize 是全局去重缓存,记"上次 POST 的尺寸"。切到另一个在不同
15535
+ // 尺寸下创建的会话时,若新算出的 cols/rows 恰好等于上次值会被去重跳过,导致
15536
+ // 后端该会话列宽停在旧值、整段折行。teardown 重置后新会话首次 resize 必发出。
15537
+ state.lastResize = { cols: 0, rows: 0 };
15374
15538
  }
15375
15539
 
15376
15540
  function sendTerminalResize(cols, rows) {
@@ -15383,6 +15547,10 @@
15383
15547
  // wterm WASM grid 的 maxCols 硬编码 256。POST 给服务端的 cols 也同步
15384
15548
  // clamp,避免服务端 pty.resize 给 Claude 一个 wterm 实际渲不下的列宽。
15385
15549
  if (cols > 256) cols = 256;
15550
+ // SIZE-1: rows 也 clamp 到后端上限 160(process-manager clampDimension 10..160)。
15551
+ // 否则高分屏+小字号客户端算出 rows>160 时,后端压到 160,客户端网格底部多出的
15552
+ // 行对应的 PTY 内容永不写入 → 底部大片空白 / 光标错位 / 全屏菜单画到不存在的行。
15553
+ if (rows > 160) rows = 160;
15386
15554
  var nextSize = { cols: cols, rows: rows };
15387
15555
  if (state.lastResize.cols !== nextSize.cols || state.lastResize.rows !== nextSize.rows) {
15388
15556
  state.lastResize = nextSize;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.32.2",
3
+ "version": "1.33.0",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {