@co0ontty/wand 4.56.0 → 4.56.1

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": "a71d7db07de28e316d86d8a42c23c7a59a8dbace",
3
- "builtAt": "2026-09-10T01:20:45.427Z",
4
- "version": "4.56.0",
2
+ "commit": "477c21d747e18e9f4df6942ab0f47bd817f66ff0",
3
+ "builtAt": "2026-09-10T03:53:28.222Z",
4
+ "version": "4.56.1",
5
5
  "channel": "stable"
6
6
  }
package/dist/cli.js CHANGED
@@ -93,6 +93,8 @@ async function main() {
93
93
  break;
94
94
  }
95
95
  case "terminald": {
96
+ const { ensureNodePtyHelperExecutable } = await import("./ensure-node-pty-helper.js");
97
+ ensureNodePtyHelperExecutable();
96
98
  const { runTerminalDaemon } = await import("./terminal-daemon-server.js");
97
99
  await runTerminalDaemon(configPath);
98
100
  break;
@@ -1 +1,10 @@
1
+ export interface SpawnHelperRepair {
2
+ vendorPath: string;
3
+ helperPath: string;
4
+ copied: boolean;
5
+ }
6
+ /** Repair one spawn-helper file. Exported for tests. */
7
+ export declare function repairSpawnHelperFile(vendorPath: string): SpawnHelperRepair;
1
8
  export declare function ensureNodePtyHelperExecutable(): void;
9
+ export declare function resetNodePtyHelperStateForTests(): void;
10
+ export declare function describePtySpawnFailure(error: unknown): string;
@@ -5,64 +5,151 @@
5
5
  //
6
6
  // Locates node-pty wherever it actually lives (dev repo, hoisted global,
7
7
  // pnpm store …) via require.resolve, finds the per-arch prebuilds dir the
8
- // loaded binary is in, and ensures the helper next to it is +x. Idempotent
9
- // and best-effort: silent on the happy path, warns on failure but never
10
- // blocks startup.
8
+ // loaded binary is in, and ensures the helper is actually executable.
11
9
  //
12
- // Called both once at server startup AND right before every PTY spawn. The
13
- // per-spawn call is what makes this self-heal: a self-update (`npm install
10
+ // Two repair paths:
11
+ // 1. chmod +x the vendor helper (works when we own the file).
12
+ // 2. If chmod is denied (root-owned global install), copy the helper to a
13
+ // user-writable cache and rewrite node-pty's native `fork` helperPath.
14
+ // In-place chmod cannot fix a root-owned 644 file, and the native addon
15
+ // receives the helper path as an argument, so a writable copy is enough.
16
+ //
17
+ // Called both once at server/daemon startup AND right before every PTY spawn.
18
+ // The per-spawn call is what makes this self-heal: a self-update (`npm install
14
19
  // -g`) reinstalls node-pty and re-drops the bit, and that extraction can
15
- // land *after* the relaunched server already ran its startup chmod — so a
16
- // startup-only fix leaves every subsequent PTY launch broken until the next
17
- // restart. Re-checking before each spawn closes that race (the resolve is
18
- // cached, the happy path is a single stat + early return).
19
- import { chmodSync, existsSync, statSync } from "node:fs";
20
+ // land *after* the relaunched server already ran its startup chmod.
21
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, } from "node:fs";
22
+ import { createHash } from "node:crypto";
20
23
  import { createRequire } from "node:module";
24
+ import os from "node:os";
21
25
  import path from "node:path";
22
26
  import process from "node:process";
23
27
  import { getErrorMessage } from "./error-utils.js";
24
28
  const requireFromHere = createRequire(import.meta.url);
