@co0ontty/wand 4.72.2 → 4.75.0-beta.g1d477a2

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 (101) hide show
  1. package/dist/build-info.json +4 -4
  2. package/dist/claude-sdk-runner.d.ts +2 -3
  3. package/dist/config.d.ts +1 -1
  4. package/dist/config.js +48 -0
  5. package/dist/daemon-connection.d.ts +9 -0
  6. package/dist/daemon-connection.js +86 -0
  7. package/dist/distribution-manager.d.ts +5 -0
  8. package/dist/distribution-manager.js +79 -1
  9. package/dist/git-quick-commit.js +12 -0
  10. package/dist/message-truncator.js +9 -3
  11. package/dist/models.d.ts +39 -2
  12. package/dist/models.js +302 -9
  13. package/dist/native/darwin-arm64/wand-render +0 -0
  14. package/dist/native/darwin-arm64/wand-render.sha256 +1 -0
  15. package/dist/native/darwin-arm64/wand-render.version +1 -0
  16. package/dist/native/darwin-arm64/wand-structured-renderd +0 -0
  17. package/dist/native/darwin-arm64/wand-structured-renderd.sha256 +1 -0
  18. package/dist/native/darwin-arm64/wand-structured-renderd.version +1 -0
  19. package/dist/native/darwin-x64/wand-render +0 -0
  20. package/dist/native/darwin-x64/wand-render.sha256 +1 -0
  21. package/dist/native/darwin-x64/wand-render.version +1 -0
  22. package/dist/native/darwin-x64/wand-structured-renderd +0 -0
  23. package/dist/native/darwin-x64/wand-structured-renderd.sha256 +1 -0
  24. package/dist/native/darwin-x64/wand-structured-renderd.version +1 -0
  25. package/dist/native/linux-arm64/wand-render +0 -0
  26. package/dist/native/linux-arm64/wand-render.sha256 +1 -0
  27. package/dist/native/linux-arm64/wand-render.version +1 -0
  28. package/dist/native/linux-arm64/wand-structured-renderd +0 -0
  29. package/dist/native/linux-arm64/wand-structured-renderd.sha256 +1 -0
  30. package/dist/native/linux-arm64/wand-structured-renderd.version +1 -0
  31. package/dist/native/linux-x64/wand-render +0 -0
  32. package/dist/native/linux-x64/wand-render.sha256 +1 -0
  33. package/dist/native/linux-x64/wand-render.version +1 -0
  34. package/dist/native/linux-x64/wand-structured-renderd +0 -0
  35. package/dist/native/linux-x64/wand-structured-renderd.sha256 +1 -0
  36. package/dist/native/linux-x64/wand-structured-renderd.version +1 -0
  37. package/dist/process-manager.d.ts +12 -1
  38. package/dist/process-manager.js +88 -7
  39. package/dist/pty-terminal-state.d.ts +17 -0
  40. package/dist/pty-terminal-state.js +51 -1
  41. package/dist/render-binary.d.ts +61 -0
  42. package/dist/render-binary.js +204 -0
  43. package/dist/render-daemon-client.d.ts +202 -0
  44. package/dist/render-daemon-client.js +1164 -0
  45. package/dist/render-host.d.ts +88 -0
  46. package/dist/render-host.js +440 -0
  47. package/dist/render-protocol.d.ts +128 -0
  48. package/dist/render-protocol.js +99 -0
  49. package/dist/render-structured-client.d.ts +80 -0
  50. package/dist/render-structured-client.js +513 -0
  51. package/dist/render-structured-host.d.ts +31 -0
  52. package/dist/render-structured-host.js +218 -0
  53. package/dist/render-structured-protocol.d.ts +101 -0
  54. package/dist/render-structured-protocol.js +35 -0
  55. package/dist/server-session-routes.js +114 -12
  56. package/dist/server-task-routes.js +55 -6
  57. package/dist/server-update-routes.d.ts +2 -0
  58. package/dist/server-update-routes.js +44 -0
  59. package/dist/server.js +43 -13
  60. package/dist/session-ai-context.d.ts +1 -1
  61. package/dist/session-ai-context.js +15 -4
  62. package/dist/session-logger.d.ts +2 -0
  63. package/dist/session-logger.js +44 -1
  64. package/dist/session-transport.js +1 -1
  65. package/dist/storage.d.ts +2 -1
  66. package/dist/storage.js +14 -5
  67. package/dist/structured-client-protocol.d.ts +3 -1
  68. package/dist/structured-client-protocol.js +85 -25
  69. package/dist/structured-content.d.ts +20 -3
  70. package/dist/structured-content.js +80 -1
  71. package/dist/structured-exec-host.js +7 -1
  72. package/dist/structured-exec-pump.js +6 -2
  73. package/dist/structured-grok-adapter.js +22 -0
  74. package/dist/structured-pi-adapter.js +18 -2
  75. package/dist/structured-provider-common.d.ts +11 -9
  76. package/dist/structured-provider-common.js +60 -18
  77. package/dist/structured-session-manager.d.ts +2 -0
  78. package/dist/structured-session-manager.js +27 -3
  79. package/dist/task-types.d.ts +4 -1
  80. package/dist/terminal-daemon-client.d.ts +9 -2
  81. package/dist/terminal-daemon-client.js +119 -46
  82. package/dist/terminal-daemon-server.js +47 -8
  83. package/dist/terminal-host.d.ts +6 -0
  84. package/dist/terminal-host.js +3 -0
  85. package/dist/tui/commands.js +183 -10
  86. package/dist/tui/ipc-server.js +12 -0
  87. package/dist/types.d.ts +30 -10
  88. package/dist/unix-socket-keepalive.d.ts +3 -0
  89. package/dist/unix-socket-keepalive.js +94 -0
  90. package/dist/wand-task-sync.js +2 -1
  91. package/dist/web-ui/content/scripts.js +79 -73
  92. package/dist/web-ui/content/styles.css +1 -1
  93. package/dist/web-ui/content/tailwind.css +21 -0
  94. package/dist/web-ui/embedded-assets.js +2 -2
  95. package/dist/web-ui/thinking-efforts.d.ts +24 -0
  96. package/dist/web-ui/thinking-efforts.js +81 -0
  97. package/dist/ws-broadcast.d.ts +1 -1
  98. package/dist/ws-broadcast.js +28 -9
  99. package/package.json +8 -3
  100. package/scripts/install-render-binary.js +499 -0
  101. package/scripts/stage-render-binaries.js +626 -0
