@co0ontty/wand 4.72.2 → 4.73.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.
Files changed (72) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/config.js +20 -0
  3. package/dist/daemon-connection.d.ts +9 -0
  4. package/dist/daemon-connection.js +86 -0
  5. package/dist/distribution-manager.d.ts +5 -0
  6. package/dist/distribution-manager.js +79 -1
  7. package/dist/git-quick-commit.js +12 -0
  8. package/dist/message-truncator.js +6 -0
  9. package/dist/native/darwin-arm64/wand-render +0 -0
  10. package/dist/native/darwin-arm64/wand-render.sha256 +1 -0
  11. package/dist/native/darwin-arm64/wand-render.version +1 -0
  12. package/dist/native/darwin-x64/wand-render +0 -0
  13. package/dist/native/darwin-x64/wand-render.sha256 +1 -0
  14. package/dist/native/darwin-x64/wand-render.version +1 -0
  15. package/dist/native/linux-arm64/wand-render +0 -0
  16. package/dist/native/linux-arm64/wand-render.sha256 +1 -0
  17. package/dist/native/linux-arm64/wand-render.version +1 -0
  18. package/dist/native/linux-x64/wand-render +0 -0
  19. package/dist/native/linux-x64/wand-render.sha256 +1 -0
  20. package/dist/native/linux-x64/wand-render.version +1 -0
  21. package/dist/process-manager.d.ts +10 -0
  22. package/dist/process-manager.js +73 -4
  23. package/dist/render-binary.d.ts +61 -0
  24. package/dist/render-binary.js +204 -0
  25. package/dist/render-daemon-client.d.ts +171 -0
  26. package/dist/render-daemon-client.js +1114 -0
  27. package/dist/render-host.d.ts +64 -0
  28. package/dist/render-host.js +301 -0
  29. package/dist/render-protocol.d.ts +128 -0
  30. package/dist/render-protocol.js +99 -0
  31. package/dist/render-structured-client.d.ts +80 -0
  32. package/dist/render-structured-client.js +513 -0
  33. package/dist/render-structured-host.d.ts +31 -0
  34. package/dist/render-structured-host.js +218 -0
  35. package/dist/render-structured-protocol.d.ts +101 -0
  36. package/dist/render-structured-protocol.js +35 -0
  37. package/dist/server-session-routes.js +72 -9
  38. package/dist/server-task-routes.js +52 -3
  39. package/dist/server-update-routes.d.ts +2 -0
  40. package/dist/server-update-routes.js +44 -0
  41. package/dist/server.js +31 -9
  42. package/dist/session-transport.js +1 -1
  43. package/dist/storage.d.ts +2 -1
  44. package/dist/storage.js +14 -5
  45. package/dist/structured-client-protocol.d.ts +3 -1
  46. package/dist/structured-client-protocol.js +85 -25
  47. package/dist/structured-content.d.ts +20 -3
  48. package/dist/structured-content.js +80 -1
  49. package/dist/structured-exec-host.js +7 -1
  50. package/dist/structured-exec-pump.js +6 -2
  51. package/dist/structured-pi-adapter.js +18 -2
  52. package/dist/structured-session-manager.d.ts +2 -0
  53. package/dist/structured-session-manager.js +27 -3
  54. package/dist/task-types.d.ts +2 -0
  55. package/dist/terminal-daemon-client.d.ts +9 -2
  56. package/dist/terminal-daemon-client.js +119 -46
  57. package/dist/terminal-daemon-server.js +20 -4
  58. package/dist/terminal-host.d.ts +6 -0
  59. package/dist/terminal-host.js +3 -0
  60. package/dist/tui/ipc-server.js +12 -0
  61. package/dist/types.d.ts +19 -1
  62. package/dist/unix-socket-keepalive.d.ts +3 -0
  63. package/dist/unix-socket-keepalive.js +94 -0
  64. package/dist/web-ui/content/scripts.js +68 -68
  65. package/dist/web-ui/content/styles.css +1 -1
  66. package/dist/web-ui/content/tailwind.css +12 -0
  67. package/dist/web-ui/embedded-assets.js +2 -2
  68. package/dist/ws-broadcast.d.ts +1 -1
  69. package/dist/ws-broadcast.js +17 -5
  70. package/package.json +8 -3
  71. package/scripts/install-render-binary.js +499 -0
  72. package/scripts/stage-render-binaries.js +626 -0
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "cc5d57ee24c38039f381a1bbda3b8d4a95fc26b4",
3
- "builtAt": "2026-09-22T23:09:41.272Z",
4
- "version": "4.72.2",
2
+ "commit": "7ff219242659cdf0ec264845fbba73b7bbf8561f",
3
+ "builtAt": "2026-09-23T13:34:35.791Z",
4
+ "version": "4.73.0",
5
5
  "channel": "stable"