25
- // Cache the resolved helper path: `null` = not yet resolved, `""` = resolved
26
- // to "no helper on this platform/install" (don't retry).
27
- let cachedHelperPath = null;
28
- function resolveHelperPath() {
29
- if (cachedHelperPath !== null)
30
- return cachedHelperPath;
29
+ // Cache the resolved vendor helper path: `null` = not yet resolved, `""` =
30
+ // resolved to "no helper on this platform/install" (don't retry).
31
+ let cachedVendorHelperPath = null;
32
+ let nativeForkPatched = false;
33
+ let patchedVendorHelperPath = null;
34
+ let patchedExecutableHelperPath = null;
35
+ function resolveVendorHelperPath() {
36
+ if (cachedVendorHelperPath !== null)
37
+ return cachedVendorHelperPath;
31
38
  // spawn-helper is only used on Unix-likes. Windows uses winpty / conpty.
32
39
  if (process.platform === "win32")
33
- return (cachedHelperPath = "");
40
+ return (cachedVendorHelperPath = "");
34
41
  let nodePtyEntry;
35
42
  try {
36
43
  nodePtyEntry = requireFromHere.resolve("node-pty");
37
44
  }
38
45
  catch {
39
- return (cachedHelperPath = "");
46
+ return (cachedVendorHelperPath = "");
40
47
  }
41
48
  // node-pty's lib/index.js sits at <pkg>/lib/index.js; helper lives at
42
49
  // <pkg>/prebuilds/<platform>-<arch>/spawn-helper.
43
50
  const pkgRoot = path.resolve(path.dirname(nodePtyEntry), "..");
44
51
  const arch = `${process.platform}-${process.arch}`;
45
- return (cachedHelperPath = path.join(pkgRoot, "prebuilds", arch, "spawn-helper"));
52
+ return (cachedVendorHelperPath = path.join(pkgRoot, "prebuilds", arch, "spawn-helper"));
46
53
  }
47
- export function ensureNodePtyHelperExecutable() {
48
- const helper = resolveHelperPath();
49
- if (!helper || !existsSync(helper))
50
- return;
51
- let mode;
54
+ function isExecutable(filePath) {
52
55
  try {
53
- mode = statSync(helper).mode & 0o777;
56
+ return (statSync(filePath).mode & 0o111) === 0o111;
54
57
  }
55
58
  catch {
56
- return;
59
+ return false;
60
+ }
61
+ }
62
+ function helperCacheDir() {
63
+ return path.join(os.tmpdir(), "wand-pty-helpers");
64
+ }
65
+ function copyHelperToWritableCache(source) {
66
+ const digest = createHash("sha256").update(readFileSync(source)).digest("hex").slice(0, 16);
67
+ const dir = helperCacheDir();
68
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
69
+ const dest = path.join(dir, `${digest}-spawn-helper`);
70
+ if (!existsSync(dest)) {
71
+ copyFileSync(source, dest);
72
+ }
73
+ if (!isExecutable(dest)) {
74
+ chmodSync(dest, 0o755);
57
75
  }
58
- if ((mode & 0o111) === 0o111)
76
+ return dest;
77
+ }
78
+ function installNativeHelperPathPatch(vendorPath, executablePath) {
79
+ patchedVendorHelperPath = vendorPath;
80
+ patchedExecutableHelperPath = executablePath;
81
+ if (nativeForkPatched || process.platform === "win32" || vendorPath === executablePath)
59
82
  return;
83
+ const utils = requireFromHere("node-pty/lib/utils.js");
84
+ const native = utils.loadNativeModule("pty");
85
+ const originalFork = native.module.fork.bind(native.module);
86
+ native.module.fork = (...args) => {
87
+ if (typeof args[9] === "string" && patchedVendorHelperPath && patchedExecutableHelperPath) {
88
+ const requested = args[9];
89
+ const resolvedRequested = path.resolve(requested);
90
+ if (requested === patchedVendorHelperPath
91
+ || resolvedRequested === patchedVendorHelperPath
92
+ || resolvedRequested === path.resolve(patchedVendorHelperPath)) {
93
+ args[9] = patchedExecutableHelperPath;
94
+ }
95
+ }
96
+ return originalFork(...args);
97
+ };
98
+ nativeForkPatched = true;
99
+ process.stderr.write(`[wand] Using writable node-pty spawn-helper copy at ${executablePath}\n`);
100
+ }
101
+ /** Repair one spawn-helper file. Exported for tests. */
102
+ export function repairSpawnHelperFile(vendorPath) {
103
+ if (!vendorPath || !existsSync(vendorPath)) {
104
+ return { vendorPath, helperPath: vendorPath, copied: false };
105
+ }
106
+ if (isExecutable(vendorPath)) {
107
+ return { vendorPath, helperPath: vendorPath, copied: false };
108
+ }
60
109
  try {
61
- chmodSync(helper, mode | 0o755);
62
- process.stderr.write(`[wand] Restored +x on ${helper} (npm dropped the bit on install)\n`);
110
+ const mode = statSync(vendorPath).mode & 0o777;
111
+ chmodSync(vendorPath, mode | 0o755);
112
+ process.stderr.write(`[wand] Restored +x on ${vendorPath} (npm dropped the bit on install)\n`);
113
+ return { vendorPath, helperPath: vendorPath, copied: false };
63
114
  }
64
115
  catch (err) {
65
- process.stderr.write(`[wand] Warning: could not chmod +x ${helper}: ${getErrorMessage(err)}\n`
66
- + `[wand] PTY sessions may fail to start. Run: chmod +x ${JSON.stringify(helper)}\n`);
116
+ try {
117
+ const copiedPath = copyHelperToWritableCache(vendorPath);
118
+ process.stderr.write(`[wand] Warning: could not chmod +x ${vendorPath}: ${getErrorMessage(err)}\n`
119
+ + `[wand] Copied spawn-helper to ${copiedPath}\n`);
120
+ return { vendorPath, helperPath: copiedPath, copied: true };
121
+ }
122
+ catch (copyErr) {
123
+ process.stderr.write(`[wand] Warning: could not chmod +x ${vendorPath}: ${getErrorMessage(err)}\n`
124
+ + `[wand] Also failed to copy spawn-helper: ${getErrorMessage(copyErr)}\n`
125
+ + `[wand] PTY sessions may fail to start. Run: chmod +x ${JSON.stringify(vendorPath)}\n`);
126
+ return { vendorPath, helperPath: vendorPath, copied: false };
127
+ }
128
+ }
129
+ }
130
+ export function ensureNodePtyHelperExecutable() {
131
+ const vendorPath = resolveVendorHelperPath();
132
+ if (!vendorPath)
133
+ return;
134
+ const repaired = repairSpawnHelperFile(vendorPath);
135
+ if (repaired.copied) {
136
+ try {
137
+ installNativeHelperPathPatch(repaired.vendorPath, repaired.helperPath);
138
+ }
139
+ catch (err) {
140
+ process.stderr.write(`[wand] Warning: could not redirect node-pty spawn-helper: ${getErrorMessage(err)}\n`);
141
+ }
142
+ }
143
+ }
144
+ export function resetNodePtyHelperStateForTests() {
145
+ cachedVendorHelperPath = null;
146
+ patchedVendorHelperPath = null;
147
+ patchedExecutableHelperPath = null;
148
+ }
149
+ export function describePtySpawnFailure(error) {
150
+ const message = getErrorMessage(error, "无法启动终端。");
151
+ if (/posix_spawnp failed/i.test(message)) {
152
+ return "无法启动终端:node-pty 的 spawn-helper 不可执行。请重新安装 Wand,或对该文件执行 chmod +x。";
67
153
  }
154
+ return message;
68
155
  }
@@ -16,6 +16,7 @@ import { getProviderCommandSessionId, getProviderResumeCommandSessionId } from "
16
16
  import { normalizeThinkingEffort, thinkingEffortToClaudeCliEffort, thinkingEffortToClaudeSlashEffort, thinkingEffortToCodexReasoningEffort, thinkingEffortToOpenCodeVariant, thinkingEffortToPiLevel } from "./structured-provider-common.js";
17
17
  import { consumePtyInputForTopic, createPtyTopicLineBuffer, provisionalSessionTopic, SessionTopicCoordinator, sessionTopicBlocklistForSnapshot, shouldAcceptGeneratedSessionTitle, } from "./session-topic.js";
18
18
  import { getErrorMessage } from "./error-utils.js";
19
+ import { describePtySpawnFailure } from "./ensure-node-pty-helper.js";
19
20
  import { resolveSystemAiContext } from "./session-ai-context.js";
20
21
  import { resolveSessionCwd } from "./session-cwd.js";
21
22
  import { PtyTerminalState } from "./pty-terminal-state.js";
@@ -1144,12 +1145,20 @@ export class ProcessManager extends EventEmitter {
1144
1145
  }
1145
1146
  catch (err) {
1146
1147
  console.error("[ProcessManager] terminal spawn failed", { sessionId: id, error: String(err) });
1147
- record.status = "failed";
1148
- record.exitCode = -1;
1149
- record.endedAt = new Date().toISOString();
1150
- record.ptyProcess = null;
1151
- this.persist(record, { forceFullSave: true, metadataDirty: true });
1152
- return this.snapshot(record);
1148
+ this.cleanupRecord(record, false);
1149
+ this.sessions.delete(id);
1150
+ this.lastPersistedMessageState.delete(id);
1151
+ this.dirtySessions.delete(id);
1152
+ this.terminalHost.forget(id);
1153
+ try {
1154
+ this.storage.deleteSession(id);
1155
+ }
1156
+ catch { /* best-effort spawn-failure cleanup */ }
1157
+ try {
1158
+ this.logger.deleteSession(id);
1159
+ }
1160
+ catch { /* best-effort spawn-failure cleanup */ }
1161
+ throw new Error(describePtySpawnFailure(err));
1153
1162
  }
1154
1163
  record.processId = child.pid;
1155
1164
  record.ptyProcess = child;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "4.56.0",
3
+ "version": "4.56.1",
4
4
  "description": "A web console for Claude Code, Codex, OpenCode, and other local CLI tools.",
5
5
  "type": "module",
6
6
  "bin": {