@@ -0,0 +1,88 @@
1
+ import { type RenderDaemonClient, type RenderDaemonReviver } from "./render-daemon-client.js";
2
+ import { TerminalDaemonClient } from "./terminal-daemon-client.js";
3
+ import { type TerminalAttachResult, type TerminalHost, type TerminalSpawnRequest } from "./terminal-host.js";
4
+ import type { RenderEngine } from "./types.js";
5
+ export interface RenderHostOptions {
6
+ /** 配置里显式指定的引擎;环境变量 WAND_RENDER_ENGINE 优先。 */
7
+ engine?: RenderEngine;
8
+ /** 配置里显式指定的二进制路径。 */
9
+ binaryPath?: string;
10
+ /** DB 中仍在运行的 PTY,用于旧版 Render 的超大 list 失败时逐个 attach。 */
11
+ knownSessionIds?: readonly string[];
12
+ }
13
+ /**
14
+ * 运行时自愈:没有活着的 owner、且端点不可用时,起一个 detached Render。
15
+ * 返回 true 表示起了新进程(调用方应立刻重连)。
16
+ *
17
+ * 覆盖两种「重试永远不会好」的形状(都是同一个后果:终端一直断、只能重启整个服务):
18
+ * - daemon 没了,socket 文件也被清掉(临时目录清理器,线上真发生过);
19
+ * - daemon 被 kill -9 / 崩溃,socket 文件作为陈留文件留了下来(没有监听者)。
20
+ *
21
+ * 保守之处:pid 文件里还有活进程就不插手(那个 daemon 会自己重建端点,
22
+ * docs/render-protocol.md §6 single instance);能连上监听者也不插手;同一个窗口只 spawn 一次。
23
+ */
24
+ export declare function createRenderDaemonReviver(configPath: string, binaryPath: string | null): RenderDaemonReviver;
25
+ export interface UpgradeAwareTerminalHost {
26
+ host: TerminalHost;
27
+ renderHost: TerminalHost | null;
28
+ legacyHost: TerminalDaemonClient | null;
29
+ }
30
+ export interface RoutedTerminalHosts {
31
+ legacy: TerminalHost | null;
32
+ render: TerminalHost;
33
+ }
34
+ /**
35
+ * 升级期无损路由:旧会话留在 legacy terminald,新会话一律进 Render。
36
+ * MIGRATION-COMPAT: 旧 PTY/Render v1 路由须保留到用户另行要求删除;
37
+ * 不能因为新 Render 负责新会话就杀掉或丢弃旧 owner。
38
+ *
39
+ * 所有权判断只能靠 legacy 自己的 inventory —— Server 无法从 DB 倒推
40
+ * 「这个 PTY 现在归谁」,因为升级前建的会话进程就在旧 daemon 里。
41
+ */
42
+ export declare class CompositeTerminalHost implements TerminalHost {
43
+ readonly persistent = true;
44
+ private readonly legacy;
45
+ private readonly render;
46
+ constructor(hosts: RoutedTerminalHosts);
47
+ attach(sessionId: string, afterSeq?: number): TerminalAttachResult | null;
48
+ createOrAttach(request: TerminalSpawnRequest, afterSeq?: number): Promise<TerminalAttachResult>;
49
+ forget(sessionId: string): void;
50
+ /**
51
+ * 两侧都只是解绑:legacy daemon 可能还持有升级前的旧 PTY,Render 更是必须
52
+ * 跨 Server 重启继续活着。**这里绝不能 kill 任何东西。**
53
+ */
54
+ disconnect(): void;
55
+ /**
56
+ * legacy 查询失败(socket 抖动)时按「不属于它」处理:宁可让 Render 侧查一遍,
57
+ * 也不能在会话恢复路径上抛异常 —— 那会让服务器重启后旧会话全部恢复失败。
58
+ */
59
+ private attachFromLegacy;
60
+ private legacyOwns;
61
+ }
62
+ /** 环境变量优先于 config;非法值忽略并告警。 */
63
+ export declare function resolveRenderEngine(envValue: string | undefined, configEngine: RenderEngine | undefined): RenderEngine;
64
+ export declare function createUpgradeAwareTerminalHost(configPath: string, options?: RenderHostOptions): Promise<UpgradeAwareTerminalHost>;
65
+ /**
66
+ * adopt 已存在的 Render,不存在则 spawn 后等待就绪。
67
+ *
68
+ * 协议(docs/render-protocol.md §6):pid 活着但 socket 不可用 → 等待就绪,
69
+ * 不另起第二个 daemon;协议版本不符 → 明确抛错,**不做降级运行**。
70
+ */
71
+ export declare function createRenderTerminalHost(configPath: string, options?: {
72
+ binaryPath?: string;
73
+ knownSessionIds?: readonly string[];
74
+ }): Promise<RenderDaemonClient>;
75
+ /**
76
+ * 该不该把正在跑的 daemon 换成装好的二进制:只有「装好的更新」且「daemon 空闲」才换。
77
+ * 纯决策,便于单测;daemon 持有 PTY 时绝不能动它(会杀掉用户的 shell)。
78
+ */
79
+ export declare function shouldUpgradeRenderDaemon(daemonVersion: string | null, binaryVersion: string | null, sessionCount: number): boolean;
80
+ /**
81
+ * detached spawn(stdio 忽略、unref),返回子进程 pid。
82
+ *
83
+ * `-c <configPath>` 是按 config 隔离的既定约定(docs/render-protocol.md §6):
84
+ * socket / token / pid / meta 全部由它派生。
85
+ */
86
+ export declare function spawnDetachedRenderProcess(configPath: string, binaryPath: string): number | null;
87
+ /** spawn 并等 socket + token 就绪(启动路径用)。 */
88
+ export declare function spawnDetachedRender(configPath: string, binaryPath: string, knownSessionIds?: readonly string[], reviveDaemon?: RenderDaemonReviver | null): Promise<RenderDaemonClient>;
@@ -0,0 +1,440 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
+ import net from "node:net";
4
+ import process from "node:process";
5
+ import { getErrorMessage } from "./error-utils.js";
6
+ import { isExecutableFile, readRenderBinaryVersion, resolveRenderBinaryPath } from "./render-binary.js";
7
+ import { connectExistingRenderClient } from "./render-daemon-client.js";
8
+ import { renderPaths } from "./render-protocol.js";
9
+ import { compareSemver } from "./version-utils.js";
10
+ import { connectExistingTerminalHost, createTerminalHost, TerminalDaemonClient } from "./terminal-daemon-client.js";
11
+ import { InProcessTerminalHost, } from "./terminal-host.js";
12
+ /** Render 的 socket/token 就绪等待窗口,与 legacy daemon 保持一致。 */
13
+ const RENDER_READY_TIMEOUT_MS = 5_000;
14
+ /** 自愈 revive 的最小间隔:重连退避可能很密集,窗口内只 spawn 一次。 */
15
+ const RENDER_REVIVE_BACKOFF_MS = 5_000;
16
+ /** 空闲 daemon 升级时等它退出的上限(超过就当升级失败,交给上层决定)。 */
17
+ const RENDER_SHUTDOWN_TIMEOUT_MS = 5_000;
18
+ /** 陈留 socket 的二次确认间隔(避开「刚 bind、还没 accept」的启动窗口)。 */
19
+ const RENDER_STALE_PROBE_DELAY_MS = 250;
20
+ /**
21
+ * 运行时自愈:没有活着的 owner、且端点不可用时,起一个 detached Render。
22
+ * 返回 true 表示起了新进程(调用方应立刻重连)。
23
+ *
24
+ * 覆盖两种「重试永远不会好」的形状(都是同一个后果:终端一直断、只能重启整个服务):
25
+ * - daemon 没了,socket 文件也被清掉(临时目录清理器,线上真发生过);
26
+ * - daemon 被 kill -9 / 崩溃,socket 文件作为陈留文件留了下来(没有监听者)。
27
+ *
28
+ * 保守之处:pid 文件里还有活进程就不插手(那个 daemon 会自己重建端点,
29
+ * docs/render-protocol.md §6 single instance);能连上监听者也不插手;同一个窗口只 spawn 一次。
30
+ */
31
+ export function createRenderDaemonReviver(configPath, binaryPath) {
32
+ let inFlight = null;
33
+ let lastSpawnAt = 0;
34
+ return () => {
35
+ if (inFlight)
36
+ return inFlight;
37
+ inFlight = (async () => {
38
+ if (process.platform === "win32")
39
+ return false;
40
+ const paths = renderPaths(configPath);
41
+ // 有活着的 owner 就不插手:端点要么还在,要么它自己会重建
42
+ // (旧版 daemon 没有看门狗,但也不能开第二个 daemon —— 协议 §6 single instance)。
43
+ if (readLiveRenderPid(paths.pidPath) !== null)
44
+ return false;
45
+ if (await isSocketListening(paths.socketPath))
46
+ return false;
47
+ // 端点文件还在但没人监听 = 崩溃留下的陈留 socket。它可能是「刚 bind、 还没 accept」
48
+ // 的启动窗口,隔一下再确认一次;确认真死了就把陈留文件交给新 daemon 自己的
49
+ // prepare_socket_path 去判定(它只删本用户的 0600 socket,不碰别人的路径)。
50
+ if (existsSync(paths.socketPath)) {
51
+ await new Promise((resolve) => setTimeout(resolve, RENDER_STALE_PROBE_DELAY_MS));
52
+ if (await isSocketListening(paths.socketPath))
53
+ return false;
54
+ }
55
+ if (Date.now() - lastSpawnAt < RENDER_REVIVE_BACKOFF_MS)
56
+ return false;
57
+ const resolved = binaryPath && isExecutableFile(binaryPath) ? binaryPath : resolveRenderBinaryPath(configPath);
58
+ if (!resolved)
59
+ return false;
60
+ lastSpawnAt = Date.now();
61
+ const pid = spawnDetachedRenderProcess(configPath, resolved);
62
+ const deadline = Date.now() + RENDER_READY_TIMEOUT_MS;
63
+ while (Date.now() < deadline) {
64
+ // 新 daemon 先 bind 再写 pid:pid 文件里出现活 pid 才算它真正接管了这个端点。
65
+ if (readLiveRenderPid(paths.pidPath) !== null && await isSocketListening(paths.socketPath)) {
66
+ process.stderr.write(`[wand] Restarted Render daemon (pid ${pid ?? "?"}); reconnecting.\n`);
67
+ return true;
68
+ }
69
+ await new Promise((resolve) => setTimeout(resolve, 50));
70
+ }
71
+ // 起了却没就绪(二进制不对/卡在启动/端点被别人占着):回收自己生的进程,
72
+ // 不留占着 socket 名字的孤儿 daemon。
73
+ reapUnreadyRender(pid ?? undefined);
74
+ return false;
75
+ })().finally(() => { inFlight = null; });
76
+ return inFlight;
77
+ };
78
+ }
79
+ /**
80
+ * 升级期无损路由:旧会话留在 legacy terminald,新会话一律进 Render。
81
+ * MIGRATION-COMPAT: 旧 PTY/Render v1 路由须保留到用户另行要求删除;
82
+ * 不能因为新 Render 负责新会话就杀掉或丢弃旧 owner。
83
+ *
84
+ * 所有权判断只能靠 legacy 自己的 inventory —— Server 无法从 DB 倒推
85
+ * 「这个 PTY 现在归谁」,因为升级前建的会话进程就在旧 daemon 里。
86
+ */
87
+ export class CompositeTerminalHost {
88
+ persistent = true;
89
+ legacy;
90
+ render;
91
+ constructor(hosts) {
92
+ this.legacy = hosts.legacy;
93
+ this.render = hosts.render;
94
+ }
95
+ attach(sessionId, afterSeq = 0) {
96
+ // legacy 命中就直接用;返回 null 说明这条会话不属于它(新会话在 Render)。
97
+ const fromLegacy = this.attachFromLegacy(sessionId, afterSeq);
98
+ if (fromLegacy)
99
+ return fromLegacy;
100
+ return this.render.attach(sessionId, afterSeq);
101
+ }
102
+ async createOrAttach(request, afterSeq = 0) {
103
+ if (this.legacyOwns(request.sessionId)) {
104
+ return this.legacy.createOrAttach(request, afterSeq);
105
+ }
106
+ return this.render.createOrAttach(request, afterSeq);
107
+ }
108
+ forget(sessionId) {
109
+ this.legacy?.forget(sessionId);
110
+ this.render.forget(sessionId);
111
+ }
112
+ /**
113
+ * 两侧都只是解绑:legacy daemon 可能还持有升级前的旧 PTY,Render 更是必须
114
+ * 跨 Server 重启继续活着。**这里绝不能 kill 任何东西。**
115
+ */
116
+ disconnect() {
117
+ this.legacy?.disconnect();
118
+ this.render.disconnect();
119
+ }
120
+ /**
121
+ * legacy 查询失败(socket 抖动)时按「不属于它」处理:宁可让 Render 侧查一遍,
122
+ * 也不能在会话恢复路径上抛异常 —— 那会让服务器重启后旧会话全部恢复失败。
123
+ */
124
+ attachFromLegacy(sessionId, afterSeq) {
125
+ if (!this.legacy)
126
+ return null;
127
+ try {
128
+ return this.legacy.attach(sessionId, afterSeq) ?? null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ }
134
+ legacyOwns(sessionId) {
135
+ if (!this.legacy)
136
+ return false;
137
+ try {
138
+ return this.legacy.attach(sessionId) !== null;
139
+ }
140
+ catch {
141
+ // 查询失败按「不属于 legacy」处理:宁可让 Render 建新会话,也不要把请求丢进坏掉的 daemon。
142
+ return false;
143
+ }
144
+ }
145
+ }
146
+ /** 环境变量优先于 config;非法值忽略并告警。 */
147
+ export function resolveRenderEngine(envValue, configEngine) {
148
+ const raw = (envValue ?? "").trim().toLowerCase();
149
+ if (raw) {
150
+ if (raw === "rust" || raw === "legacy" || raw === "auto")
151
+ return raw;
152
+ process.stderr.write(`[wand] WAND_RENDER_ENGINE=${envValue} is invalid (expected rust|legacy|auto); ignoring it.\n`);
153
+ }
154
+ return configEngine === "rust" || configEngine === "legacy" ? configEngine : "auto";
155
+ }
156
+ export async function createUpgradeAwareTerminalHost(configPath, options = {}) {
157
+ if (process.env.WAND_TEST_MODE === "1" || process.env.NODE_TEST_CONTEXT) {
158
+ // 与 legacy 缝隙一致:测试进程不拉起任何常驻 daemon。
159
+ return { host: new InProcessTerminalHost(), renderHost: null, legacyHost: null };
160
+ }
161
+ const engine = resolveRenderEngine(process.env.WAND_RENDER_ENGINE, options.engine);
162
+ // engine=legacy 等价于当前行为:adopt 已有 terminald,不存在才 spawn。
163
+ if (engine === "legacy") {
164
+ const legacy = await createTerminalHost(configPath);
165
+ return { host: legacy, renderHost: null, legacyHost: asTerminalDaemonClient(legacy) };
166
+ }
167
+ // 先 adopt 升级前的 terminald,以它的 inventory 判定旧 PTY 的 owner。
168
+ const legacy = await connectExistingTerminalHost(configPath);
169
+ const configuredBinary = options.binaryPath?.trim();
170
+ const binaryPath = configuredBinary || resolveRenderBinaryPath(configPath);
171
+ if (!binaryPath) {
172
+ if (engine === "rust") {
173
+ throw new Error("render.engine=rust but no wand-render binary was found (checked WAND_RENDER_BIN, render/target/{release,debug}, " +
174
+ "<configDir>/bin, dist/native/<platform>-<arch>, PATH). " +
175
+ "Build it with `npm run build:render-native` (cargo build --release in render/), " +
176
+ "or set render.engine=auto/legacy to fall back.");
177
+ }
178
+ process.stderr.write("[wand] WARNING: render.engine=auto but no wand-render binary was found; PTYs stay on the legacy terminald. " +
179
+ "Build it with `npm run build:render-native`, stage a published build with `npm run build:render-bin`, or set WAND_RENDER_BIN.\n");
180
+ const fallback = legacy ?? await createTerminalHost(configPath);
181
+ return { host: fallback, renderHost: null, legacyHost: asTerminalDaemonClient(fallback) };
182
+ }
183
+ if (configuredBinary && !isExecutableFile(configuredBinary)) {
184
+ // 显式配置指向不可执行文件属于配置错误,静默回退 legacy 会让人以为 Rust 引擎在跑。
185
+ throw new Error(`render.binaryPath points at ${configuredBinary}, which is not an executable file.`);
186
+ }
187
+ let render;
188
+ try {
189
+ render = await createRenderTerminalHost(configPath, {
190
+ binaryPath,
191
+ knownSessionIds: options.knownSessionIds,
192
+ });
193
+ }
194
+ catch (error) {
195
+ if (engine === "rust")
196
+ throw error;
197
+ // auto 模式下把失败讲清楚再回退:这种情况用户最需要知道 Rust 引擎没生效。
198
+ process.stderr.write(`[wand] WARNING: render.engine=auto could not bring up Render (${getErrorMessage(error)}); falling back to the legacy terminald.\n`);
199
+ const fallback = legacy ?? await createTerminalHost(configPath);
200
+ return { host: fallback, renderHost: null, legacyHost: asTerminalDaemonClient(fallback) };
201
+ }
202
+ // MIGRATION-COMPAT: Render v1 only owns PTYs. Keep terminald as the owner
203
+ // of existing structured runs during the v2 migration, even if new runs
204
+ // eventually use Rust. Remove this path only on a later explicit request.
205
+ // Structured CLI runs still need a daemon-backed host to survive a web restart,
206
+ // including on a fresh installation where no
207
+ // pre-upgrade terminald exists. The composite routes new PTYs to Render.
208
+ let structuredHost;
209
+ try {
210
+ const daemon = legacy ?? await createTerminalHost(configPath);
211
+ const client = asTerminalDaemonClient(daemon);
212
+ if (!client)
213
+ throw new Error("A persistent terminal daemon is required for structured CLI runs.");
214
+ structuredHost = client;
215
+ }
216
+ catch (error) {
217
+ render.disconnect();
218
+ throw error;
219
+ }
220
+ const version = await readRenderBinaryVersion(binaryPath);
221
+ process.stderr.write(`[wand] Render engine active (${version ? `wand-render ${version}` : "version unknown"}, ${binaryPath}); ` +
222
+ "terminald available for structured CLI runs and pre-upgrade PTYs.\n");
223
+ return {
224
+ host: new CompositeTerminalHost({ legacy: structuredHost, render }),
225
+ renderHost: render,
226
+ legacyHost: structuredHost,
227
+ };
228
+ }
229
+ /**
230
+ * adopt 已存在的 Render,不存在则 spawn 后等待就绪。
231
+ *
232
+ * 协议(docs/render-protocol.md §6):pid 活着但 socket 不可用 → 等待就绪,
233
+ * 不另起第二个 daemon;协议版本不符 → 明确抛错,**不做降级运行**。
234
+ */
235
+ export async function createRenderTerminalHost(configPath, options = {}) {
236
+ const binaryPath = options.binaryPath ?? resolveRenderBinaryPath(configPath);
237
+ // adopt 到的是已经在跑的 daemon:它可能是 npm 升级前启动的旧二进制。
238
+ const adopt = (client) => upgradeRenderDaemonIfIdle(configPath, binaryPath, options.knownSessionIds, client);
239
+ const paths = renderPaths(configPath);
240
+ const endpointExists = process.platform !== "win32" && existsSync(paths.socketPath);
241
+ // 已经 adopt 过的客户端需要能自己把 daemon 拉回来:adopt 完 binaryPath 就丢了,
242
+ // 所以这里把解析结果一次性算好交给它。
243
+ const reviveDaemon = createRenderDaemonReviver(configPath, binaryPath);
244
+ if (endpointExists) {
245
+ const adopted = await connectExistingRenderClient(configPath, options.knownSessionIds, reviveDaemon);
246
+ if (adopted)
247
+ return adopt(adopted);
248
+ if (await isSocketListening(paths.socketPath)) {
249
+ // 有人监听却拒绝 adopt(协议版本不符 / token 不符):这是错误配置,必须报出来而不是绕开。
250
+ const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, options.knownSessionIds, reviveDaemon);
251
+ if (starting)
252
+ return adopt(starting);
253
+ throw new Error(`A Render daemon is listening on ${paths.socketPath} but rejected adoption. Refusing to spawn a competing Render; ` +
254
+ "check the protocol version/token of the running binary, or stop it first.");
255
+ }
256
+ // 没人在监听,且没有活着的 pid = 上一次 renderd 崩溃留下的孤儿 socket 文件。
257
+ // 清掉它再 spawn,否则会永久卡在「等一个永远不会就绪的 socket」。
258
+ if (readLiveRenderPid(paths.pidPath) === null) {
259
+ try {
260
+ unlinkSync(paths.socketPath);
261
+ }
262
+ catch { /* 已被别处清掉即可 */ }
263
+ }
264
+ }
265
+ // 进程活着但 socket 还没就绪(启动竞态 / 正在 drain):等它就绪,
266
+ // 绝不另起第二个 daemon(协议 §6 single instance)。
267
+ const livePid = readLiveRenderPid(paths.pidPath);
268
+ if (livePid !== null) {
269
+ const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, options.knownSessionIds, reviveDaemon);
270
+ if (starting)
271
+ return adopt(starting);
272
+ throw new Error(`Render process ${livePid} is alive but its socket ${paths.socketPath} is unavailable; refusing to spawn a second daemon.`);
273
+ }
274
+ if (!binaryPath)
275
+ throw new Error("wand-render binary is required to start Render");
276
+ return spawnDetachedRender(configPath, binaryPath, options.knownSessionIds, reviveDaemon);
277
+ }
278
+ /**
279
+ * 该不该把正在跑的 daemon 换成装好的二进制:只有「装好的更新」且「daemon 空闲」才换。
280
+ * 纯决策,便于单测;daemon 持有 PTY 时绝不能动它(会杀掉用户的 shell)。
281
+ */
282
+ export function shouldUpgradeRenderDaemon(daemonVersion, binaryVersion, sessionCount) {
283
+ if (!daemonVersion || !binaryVersion)
284
+ return false;
285
+ if (compareSemver(daemonVersion, binaryVersion) >= 0)
286
+ return false;
287
+ return sessionCount === 0;
288
+ }
289
+ /**
290
+ * 装好的二进制比正在跑的 daemon 新时怎么办。
291
+ *
292
+ * npm 升级 / `./start.sh` 都不会重启正在跑的 daemon(不能杀别人的 PTY),所以升级后
293
+ * 一直在跑旧代码 —— 2026-09-24 的线上事故就是这么来的:端点自愈是 9/23 21:26 补上的,
294
+ * 但 terminald 是 9/19 启的老进程、Render 是 9/23 10:49 的老二进制,socket 被临时目录
295
+ * 清理器删掉后永远回不来。
296
+ *
297
+ * 所以:daemon 空闲(没有 PTY)就直接把它换到新二进制上 —— 升级完就好;
298
+ * 还有会话就只大声告警,让人在会话结束后跑 `./start.sh --restart-daemons`。
299
+ */
300
+ async function upgradeRenderDaemonIfIdle(configPath, binaryPath, knownSessionIds, client) {
301
+ if (!binaryPath)
302
+ return client;
303
+ const daemonVersion = client.version;
304
+ if (!daemonVersion)
305
+ return client;
306
+ const binaryVersion = await readRenderBinaryVersion(binaryPath);
307
+ if (!binaryVersion || compareSemver(daemonVersion, binaryVersion) >= 0)
308
+ return client;
309
+ const sessions = client.sessionCount;
310
+ if (!shouldUpgradeRenderDaemon(daemonVersion, binaryVersion, sessions)) {
311
+ process.stderr.write(`[wand] WARNING: Render daemon ${daemonVersion} is older than the installed binary ${binaryVersion} but still owns ` +
312
+ `${sessions} PTY session(s); it keeps running the old code. Restart it when those sessions are done: ./start.sh --restart-daemons\n`);
313
+ return client;
314
+ }
315
+ try {
316
+ await client.requestShutdownNow();
317
+ client.disconnect();
318
+ await waitForRenderProcessExit(renderPaths(configPath).pidPath, RENDER_SHUTDOWN_TIMEOUT_MS);
319
+ const upgraded = await spawnDetachedRender(configPath, binaryPath, knownSessionIds);
320
+ process.stderr.write(`[wand] Render daemon upgraded ${daemonVersion} -> ${binaryVersion} (it was idle).\n`);
321
+ return upgraded;
322
+ }
323
+ catch (error) {
324
+ // 已经让它收摊了:回不去旧 client,报清楚并让上层(auto 模式)回退 legacy。
325
+ throw new Error(`Render daemon ${daemonVersion} was stopped for an upgrade to ${binaryVersion} but the replacement did not come up: ` +
326
+ getErrorMessage(error));
327
+ }
328
+ }
329
+ /** 等旧 daemon 真的退出(新 daemon 会因为 pid 活着而拒绝启动)。 */
330
+ async function waitForRenderProcessExit(pidPath, timeoutMs) {
331
+ const deadline = Date.now() + timeoutMs;
332
+ while (Date.now() < deadline) {
333
+ if (readLiveRenderPid(pidPath) === null)
334
+ return;
335
+ await new Promise((resolve) => setTimeout(resolve, 50));
336
+ }
337
+ }
338
+ /**
339
+ * detached spawn(stdio 忽略、unref),返回子进程 pid。
340
+ *
341
+ * `-c <configPath>` 是按 config 隔离的既定约定(docs/render-protocol.md §6):
342
+ * socket / token / pid / meta 全部由它派生。
343
+ */
344
+ export function spawnDetachedRenderProcess(configPath, binaryPath) {
345
+ const child = spawn(binaryPath, ["-c", configPath], {
346
+ detached: true,
347
+ stdio: "ignore",
348
+ cwd: process.cwd(),
349
+ env: process.env,
350
+ });
351
+ child.on("error", (error) => {
352
+ process.stderr.write(`[wand] Failed to spawn wand-render: ${getErrorMessage(error)}\n`);
353
+ });
354
+ child.unref();
355
+ return typeof child.pid === "number" ? child.pid : null;
356
+ }
357
+ /** spawn 并等 socket + token 就绪(启动路径用)。 */
358
+ export async function spawnDetachedRender(configPath, binaryPath, knownSessionIds, reviveDaemon = null) {
359
+ const paths = renderPaths(configPath);
360
+ const pid = spawnDetachedRenderProcess(configPath, binaryPath);
361
+ const client = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, knownSessionIds, reviveDaemon);
362
+ if (!client) {
363
+ // 自己生的进程自己收:就位超时(选错了二进制、二进制是 stub、或者它卡在启动)
364
+ // 时如果不回收,就会留下一个永久孤儿 daemon —— 它占着 socket 名字、下次启动会被
365
+ // 当成「活着但不接受领养」而直接阻塞启动。收尾是 best-effort,失败不影响报错。
366
+ reapUnreadyRender(pid ?? undefined);
367
+ throw new Error(`Spawned ${binaryPath} but its socket/token (${paths.socketPath}) did not become ready within ${RENDER_READY_TIMEOUT_MS}ms.`);
368
+ }
369
+ return client;
370
+ }
371
+ async function waitForRender(configPath, timeoutMs, knownSessionIds, reviveDaemon = null) {
372
+ const deadline = Date.now() + timeoutMs;
373
+ while (Date.now() < deadline) {
374
+ const client = await connectExistingRenderClient(configPath, knownSessionIds, reviveDaemon);
375
+ if (client)
376
+ return client;
377
+ await new Promise((resolve) => setTimeout(resolve, 50));
378
+ }
379
+ return null;
380
+ }
381
+ /**
382
+ * 回收一个「已 spawn 但始终没就绪」的 Render 进程。
383
+ *
384
+ * 不回收会留下孤儿 daemon:它占着按 config 派生的 socket 名字,下次启动会被判成
385
+ * 「进程活着但 socket 不可达」,于是启动直接失败而不是重建 —— 一个失败的启动不该
386
+ * 破坏后续所有启动。先 SIGTERM 给它清理 socket/token 的机会,短暂等待后 SIGKILL。
387
+ */
388
+ function reapUnreadyRender(pid) {
389
+ if (typeof pid !== "number" || pid <= 0)
390
+ return;
391
+ const signal = (name) => {
392
+ try {
393
+ process.kill(pid, name);
394
+ }
395
+ catch { /* 已经退出 */ }
396
+ };
397
+ signal("SIGTERM");
398
+ const timer = setTimeout(() => signal("SIGKILL"), 500);
399
+ timer.unref?.();
400
+ }
401
+ /** 对端是否真的在监听:区分「活着但拒绝 adopt」与「上一轮崩溃留下的孤儿 socket 文件」。 */ function isSocketListening(socketPath) {
402
+ return new Promise((resolve) => {
403
+ const socket = net.createConnection(socketPath);
404
+ let settled = false;
405
+ const finish = (alive) => {
406
+ if (settled)
407
+ return;
408
+ settled = true;
409
+ clearTimeout(timer);
410
+ socket.destroy();
411
+ resolve(alive);
412
+ };
413
+ socket.once("connect", () => finish(true));
414
+ socket.once("error", () => finish(false));
415
+ const timer = setTimeout(() => finish(false), 300);
416
+ timer.unref?.();
417
+ });
418
+ }
419
+ function readLiveRenderPid(pidPath) {
420
+ let pid;
421
+ try {
422
+ pid = Number(readFileSync(pidPath, "utf8").trim());
423
+ }
424
+ catch {
425
+ return null;
426
+ }
427
+ if (!Number.isSafeInteger(pid) || pid <= 0)
428
+ return null;
429
+ try {
430
+ process.kill(pid, 0);
431
+ return pid;
432
+ }
433
+ catch {
434
+ return null;
435
+ }
436
+ }
437
+ /** structuredExecHost 只认 daemon 客户端;测试注入的 in-process host 不算。 */
438
+ function asTerminalDaemonClient(host) {
439
+ return host instanceof TerminalDaemonClient ? host : null;
440
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Render 协议 v1 的 TS 镜像。Rust 单一真源在
3
+ * `render/crates/wand-render-protocol/src/lib.rs`,契约文档在
4
+ * `render/docs/render-protocol.md`(本仓库 `docs/render-protocol.md` 只是指针)。
5
+ *
6
+ * 两侧常量与字段名必须逐一对齐;任一侧改动都要同时提升版本号并更新文档。
7
+ */
8
+ export declare const RENDER_PROTOCOL_VERSION = 1;
9
+ /** 单帧上限:`u32` 大端长度前缀 + UTF-8 JSON。 */
10
+ export declare const MAX_FRAME_BYTES: number;
11
+ /** 与 `PTY_OUTPUT_MAX_SIZE` 对齐(chunk 窗口 / output 有界)。 */
12
+ export declare const RENDER_PTY_OUTPUT_MAX_CHARS = 200000;
13
+ export type RenderErrorCode = "unauthorized" | "badRequest" | "notFound" | "conflict" | "unsupportedMethod" | "protocolMismatch" | "internal";
14
+ export interface RenderRequest {
15
+ id: number;
16
+ token: string;
17
+ protocolVersion: number;
18
+ method: RenderMethod;
19
+ params?: Record<string, unknown>;
20
+ }
21
+ export type RenderMethod = "hello" | "ping" | "list" | "attach" | "createOrAttach" | "write" | "resize" | "kill" | "forget" | "stats" | "shutdown";
22
+ export interface RenderResponse {
23
+ id: number;
24
+ ok: boolean;
25
+ result?: unknown;
26
+ error?: {
27
+ code: RenderErrorCode;
28
+ message: string;
29
+ };
30
+ }
31
+ export type RenderEvent = {
32
+ event: "data";
33
+ sessionId: string;
34
+ incarnationId: string;
35
+ data: string;
36
+ seq: number;
37
+ } | {
38
+ event: "exit";
39
+ sessionId: string;
40
+ incarnationId: string;
41
+ exitCode: number | null;
42
+ signal: number | null;
43
+ } | {
44
+ event: "reconcile";
45
+ sessionIds: string[];
46
+ };
47
+ export interface RenderHelloResult {
48
+ version: string;
49
+ protocolVersion: number;
50
+ pid: number;
51
+ startedAt: string;
52
+ sessions: number;
53
+ }
54
+ export interface RenderSessionState {
55
+ sessionId: string;
56
+ incarnationId: string;
57
+ pid: number;
58
+ status: "running" | "exited";
59
+ exitCode: number | null;
60
+ cols: number;
61
+ rows: number;
62
+ seq: number;
63
+ output: string;
64
+ chunks: {
65
+ data: string;
66
+ seq: number;
67
+ }[];
68
+ terminalSnapshot: {
69
+ version: number;
70
+ data: string;
71
+ cols: number;
72
+ rows: number;
73
+ pending: ({
74
+ type: "data";
75
+ data: string;
76
+ } | {
77
+ type: "resize";
78
+ cols: number;
79
+ rows: number;
80
+ })[];
81
+ } | null;
82
+ launchMarkerToken: string | null;
83
+ }
84
+ export interface RenderCreateOrAttachParams {
85
+ sessionId: string;
86
+ file: string;
87
+ args: string[];
88
+ cwd: string;
89
+ env: Record<string, string>;
90
+ name: string;
91
+ cols: number;
92
+ rows: number;
93
+ launchMarkerToken?: string;
94
+ afterSeq?: number;
95
+ }
96
+ export interface RenderStatsResult {
97
+ uptimeMs: number;
98
+ sessions: number;
99
+ liveBytes: number;
100
+ rssBytes: number;
101
+ }
102
+ export interface RenderPaths {
103
+ socketPath: string;
104
+ tokenPath: string;
105
+ pidPath: string;
106
+ metaPath: string;
107
+ }
108
+ /**
109
+ * Render 的按 config 隔离寻址。
110
+ *
111
+ * 与 legacy `terminald`(`wand-terminald-<uid>-<suffix>.sock` /
112
+ * `.terminald-<suffix>.token`)**刻意使用不同文件名**:升级期两套并存,
113
+ * 但永不互相领养。
114
+ */
115
+ export declare function renderPaths(configPath: string): RenderPaths;
116
+ /** 编码一帧:`u32` 大端长度 + UTF-8 JSON。 */
117
+ export declare function encodeRenderFrame(value: unknown): Buffer;
118
+ /**
119
+ * 从累积缓冲区解出所有完整帧。
120
+ *
121
+ * 返回剩余(不完整)字节。长度越界时抛错,调用方应关闭连接。
122
+ */
123
+ export declare function decodeRenderFrames<T>(buffer: Buffer): {
124
+ frames: T[];
125
+ rest: Buffer;
126
+ };
127
+ /** 是否为 protocol v1 事件帧(无 `id`,带 `event`)。 */
128
+ export declare function isRenderEvent(value: unknown): value is RenderEvent;