6
6
  }
package/dist/config.js CHANGED
@@ -91,6 +91,8 @@ export const defaultConfig = () => ({
91
91
  android: defaultAndroidApkConfig(),
92
92
  macos: defaultMacosDmgConfig(),
93
93
  ios: defaultIosIpaConfig(),
94
+ render: defaultRenderConfig(),
95
+ structured: { processHost: "legacy" },
94
96
  cardDefaults: defaultCardExpandDefaults(),
95
97
  defaultModel: "",
96
98
  defaultCodexModel: "",
@@ -634,6 +636,22 @@ function defaultIosIpaConfig() {
634
636
  currentIpaFile: "",
635
637
  };
636
638
  }
639
+ /**
640
+ * 默认 auto:存在 wand-render 就用它,否则保持 legacy terminald。
641
+ * 放在 config.json(部署项)而非 SQLite 偏好里——它决定进程所有权,属于部署决策。
642
+ */
643
+ function defaultRenderConfig() {
644
+ return { engine: "auto" };
645
+ }
646
+ /** 旧 config.json 没有 render 字段时回落到默认值,不能因为新字段让启动失败。 */
647
+ function normalizeRenderConfig(input) {
648
+ if (!input || typeof input !== "object" || Array.isArray(input))
649
+ return undefined;
650
+ const raw = input;
651
+ const engine = raw.engine === "rust" || raw.engine === "legacy" ? raw.engine : "auto";
652
+ const binaryPath = typeof raw.binaryPath === "string" ? raw.binaryPath.trim() : "";
653
+ return { engine, ...(binaryPath ? { binaryPath } : {}) };
654
+ }
637
655
  function normalizeIosIpaConfig(input) {
638
656
  if (!input || typeof input !== "object")
639
657
  return undefined;
@@ -737,6 +755,8 @@ function mergeWithDefaults(input) {
737
755
  android: normalizeAndroidApkConfig(input.android) ?? defaults.android,
738
756
  macos: normalizeMacosDmgConfig(input.macos) ?? defaults.macos,
739
757
  ios: normalizeIosIpaConfig(input.ios) ?? defaults.ios,
758
+ render: normalizeRenderConfig(input.render) ?? defaults.render,
759
+ structured: { processHost: input.structured?.processHost === "rust" ? "rust" : "legacy" },
740
760
  cardDefaults: normalizeCardDefaults(input.cardDefaults),
741
761
  defaultProvider: isSessionProvider(input.defaultProvider) ? input.defaultProvider : "claude",
742
762
  defaultSessionKind: input.defaultSessionKind === "pty" ? "pty" : "structured",
@@ -0,0 +1,9 @@
1
+ import type net from "node:net";
2
+ /** Bound connect attempts, including sockets that close before the handshake. */
3
+ export declare function waitForDaemonSocket(socket: net.Socket): Promise<void>;
4
+ /** Probe the application, not just the fd: a half-open socket may still be writable. */
5
+ export declare function startDaemonHeartbeat(socket: net.Socket, probe: () => Promise<unknown>, options?: {
6
+ socketPath?: string;
7
+ intervalMs?: number;
8
+ timeoutMs?: number;
9
+ }): () => void;
@@ -0,0 +1,86 @@
1
+ import { lstatSync, utimesSync } from "node:fs";
2
+ const CONNECT_TIMEOUT_MS = 10_000;
3
+ const HEARTBEAT_INTERVAL_MS = 15_000;
4
+ const HEARTBEAT_TIMEOUT_MS = 5_000;
5
+ /** Bound connect attempts, including sockets that close before the handshake. */
6
+ export function waitForDaemonSocket(socket) {
7
+ return new Promise((resolve, reject) => {
8
+ const cleanup = () => {
9
+ clearTimeout(timer);
10
+ socket.off("connect", onConnect);
11
+ socket.off("error", onError);
12
+ socket.off("close", onClose);
13
+ };
14
+ const onConnect = () => { cleanup(); resolve(); };
15
+ const onError = (error) => { cleanup(); socket.destroy(); reject(error); };
16
+ const onClose = () => onError(new Error("Daemon socket closed while connecting"));
17
+ const timer = setTimeout(() => onError(new Error("Daemon socket connect timed out")), CONNECT_TIMEOUT_MS);
18
+ socket.once("connect", onConnect);
19
+ socket.once("error", onError);
20
+ socket.once("close", onClose);
21
+ });
22
+ }
23
+ /** Probe the application, not just the fd: a half-open socket may still be writable. */
24
+ export function startDaemonHeartbeat(socket, probe, options = {}) {
25
+ const { socketPath, intervalMs = HEARTBEAT_INTERVAL_MS, timeoutMs = HEARTBEAT_TIMEOUT_MS } = options;
26
+ // Old daemons have no listener watchdog. Refresh their existing endpoint's
27
+ // timestamps after a successful probe so idle /tmp cleanup won't remove it.
28
+ const identity = (() => {
29
+ if (!socketPath || process.platform === "win32")
30
+ return null;
31
+ try {
32
+ const stat = lstatSync(socketPath);
33
+ return stat.isSocket() && (typeof process.getuid !== "function" || stat.uid === process.getuid())
34
+ ? stat : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ })();
40
+ let lastTouch = 0;
41
+ let stopped = false;
42
+ let inFlight = false;
43
+ let deadline = null;
44
+ const stop = () => {
45
+ stopped = true;
46
+ clearInterval(timer);
47
+ if (deadline)
48
+ clearTimeout(deadline);
49
+ socket.off("close", stop);
50
+ };
51
+ const fail = () => {
52
+ if (stopped)
53
+ return;
54
+ stop();
55
+ // Destroy this generation only; its close handler performs normal replay
56
+ // and reconnect. Never retry mutating RPCs or terminate the daemon.
57
+ socket.destroy(new Error("Daemon heartbeat failed; reconnecting"));
58
+ };
59
+ const timer = setInterval(() => {
60
+ if (stopped || inFlight || socket.destroyed)
61
+ return;
62
+ inFlight = true;
63
+ deadline = setTimeout(fail, timeoutMs);
64
+ deadline.unref();
65
+ void Promise.resolve().then(() => stopped ? undefined : probe()).then(() => {
66
+ if (deadline)
67
+ clearTimeout(deadline);
68
+ deadline = null;
69
+ inFlight = false;
70
+ if (stopped || !identity || !socketPath || Date.now() - lastTouch < 60_000)
71
+ return;
72
+ try {
73
+ const stat = lstatSync(socketPath);
74
+ if (stat.dev === identity.dev && stat.ino === identity.ino) {
75
+ const now = new Date();
76
+ utimesSync(socketPath, now, now);
77
+ lastTouch = Date.now();
78
+ }
79
+ }
80
+ catch { /* The owning daemon repairs a missing path; never replace it here. */ }
81
+ }, fail);
82
+ }, intervalMs);
83
+ timer.unref();
84
+ socket.once("close", stop);
85
+ return stop;
86
+ }
@@ -82,6 +82,11 @@ export declare class DistributionManager {
82
82
  deleted: string[];
83
83
  freedBytes: number;
84
84
  }>;
85
+ /** Beta 仅从当前服务端的 macOS 分发目录选择,绝不回退 GitHub。 */
86
+ resolveLatestMacosBeta(): Promise<ResolvedDistributionAsset | null>;
87
+ /** 固定下载文件名,避免检查与下载之间新增包后下载到另一个版本。 */
88
+ resolveMacosBetaDownload(fileName: string): Promise<LocalDistributionAsset | null>;
89
+ private resolveMacosBetaAsset;
85
90
  resolveLatestDmg(): Promise<ResolvedDistributionAsset | null>;
86
91
  resolveMacosDownload(): Promise<LocalDistributionAsset | null>;
87
92
  resolveLatestIpa(): Promise<ResolvedDistributionAsset | null>;
@@ -1,9 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
- import { mkdir, readdir, readFile, stat, unlink } from "node:fs/promises";
3
+ import { lstat, mkdir, readdir, readFile, stat, unlink } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { compareApkInstallOrder, compareSemver, compareWandInstallOrder, extractSemver } from "./version-utils.js";
6
6
  const GITHUB_CACHE_TTL_MS = 10 * 60 * 1000;
7
+ const MACOS_UPDATE_FILE_PATTERN = /^wand-v\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?\.(?:zip|dmg)$/i;
7
8
  /**
8
9
  * GitHub Release 正文还包含 Android/macOS/iOS 的安装指引;它们属于发布页,
9
10
  * 不该出现在 Android 的更新弹窗。保留分隔线前的变更摘要,并兼容旧版正文。
@@ -211,6 +212,83 @@ export class DistributionManager {
211
212
  }
212
213
  return { deleted, freedBytes };
213
214
  }
215
+ /** Beta 仅从当前服务端的 macOS 分发目录选择,绝不回退 GitHub。 */
216
+ async resolveLatestMacosBeta() {
217
+ const asset = await this.resolveMacosBetaAsset();
218
+ if (!asset?.version || asset.size <= 0)
219
+ return null;
220
+ const sha256 = await this.computeAssetSha256(asset);
221
+ if (!sha256)
222
+ return null;
223
+ return {
224
+ version: asset.version.split("+")[0],
225
+ downloadUrl: `/macos/update-download?fileName=${encodeURIComponent(asset.fileName)}`,
226
+ fileName: asset.fileName,
227
+ size: asset.size,
228
+ source: "local",
229
+ sha256,
230
+ };
231
+ }
232
+ /** 固定下载文件名,避免检查与下载之间新增包后下载到另一个版本。 */
233
+ async resolveMacosBetaDownload(fileName) {
234
+ if (!MACOS_UPDATE_FILE_PATTERN.test(fileName))
235
+ return null;
236
+ await this.refreshConfig();
237
+ const { config, configDir } = this.options;
238
+ if (config.macos?.enabled !== true)
239
+ return null;
240
+ const directory = resolveConfiguredDir(configDir, config.macos.dmgDir, "macos");
241
+ const filePath = path.join(directory, fileName);
242
+ try {
243
+ if (!(await lstat(filePath)).isFile())
244
+ return null;
245
+ }
246
+ catch {
247
+ return null;
248
+ }
249
+ return this.readLocalAsset(filePath, {
250
+ extension: fileName.toLowerCase().endsWith(".zip") ? ".zip" : ".dmg",
251
+ downloadUrl: `/macos/update-download?fileName=${encodeURIComponent(fileName)}`,
252
+ });
253
+ }
254
+ async resolveMacosBetaAsset() {
255
+ await this.refreshConfig();
256
+ const { config, configDir } = this.options;
257
+ if (config.macos?.enabled !== true)
258
+ return null;
259
+ const directory = resolveConfiguredDir(configDir, config.macos.dmgDir, "macos");
260
+ await mkdir(directory, { recursive: true });
261
+ const entries = await readdir(directory, { withFileTypes: true });
262
+ const candidates = await Promise.all(entries
263
+ .filter((entry) => entry.isFile() && MACOS_UPDATE_FILE_PATTERN.test(entry.name))
264
+ .map(async (entry) => ({
265
+ name: entry.name,
266
+ filePath: path.join(directory, entry.name),
267
+ fileStat: await stat(path.join(directory, entry.name)),
268
+ })));
269
+ candidates.sort((a, b) => {
270
+ const aVersion = extractArtifactVersion(a.name, path.extname(a.name));
271
+ const bVersion = extractArtifactVersion(b.name, path.extname(b.name));
272
+ const order = compareWandInstallOrder(aVersion ?? "", bVersion ?? "");
273
+ if (order !== 0)
274
+ return -order;
275
+ // 同版本优先 ZIP,避免 DMG 挂载;同格式多构建按最新修改时间挑选。
276
+ const format = Number(b.name.toLowerCase().endsWith(".zip")) - Number(a.name.toLowerCase().endsWith(".zip"));
277
+ return format || b.fileStat.mtimeMs - a.fileStat.mtimeMs;
278
+ });
279
+ const selected = candidates[0];
280
+ if (!selected)
281
+ return null;
282
+ return {
283
+ fileName: selected.name,
284
+ filePath: selected.filePath,
285
+ size: selected.fileStat.size,
286
+ updatedAt: selected.fileStat.mtime.toISOString(),
287
+ version: extractArtifactVersion(selected.name, path.extname(selected.name)),
288
+ downloadUrl: `/macos/update-download?fileName=${encodeURIComponent(selected.name)}`,
289
+ source: "local",
290
+ };
291
+ }
214
292
  async resolveLatestDmg() {
215
293
  const localDmg = await this.resolveMacosDownload();
216
294
  if (localDmg?.version) {
@@ -494,6 +494,18 @@ function runCliText(command, args, prompt, opts) {
494
494
  }
495
495
  reject(new QuickCommitError(`${command} CLI 失败:${(stderr || stdout).trim() || `exit ${code}`}`, "CLAUDE_CLI_FAILED"));
496
496
  });
497
+ // A CLI may exit before reading stdin (e.g. invalid credentials/argv).
498
+ // The writable stream emits EPIPE independently of ChildProcess.error;
499
+ // without a listener that becomes an uncaught exception after callers
500
+ // have already finished their turn or closed storage.
501
+ child.stdin?.on("error", (error) => {
502
+ if (settled)
503
+ return;
504
+ settled = true;
505
+ clearTimeout(timeout);
506
+ child.kill("SIGTERM");
507
+ reject(new QuickCommitError(`${command} CLI stdin failed: ${error.code ?? "write error"}`, "CLAUDE_CLI_FAILED"));
508
+ });
497
509
  child.stdin?.end(prompt);
498
510
  });
499
511
  }
@@ -3,6 +3,7 @@
3
3
  * Cards that are collapsed by default have their large results replaced
4
4
  * with a summary, and clients fetch full content on-demand via API.
5
5
  */
6
+ import { contentHasStructuredImage } from "./structured-content.js";
6
7
  const TRUNCATION_THRESHOLD = 200;
7
8
  const SUMMARY_LENGTH = 100;
8
9
  /**
@@ -162,6 +163,11 @@ export function truncateMessagesForTransport(messages, cardDefaults, streamingTu
162
163
  const toolName = toolNameMap.get(result.tool_use_id) || "";
163
164
  if (!isToolDefaultCollapsed(toolName, cardDefaults))
164
165
  return block;
166
+ // 带图片的 tool_result 永不截断:图片 part 被 JSON.stringify 截半后客户端再也
167
+ // 抽不出 data URI(线上表现就是「读图不显示,只剩一串 base64 残文」)。图片要么
168
+ // 整块送达(客户端会强制展开渲染),要么由 _truncated + 懒加载单独取回。
169
+ if (contentHasStructuredImage(result.content))
170
+ return block;
165
171
  const contentStr = getContentString(result.content);
166
172
  if (contentStr.length <= TRUNCATION_THRESHOLD)
167
173
  return block;
@@ -0,0 +1 @@
1
+ 6855db8c0423d995dbd57567076cdf019a4e3306a465d0828cca80ee2f85f34c wand-render
@@ -0,0 +1 @@
1
+ 783bd070dad27f2ec879ed5853f813f7ffdb3914d719d3ed947ec86af12a7b88 wand-render
@@ -0,0 +1 @@
1
+ 0.1.1
@@ -0,0 +1 @@
1
+ 7acb6e92e8f049ac87d8159905115dabe5f9a33f9d310e3c98f38b798c4302e5 wand-render
@@ -0,0 +1 @@
1
+ 0.1.1
@@ -0,0 +1 @@
1
+ 7814590a02ae2606fedc395ee1708921aec3b16332edb46fa2369e43e9a589f5 wand-render
@@ -0,0 +1 @@
1
+ 0.1.1
@@ -15,6 +15,11 @@ export declare class SessionInputError extends Error {
15
15
  readonly sessionStatus?: SessionSnapshot["status"] | undefined;
16
16
  constructor(message: string, code: "SESSION_NOT_FOUND" | "SESSION_NOT_RUNNING" | "SESSION_NO_PTY", sessionId: string, sessionStatus?: SessionSnapshot["status"] | undefined);
17
17
  }
18
+ /** Write outcome is unknown to the caller; do not record this as accepted input. */
19
+ export declare class PtyInputDeliveryError extends Error {
20
+ readonly sessionId: string;
21
+ constructor(sessionId: string, cause: unknown);
22
+ }
18
23
  export declare class ProcessManager extends EventEmitter {
19
24
  private readonly config;
20
25
  private readonly storage;
@@ -39,6 +44,7 @@ export declare class ProcessManager extends EventEmitter {
39
44
  private bindRestoredTerminal;
40
45
  private initializeClaudeBridge;
41
46
  private bindTerminalProcess;
47
+ private handleTerminalResync;
42
48
  private handleTerminalExit;
43
49
  private handleTerminalData;
44
50
  on(_event: "process", listener: ProcessEventHandler): this;
@@ -107,6 +113,10 @@ export declare class ProcessManager extends EventEmitter {
107
113
  */
108
114
  setSessionMode(id: string, mode: ExecutionMode): SessionSnapshot;
109
115
  sendInput(id: string, input: string, view?: "chat" | "terminal", shortcutKey?: string, trackUserInput?: boolean): SessionSnapshot;
116
+ sendInputConfirmed(id: string, input: string, view?: "chat" | "terminal", shortcutKey?: string, trackUserInput?: boolean): Promise<SessionSnapshot>;
117
+ private preparePtyInput;
118
+ private validatePtyInput;
119
+ private trackPtyInput;
110
120
  resize(id: string, cols: number, rows: number): SessionSnapshot;
111
121
  getTerminalState(id: string): PtyTerminalSnapshot | null;
112
122
  /**
@@ -150,6 +150,15 @@ export class SessionInputError extends Error {
150
150
  this.name = "SessionInputError";
151
151
  }
152
152
  }
153
+ /** Write outcome is unknown to the caller; do not record this as accepted input. */
154
+ export class PtyInputDeliveryError extends Error {
155
+ sessionId;
156
+ constructor(sessionId, cause) {
157
+ super("PTY input delivery could not be confirmed.", { cause });
158
+ this.sessionId = sessionId;
159
+ this.name = "PtyInputDeliveryError";
160
+ }
161
+ }
153
162
  const REAL_CONVERSATION_MIN_LINES = 2;
154
163
  const DISCOVERY_RECENT_WINDOW_MS = 10 * 60 * 1000;
155
164
  const START_TIME_SKEW_MS = 30 * 1000;
@@ -736,8 +745,9 @@ export class ProcessManager extends EventEmitter {
736
745
  });
737
746
  }
738
747
  bindTerminalProcess(record, child, onVisibleChunk) {
739
- child.onExit(({ exitCode }) => this.handleTerminalExit(record, child, exitCode));
748
+ child.onResync?.((state) => this.handleTerminalResync(record, child, state));
740
749
  child.onData((event) => this.handleTerminalData(record, child, event, onVisibleChunk));
750
+ child.onExit(({ exitCode }) => this.handleTerminalExit(record, child, exitCode));
741
751
  if (record.ptyBridge) {
742
752
  record.ptyBridge.setPtyWrite((input) => {
743
753
  if (this.sessions.get(record.id) !== record || record.ptyProcess !== child)
@@ -746,6 +756,31 @@ export class ProcessManager extends EventEmitter {
746
756
  });
747
757
  }
748
758
  }
759
+ handleTerminalResync(record, child, state) {
760
+ const current = this.sessions.get(record.id);
761
+ if (current !== record || current.ptyProcess !== child)
762
+ return;
763
+ current.terminalState?.dispose();
764
+ current.terminalState = restoreTerminalState(state);
765
+ current.output = state.output;
766
+ current.storedOutput = state.output;
767
+ current.ptyOutputSeq = state.seq;
768
+ current.ptyCols = state.cols;
769
+ current.ptyRows = state.rows;
770
+ current.ptyLaunchMarkerToken = state.launchMarkerToken;
771
+ current.providerShellMarker = state.launchMarkerToken
772
+ ? new PtyCliExitMarker(state.launchMarkerToken)
773
+ : null;
774
+ if (current.ptyBridge)
775
+ current.messages = current.ptyBridge.getMessages();
776
+ this.initializeClaudeBridge(current, state.output);
777
+ current.ptyBridge?.setPtyWrite((input) => {
778
+ if (this.sessions.get(record.id) === current && current.ptyProcess === child)
779
+ child.write(input);
780
+ });
781
+ this.flushPersist(current, true);
782
+ this.emitEvent({ type: "resync", sessionId: record.id, data: { reason: "render_replay_gap" } });
783
+ }
749
784
  handleTerminalExit(record, child, exitCode) {
750
785
  const current = this.sessions.get(record.id);
751
786
  // A stopped session can be resumed under the same public id before the old
@@ -1483,6 +1518,39 @@ export class ProcessManager extends EventEmitter {
1483
1518
  return this.snapshot(record);
1484
1519
  }
1485
1520
  sendInput(id, input, view, shortcutKey, trackUserInput = true) {
1521
+ const record = this.preparePtyInput(id, input, view, shortcutKey, trackUserInput);
1522
+ record.ptyProcess.write(input);
1523
+ this.persist(record);
1524
+ return this.snapshot(record);
1525
+ }
1526
+ async sendInputConfirmed(id, input, view, shortcutKey, trackUserInput = true) {
1527
+ const record = this.validatePtyInput(id);
1528
+ const terminal = record.ptyProcess;
1529
+ try {
1530
+ if (terminal.writeConfirmed)
1531
+ await terminal.writeConfirmed(input);
1532
+ else
1533
+ terminal.write(input);
1534
+ }
1535
+ catch (cause) {
1536
+ throw new PtyInputDeliveryError(id, cause);
1537
+ }
1538
+ const current = this.sessions.get(id);
1539
+ if (current !== record || record.ptyProcess !== terminal) {
1540
+ // The write reached an old PTY, but the caller cannot treat it as input
1541
+ // accepted by the current incarnation or persist the stale record.
1542
+ throw new PtyInputDeliveryError(id, new Error("PTY incarnation changed during write"));
1543
+ }
1544
+ this.trackPtyInput(record, input, view, shortcutKey, trackUserInput);
1545
+ this.persist(record);
1546
+ return this.snapshot(record);
1547
+ }
1548
+ preparePtyInput(id, input, view, shortcutKey, trackUserInput = true) {
1549
+ const record = this.validatePtyInput(id);
1550
+ this.trackPtyInput(record, input, view, shortcutKey, trackUserInput);
1551
+ return record;
1552
+ }
1553
+ validatePtyInput(id) {
1486
1554
  if (this.disposed)
1487
1555
  throw new Error("ProcessManager has been disposed.");
1488
1556
  const record = this.mustGet(id);
@@ -1495,6 +1563,10 @@ export class ProcessManager extends EventEmitter {
1495
1563
  console.error(`[ProcessManager] Rejecting input: session ${id} has no PTY`);
1496
1564
  throw new SessionInputError("Session is not running.", "SESSION_NO_PTY", id, record.status);
1497
1565
  }
1566
+ return record;
1567
+ }
1568
+ trackPtyInput(record, input, view, shortcutKey, trackUserInput = true) {
1569
+ const id = record.id;
1498
1570
  if (!record.ptyTopicDraft)
1499
1571
  record.ptyTopicDraft = createPtyTopicLineBuffer();
1500
1572
  const topicInput = consumePtyInputForTopic(record.ptyTopicDraft, input, view, shortcutKey);
@@ -1516,9 +1588,6 @@ export class ProcessManager extends EventEmitter {
1516
1588
  if (record.ptyBridge && trackUserInput) {
1517
1589
  record.ptyBridge.onUserInput(input);
1518
1590
  }
1519
- record.ptyProcess.write(input);
1520
- this.persist(record);
1521
- return this.snapshot(record);
1522
1591
  }
1523
1592
  resize(id, cols, rows) {
1524
1593
  const record = this.mustGet(id);
@@ -0,0 +1,61 @@
1
+ /**
2
+ * 支持的分发三元组。
3
+ *
4
+ * 与 `render-bin/manifest.json` 的版本条目键、`render/release.json` 的
5
+ * `supportedTriples` 必须一致;新增平台要同时改这三处与 CI 矩阵。
6
+ */
7
+ export declare const SUPPORTED_RENDER_TRIPLES: readonly ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"];
8
+ /** `uname -m` 的输出归一成 Node 的 arch 名;认不出返回 null(调用方回退 process.arch)。 */
9
+ export declare function normalizeMachineArch(machine: string | null | undefined): string | null;
10
+ /**
11
+ * 平台三元组(`darwin-arm64` 等)。
12
+ *
13
+ * 为什么不直接用 `process.arch`:macOS 上 Node 跑在 Rosetta 里时它报 `x64`,
14
+ * 而机器其实是 arm64 —— 于是会去找一个不存在(或架构不符)的产物,最后表现成
15
+ * 「自动模式下 Rust 引擎莫名其妙不生效」。`uname -m` 才反映真实硬件;只有
16
+ * `uname` 不可用(PATH 被清空、Windows)时才回退 `process.arch`。
17
+ *
18
+ * `rosettaTranslated` 是反向兜底:翻译态下 `uname -m` 同样报 `x86_64`,
19
+ * 只有 `sysctl.proc_translated` 能说明「这个 x64 进程跑在 arm64 机器上」。
20
+ * 少了它,上面那条规则在它最该生效的场景里形同虚设。
21
+ */
22
+ export declare function resolveRenderTriple(platform: string, nodeArch: string, unameMachine: string | null, rosettaTranslated?: boolean): string;
23
+ interface PlatformArchProbe {
24
+ unameMachine: string | null;
25
+ rosettaTranslated: boolean;
26
+ }
27
+ /**
28
+ * 真实架构探测(进程级缓存):`resolveRenderBinaryPath` 在一次启动里会被调用多次,
29
+ * 不该每次都 spawn 两个进程。
30
+ */
31
+ export declare function probePlatformArch(): PlatformArchProbe;
32
+ /** 当前进程应使用的三元组。 */
33
+ export declare function currentRenderTriple(): string;
34
+ /**
35
+ * 约定候选路径(不含 `WAND_RENDER_BIN` 与 `PATH`,调用方单独处理)。
36
+ *
37
+ * 顺序刻意把「刚 cargo build 出来的」排在分发位置之前:开发机上调 Rust 引擎时
38
+ * 期望立刻生效,而不是被上一次安装的旧二进制盖住。
39
+ *
40
+ * 旧布局(`wand-rs/`、仓库根 `native/`)已不存在,候选里不留兼容分支:
41
+ * 留着只会让「引擎没生效」这类问题多一个静默的假来源。
42
+ */
43
+ export declare function renderBinaryCandidates(configDir: string, triple: string): string[];
44
+ /**
45
+ * 解析 wand-render 可执行文件。
46
+ *
47
+ * 顺序:`WAND_RENDER_BIN` → `<repo>/render/target/{release,debug}` → `<configDir>/bin`
48
+ * → `<repo>/dist/native/<triple>` → `PATH`。返回 null 表示「这台机器没有」,由调用方
49
+ * 按 engine 决定告警还是报错。
50
+ */
51
+ export declare function resolveRenderBinaryPath(configPath: string): string | null;
52
+ /**
53
+ * 读取 wand-render 的版本。
54
+ *
55
+ * 先读二进制旁边的 `wand-render.version` sidecar(就位脚本会写),读不到才去跑
56
+ * `wand-render --version` —— 启动路径上不该无谓地执行一个进程。
57
+ * 两种方式都失败时返回 null:调用方只要一个展示用版本号,拿不到不能影响启动。
58
+ */
59
+ export declare function readRenderBinaryVersion(binaryPath: string): Promise<string | null>;
60
+ export declare function isExecutableFile(candidate: string): boolean;
61
+ export {};