@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
@@ -0,0 +1,64 @@
1
+ import { type RenderDaemonClient } 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
+ export interface UpgradeAwareTerminalHost {
14
+ host: TerminalHost;
15
+ renderHost: TerminalHost | null;
16
+ legacyHost: TerminalDaemonClient | null;
17
+ }
18
+ export interface RoutedTerminalHosts {
19
+ legacy: TerminalHost | null;
20
+ render: TerminalHost;
21
+ }
22
+ /**
23
+ * 升级期无损路由:旧会话留在 legacy terminald,新会话一律进 Render。
24
+ * MIGRATION-COMPAT: 旧 PTY/Render v1 路由须保留到用户另行要求删除;
25
+ * 不能因为新 Render 负责新会话就杀掉或丢弃旧 owner。
26
+ *
27
+ * 所有权判断只能靠 legacy 自己的 inventory —— Server 无法从 DB 倒推
28
+ * 「这个 PTY 现在归谁」,因为升级前建的会话进程就在旧 daemon 里。
29
+ */
30
+ export declare class CompositeTerminalHost implements TerminalHost {
31
+ readonly persistent = true;
32
+ private readonly legacy;
33
+ private readonly render;
34
+ constructor(hosts: RoutedTerminalHosts);
35
+ attach(sessionId: string, afterSeq?: number): TerminalAttachResult | null;
36
+ createOrAttach(request: TerminalSpawnRequest, afterSeq?: number): Promise<TerminalAttachResult>;
37
+ forget(sessionId: string): void;
38
+ /**
39
+ * 两侧都只是解绑:legacy daemon 可能还持有升级前的旧 PTY,Render 更是必须
40
+ * 跨 Server 重启继续活着。**这里绝不能 kill 任何东西。**
41
+ */
42
+ disconnect(): void;
43
+ /**
44
+ * legacy 查询失败(socket 抖动)时按「不属于它」处理:宁可让 Render 侧查一遍,
45
+ * 也不能在会话恢复路径上抛异常 —— 那会让服务器重启后旧会话全部恢复失败。
46
+ */
47
+ private attachFromLegacy;
48
+ private legacyOwns;
49
+ }
50
+ /** 环境变量优先于 config;非法值忽略并告警。 */
51
+ export declare function resolveRenderEngine(envValue: string | undefined, configEngine: RenderEngine | undefined): RenderEngine;
52
+ export declare function createUpgradeAwareTerminalHost(configPath: string, options?: RenderHostOptions): Promise<UpgradeAwareTerminalHost>;
53
+ /**
54
+ * adopt 已存在的 Render,不存在则 spawn 后等待就绪。
55
+ *
56
+ * 协议(docs/render-protocol.md §6):pid 活着但 socket 不可用 → 等待就绪,
57
+ * 不另起第二个 daemon;协议版本不符 → 明确抛错,**不做降级运行**。
58
+ */
59
+ export declare function createRenderTerminalHost(configPath: string, options?: {
60
+ binaryPath?: string;
61
+ knownSessionIds?: readonly string[];
62
+ }): Promise<RenderDaemonClient>;
63
+ /** detached spawn(stdio 忽略、unref),并等 socket + token 就绪。 */
64
+ export declare function spawnDetachedRender(configPath: string, binaryPath: string, knownSessionIds?: readonly string[]): Promise<RenderDaemonClient>;
@@ -0,0 +1,301 @@
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 { connectExistingTerminalHost, createTerminalHost, TerminalDaemonClient } from "./terminal-daemon-client.js";
10
+ import { InProcessTerminalHost, } from "./terminal-host.js";
11
+ /** Render 的 socket/token 就绪等待窗口,与 legacy daemon 保持一致。 */
12
+ const RENDER_READY_TIMEOUT_MS = 5_000;
13
+ /**
14
+ * 升级期无损路由:旧会话留在 legacy terminald,新会话一律进 Render。
15
+ * MIGRATION-COMPAT: 旧 PTY/Render v1 路由须保留到用户另行要求删除;
16
+ * 不能因为新 Render 负责新会话就杀掉或丢弃旧 owner。
17
+ *
18
+ * 所有权判断只能靠 legacy 自己的 inventory —— Server 无法从 DB 倒推
19
+ * 「这个 PTY 现在归谁」,因为升级前建的会话进程就在旧 daemon 里。
20
+ */
21
+ export class CompositeTerminalHost {
22
+ persistent = true;
23
+ legacy;
24
+ render;
25
+ constructor(hosts) {
26
+ this.legacy = hosts.legacy;
27
+ this.render = hosts.render;
28
+ }
29
+ attach(sessionId, afterSeq = 0) {
30
+ // legacy 命中就直接用;返回 null 说明这条会话不属于它(新会话在 Render)。
31
+ const fromLegacy = this.attachFromLegacy(sessionId, afterSeq);
32
+ if (fromLegacy)
33
+ return fromLegacy;
34
+ return this.render.attach(sessionId, afterSeq);
35
+ }
36
+ async createOrAttach(request, afterSeq = 0) {
37
+ if (this.legacyOwns(request.sessionId)) {
38
+ return this.legacy.createOrAttach(request, afterSeq);
39
+ }
40
+ return this.render.createOrAttach(request, afterSeq);
41
+ }
42
+ forget(sessionId) {
43
+ this.legacy?.forget(sessionId);
44
+ this.render.forget(sessionId);
45
+ }
46
+ /**
47
+ * 两侧都只是解绑:legacy daemon 可能还持有升级前的旧 PTY,Render 更是必须
48
+ * 跨 Server 重启继续活着。**这里绝不能 kill 任何东西。**
49
+ */
50
+ disconnect() {
51
+ this.legacy?.disconnect();
52
+ this.render.disconnect();
53
+ }
54
+ /**
55
+ * legacy 查询失败(socket 抖动)时按「不属于它」处理:宁可让 Render 侧查一遍,
56
+ * 也不能在会话恢复路径上抛异常 —— 那会让服务器重启后旧会话全部恢复失败。
57
+ */
58
+ attachFromLegacy(sessionId, afterSeq) {
59
+ if (!this.legacy)
60
+ return null;
61
+ try {
62
+ return this.legacy.attach(sessionId, afterSeq) ?? null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ legacyOwns(sessionId) {
69
+ if (!this.legacy)
70
+ return false;
71
+ try {
72
+ return this.legacy.attach(sessionId) !== null;
73
+ }
74
+ catch {
75
+ // 查询失败按「不属于 legacy」处理:宁可让 Render 建新会话,也不要把请求丢进坏掉的 daemon。
76
+ return false;
77
+ }
78
+ }
79
+ }
80
+ /** 环境变量优先于 config;非法值忽略并告警。 */
81
+ export function resolveRenderEngine(envValue, configEngine) {
82
+ const raw = (envValue ?? "").trim().toLowerCase();
83
+ if (raw) {
84
+ if (raw === "rust" || raw === "legacy" || raw === "auto")
85
+ return raw;
86
+ process.stderr.write(`[wand] WAND_RENDER_ENGINE=${envValue} is invalid (expected rust|legacy|auto); ignoring it.\n`);
87
+ }
88
+ return configEngine === "rust" || configEngine === "legacy" ? configEngine : "auto";
89
+ }
90
+ export async function createUpgradeAwareTerminalHost(configPath, options = {}) {
91
+ if (process.env.WAND_TEST_MODE === "1" || process.env.NODE_TEST_CONTEXT) {
92
+ // 与 legacy 缝隙一致:测试进程不拉起任何常驻 daemon。
93
+ return { host: new InProcessTerminalHost(), renderHost: null, legacyHost: null };
94
+ }
95
+ const engine = resolveRenderEngine(process.env.WAND_RENDER_ENGINE, options.engine);
96
+ // engine=legacy 等价于当前行为:adopt 已有 terminald,不存在才 spawn。
97
+ if (engine === "legacy") {
98
+ const legacy = await createTerminalHost(configPath);
99
+ return { host: legacy, renderHost: null, legacyHost: asTerminalDaemonClient(legacy) };
100
+ }
101
+ // 先 adopt 升级前的 terminald,以它的 inventory 判定旧 PTY 的 owner。
102
+ const legacy = await connectExistingTerminalHost(configPath);
103
+ const configuredBinary = options.binaryPath?.trim();
104
+ const binaryPath = configuredBinary || resolveRenderBinaryPath(configPath);
105
+ if (!binaryPath) {
106
+ if (engine === "rust") {
107
+ throw new Error("render.engine=rust but no wand-render binary was found (checked WAND_RENDER_BIN, render/target/{release,debug}, " +
108
+ "<configDir>/bin, dist/native/<platform>-<arch>, PATH). " +
109
+ "Build it with `npm run build:render-native` (cargo build --release in render/), " +
110
+ "or set render.engine=auto/legacy to fall back.");
111
+ }
112
+ process.stderr.write("[wand] WARNING: render.engine=auto but no wand-render binary was found; PTYs stay on the legacy terminald. " +
113
+ "Build it with `npm run build:render-native`, stage a published build with `npm run build:render-bin`, or set WAND_RENDER_BIN.\n");
114
+ const fallback = legacy ?? await createTerminalHost(configPath);
115
+ return { host: fallback, renderHost: null, legacyHost: asTerminalDaemonClient(fallback) };
116
+ }
117
+ if (configuredBinary && !isExecutableFile(configuredBinary)) {
118
+ // 显式配置指向不可执行文件属于配置错误,静默回退 legacy 会让人以为 Rust 引擎在跑。
119
+ throw new Error(`render.binaryPath points at ${configuredBinary}, which is not an executable file.`);
120
+ }
121
+ let render;
122
+ try {
123
+ render = await createRenderTerminalHost(configPath, {
124
+ binaryPath,
125
+ knownSessionIds: options.knownSessionIds,
126
+ });
127
+ }
128
+ catch (error) {
129
+ if (engine === "rust")
130
+ throw error;
131
+ // auto 模式下把失败讲清楚再回退:这种情况用户最需要知道 Rust 引擎没生效。
132
+ process.stderr.write(`[wand] WARNING: render.engine=auto could not bring up Render (${getErrorMessage(error)}); falling back to the legacy terminald.\n`);
133
+ const fallback = legacy ?? await createTerminalHost(configPath);
134
+ return { host: fallback, renderHost: null, legacyHost: asTerminalDaemonClient(fallback) };
135
+ }
136
+ // MIGRATION-COMPAT: Render v1 only owns PTYs. Keep terminald as the owner
137
+ // of existing structured runs during the v2 migration, even if new runs
138
+ // eventually use Rust. Remove this path only on a later explicit request.
139
+ // Structured CLI runs still need a daemon-backed host to survive a web restart,
140
+ // including on a fresh installation where no
141
+ // pre-upgrade terminald exists. The composite routes new PTYs to Render.
142
+ let structuredHost;
143
+ try {
144
+ const daemon = legacy ?? await createTerminalHost(configPath);
145
+ const client = asTerminalDaemonClient(daemon);
146
+ if (!client)
147
+ throw new Error("A persistent terminal daemon is required for structured CLI runs.");
148
+ structuredHost = client;
149
+ }
150
+ catch (error) {
151
+ render.disconnect();
152
+ throw error;
153
+ }
154
+ const version = await readRenderBinaryVersion(binaryPath);
155
+ process.stderr.write(`[wand] Render engine active (${version ? `wand-render ${version}` : "version unknown"}, ${binaryPath}); ` +
156
+ "terminald available for structured CLI runs and pre-upgrade PTYs.\n");
157
+ return {
158
+ host: new CompositeTerminalHost({ legacy: structuredHost, render }),
159
+ renderHost: render,
160
+ legacyHost: structuredHost,
161
+ };
162
+ }
163
+ /**
164
+ * adopt 已存在的 Render,不存在则 spawn 后等待就绪。
165
+ *
166
+ * 协议(docs/render-protocol.md §6):pid 活着但 socket 不可用 → 等待就绪,
167
+ * 不另起第二个 daemon;协议版本不符 → 明确抛错,**不做降级运行**。
168
+ */
169
+ export async function createRenderTerminalHost(configPath, options = {}) {
170
+ const paths = renderPaths(configPath);
171
+ const endpointExists = process.platform !== "win32" && existsSync(paths.socketPath);
172
+ if (endpointExists) {
173
+ const adopted = await connectExistingRenderClient(configPath, options.knownSessionIds);
174
+ if (adopted)
175
+ return adopted;
176
+ if (await isSocketListening(paths.socketPath)) {
177
+ // 有人监听却拒绝 adopt(协议版本不符 / token 不符):这是错误配置,必须报出来而不是绕开。
178
+ const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, options.knownSessionIds);
179
+ if (starting)
180
+ return starting;
181
+ throw new Error(`A Render daemon is listening on ${paths.socketPath} but rejected adoption. Refusing to spawn a competing Render; ` +
182
+ "check the protocol version/token of the running binary, or stop it first.");
183
+ }
184
+ // 没人在监听,且没有活着的 pid = 上一次 renderd 崩溃留下的孤儿 socket 文件。
185
+ // 清掉它再 spawn,否则会永久卡在「等一个永远不会就绪的 socket」。
186
+ if (readLiveRenderPid(paths.pidPath) === null) {
187
+ try {
188
+ unlinkSync(paths.socketPath);
189
+ }
190
+ catch { /* 已被别处清掉即可 */ }
191
+ }
192
+ }
193
+ // 进程活着但 socket 还没就绪(启动竞态 / 正在 drain):等它就绪,
194
+ // 绝不另起第二个 daemon(协议 §6 single instance)。
195
+ const livePid = readLiveRenderPid(paths.pidPath);
196
+ if (livePid !== null) {
197
+ const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, options.knownSessionIds);
198
+ if (starting)
199
+ return starting;
200
+ throw new Error(`Render process ${livePid} is alive but its socket ${paths.socketPath} is unavailable; refusing to spawn a second daemon.`);
201
+ }
202
+ const binaryPath = options.binaryPath ?? resolveRenderBinaryPath(configPath);
203
+ if (!binaryPath)
204
+ throw new Error("wand-render binary is required to start Render");
205
+ return spawnDetachedRender(configPath, binaryPath, options.knownSessionIds);
206
+ }
207
+ /** detached spawn(stdio 忽略、unref),并等 socket + token 就绪。 */
208
+ export async function spawnDetachedRender(configPath, binaryPath, knownSessionIds) {
209
+ const paths = renderPaths(configPath);
210
+ // `-c <configPath>` 是按 config 隔离的既定约定(docs/render-protocol.md §6):
211
+ // socket / token / pid / meta 全部由它派生。
212
+ const child = spawn(binaryPath, ["-c", configPath], {
213
+ detached: true,
214
+ stdio: "ignore",
215
+ cwd: process.cwd(),
216
+ env: process.env,
217
+ });
218
+ child.on("error", (error) => {
219
+ process.stderr.write(`[wand] Failed to spawn wand-render: ${getErrorMessage(error)}\n`);
220
+ });
221
+ child.unref();
222
+ const client = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, knownSessionIds);
223
+ if (!client) {
224
+ // 自己生的进程自己收:就位超时(选错了二进制、二进制是 stub、或者它卡在启动)
225
+ // 时如果不回收,就会留下一个永久孤儿 daemon —— 它占着 socket 名字、下次启动会被
226
+ // 当成「活着但不接受领养」而直接阻塞启动。收尾是 best-effort,失败不影响报错。
227
+ reapUnreadyRender(child.pid);
228
+ throw new Error(`Spawned ${binaryPath} but its socket/token (${paths.socketPath}) did not become ready within ${RENDER_READY_TIMEOUT_MS}ms.`);
229
+ }
230
+ return client;
231
+ }
232
+ async function waitForRender(configPath, timeoutMs, knownSessionIds) {
233
+ const deadline = Date.now() + timeoutMs;
234
+ while (Date.now() < deadline) {
235
+ const client = await connectExistingRenderClient(configPath, knownSessionIds);
236
+ if (client)
237
+ return client;
238
+ await new Promise((resolve) => setTimeout(resolve, 50));
239
+ }
240
+ return null;
241
+ }
242
+ /**
243
+ * 回收一个「已 spawn 但始终没就绪」的 Render 进程。
244
+ *
245
+ * 不回收会留下孤儿 daemon:它占着按 config 派生的 socket 名字,下次启动会被判成
246
+ * 「进程活着但 socket 不可达」,于是启动直接失败而不是重建 —— 一个失败的启动不该
247
+ * 破坏后续所有启动。先 SIGTERM 给它清理 socket/token 的机会,短暂等待后 SIGKILL。
248
+ */
249
+ function reapUnreadyRender(pid) {
250
+ if (typeof pid !== "number" || pid <= 0)
251
+ return;
252
+ const signal = (name) => {
253
+ try {
254
+ process.kill(pid, name);
255
+ }
256
+ catch { /* 已经退出 */ }
257
+ };
258
+ signal("SIGTERM");
259
+ const timer = setTimeout(() => signal("SIGKILL"), 500);
260
+ timer.unref?.();
261
+ }
262
+ /** 对端是否真的在监听:区分「活着但拒绝 adopt」与「上一轮崩溃留下的孤儿 socket 文件」。 */ function isSocketListening(socketPath) {
263
+ return new Promise((resolve) => {
264
+ const socket = net.createConnection(socketPath);
265
+ let settled = false;
266
+ const finish = (alive) => {
267
+ if (settled)
268
+ return;
269
+ settled = true;
270
+ clearTimeout(timer);
271
+ socket.destroy();
272
+ resolve(alive);
273
+ };
274
+ socket.once("connect", () => finish(true));
275
+ socket.once("error", () => finish(false));
276
+ const timer = setTimeout(() => finish(false), 300);
277
+ timer.unref?.();
278
+ });
279
+ }
280
+ function readLiveRenderPid(pidPath) {
281
+ let pid;
282
+ try {
283
+ pid = Number(readFileSync(pidPath, "utf8").trim());
284
+ }
285
+ catch {
286
+ return null;
287
+ }
288
+ if (!Number.isSafeInteger(pid) || pid <= 0)
289
+ return null;
290
+ try {
291
+ process.kill(pid, 0);
292
+ return pid;
293
+ }
294
+ catch {
295
+ return null;
296
+ }
297
+ }
298
+ /** structuredExecHost 只认 daemon 客户端;测试注入的 in-process host 不算。 */
299
+ function asTerminalDaemonClient(host) {
300
+ return host instanceof TerminalDaemonClient ? host : null;
301
+ }
@@ -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;
@@ -0,0 +1,99 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /**
6
+ * Render 协议 v1 的 TS 镜像。Rust 单一真源在
7
+ * `render/crates/wand-render-protocol/src/lib.rs`,契约文档在
8
+ * `render/docs/render-protocol.md`(本仓库 `docs/render-protocol.md` 只是指针)。
9
+ *
10
+ * 两侧常量与字段名必须逐一对齐;任一侧改动都要同时提升版本号并更新文档。
11
+ */
12
+ export const RENDER_PROTOCOL_VERSION = 1;
13
+ /** 单帧上限:`u32` 大端长度前缀 + UTF-8 JSON。 */
14
+ export const MAX_FRAME_BYTES = 64 * 1024 * 1024;
15
+ /** 与 `PTY_OUTPUT_MAX_SIZE` 对齐(chunk 窗口 / output 有界)。 */
16
+ export const RENDER_PTY_OUTPUT_MAX_CHARS = 200_000;
17
+ /**
18
+ * config 路径的 canonical 形式:`realpathSync` 解析符号链接,失败(文件尚未创建)
19
+ * 才回退纯词法的 `path.resolve`。
20
+ *
21
+ * 两侧必须对同一 config 派生出**同一套**路径,而「同一份 config」在用户那里常常是
22
+ * 经过符号链接的路径(`~/.wand` → 别的卷、`/tmp` → `/private/tmp`、容器挂载点)。
23
+ * 一侧做 realpath、另一侧只做 resolve,就会算出两个 suffix:同一个 config 出现两个
24
+ * Render、两套 PTY 所有权 —— 这正是要靠寻址归一化防住的故障。
25
+ */
26
+ function canonicalConfigPath(configPath) {
27
+ const resolved = path.resolve(configPath);
28
+ try {
29
+ return realpathSync(resolved);
30
+ }
31
+ catch {
32
+ return resolved;
33
+ }
34
+ }
35
+ /**
36
+ * Render 的按 config 隔离寻址。
37
+ *
38
+ * 与 legacy `terminald`(`wand-terminald-<uid>-<suffix>.sock` /
39
+ * `.terminald-<suffix>.token`)**刻意使用不同文件名**:升级期两套并存,
40
+ * 但永不互相领养。
41
+ */
42
+ export function renderPaths(configPath) {
43
+ const resolved = canonicalConfigPath(configPath);
44
+ const suffix = createHash("sha256").update(resolved).digest("hex").slice(0, 12);
45
+ const dir = path.dirname(resolved);
46
+ const uid = (() => {
47
+ try {
48
+ return os.userInfo().uid;
49
+ }
50
+ catch {
51
+ return 0;
52
+ }
53
+ })();
54
+ return {
55
+ // macOS 的 Unix socket 路径上限约 100 字节,所以 socket 放 /tmp 保持短。
56
+ socketPath: path.join("/tmp", `wand-render-${uid}-${suffix}.sock`),
57
+ tokenPath: path.join(dir, `.render-${suffix}.token`),
58
+ pidPath: path.join(dir, `.render-${suffix}.pid`),
59
+ metaPath: path.join(dir, `.render-${suffix}.json`),
60
+ };
61
+ }
62
+ /** 编码一帧:`u32` 大端长度 + UTF-8 JSON。 */
63
+ export function encodeRenderFrame(value) {
64
+ const body = Buffer.from(JSON.stringify(value), "utf8");
65
+ if (body.length > MAX_FRAME_BYTES) {
66
+ throw new Error(`Render frame of ${body.length} bytes exceeds the ${MAX_FRAME_BYTES} byte limit`);
67
+ }
68
+ const header = Buffer.allocUnsafe(4);
69
+ header.writeUInt32BE(body.length, 0);
70
+ return Buffer.concat([header, body]);
71
+ }
72
+ /**
73
+ * 从累积缓冲区解出所有完整帧。
74
+ *
75
+ * 返回剩余(不完整)字节。长度越界时抛错,调用方应关闭连接。
76
+ */
77
+ export function decodeRenderFrames(buffer) {
78
+ const frames = [];
79
+ let offset = 0;
80
+ while (buffer.length - offset >= 4) {
81
+ const length = buffer.readUInt32BE(offset);
82
+ if (length > MAX_FRAME_BYTES) {
83
+ throw new Error(`Render frame length ${length} exceeds the ${MAX_FRAME_BYTES} byte limit`);
84
+ }
85
+ if (buffer.length - offset - 4 < length)
86
+ break;
87
+ const body = buffer.subarray(offset + 4, offset + 4 + length);
88
+ frames.push(JSON.parse(body.toString("utf8")));
89
+ offset += 4 + length;
90
+ }
91
+ return { frames, rest: offset === 0 ? buffer : buffer.subarray(offset) };
92
+ }
93
+ /** 是否为 protocol v1 事件帧(无 `id`,带 `event`)。 */
94
+ export function isRenderEvent(value) {
95
+ if (!value || typeof value !== "object")
96
+ return false;
97
+ const record = value;
98
+ return typeof record.event === "string" && record.id === undefined;
99
+ }
@@ -0,0 +1,80 @@
1
+ import { type StructuredRenderEvent, type StructuredRenderMethod } from "./render-structured-protocol.js";
2
+ import type { StructuredExecHost, StructuredExecProcess, StructuredExitEvent, StructuredRunState, StructuredSpawnRequest, StructuredStreamEvent } from "./structured-exec-host.js";
3
+ type RunEvent = Extract<StructuredRenderEvent, {
4
+ event: "stream" | "exit";
5
+ }>;
6
+ declare class RemoteRun implements StructuredExecProcess {
7
+ readonly runId: string;
8
+ readonly incarnationId: string;
9
+ readonly pid: number;
10
+ private readonly client;
11
+ private readonly streamListeners;
12
+ private readonly exitListeners;
13
+ private readonly beforeListener;
14
+ private pendingExit;
15
+ readonly seq: {
16
+ stdout: number;
17
+ stderr: number;
18
+ };
19
+ readonly queued: RunEvent[];
20
+ syncing: boolean;
21
+ overflow: boolean;
22
+ get exited(): boolean;
23
+ constructor(runId: string, incarnationId: string, pid: number, client: RenderStructuredClient);
24
+ interrupt(signal?: string): void;
25
+ onStream(listener: (event: StructuredStreamEvent) => void): {
26
+ dispose(): void;
27
+ };
28
+ onExit(listener: (event: StructuredExitEvent) => void): {
29
+ dispose(): void;
30
+ };
31
+ acceptStream(event: StructuredStreamEvent): void;
32
+ acceptExit(event: StructuredExitEvent): void;
33
+ fail(): void;
34
+ }
35
+ /** v2-only StructuredExecHost. It never accesses the PTY v1 or terminald sockets. */
36
+ export declare class RenderStructuredClient implements StructuredExecHost {
37
+ private readonly configPath;
38
+ readonly persistent = true;
39
+ private socket;
40
+ private buffer;
41
+ private token;
42
+ private requestId;
43
+ private readonly pending;
44
+ private readonly inventory;
45
+ private readonly observed;
46
+ private readonly handles;
47
+ private readonly orphanEvents;
48
+ private readonly orphanOverflow;
49
+ private connecting;
50
+ private reconnectTimer;
51
+ private disposed;
52
+ private connected;
53
+ private fatalProtocol;
54
+ private stopHeartbeat;
55
+ private reconnectDelayMs;
56
+ constructor(configPath: string);
57
+ connect(): Promise<void>;
58
+ private open;
59
+ request(method: StructuredRenderMethod, params?: Record<string, unknown>): Promise<unknown>;
60
+ private consume;
61
+ private acceptEvent;
62
+ private queue;
63
+ private catchUp;
64
+ private replayStream;
65
+ private collect;
66
+ /** Events can overtake the spawn response. Feed the contiguous prefix before
67
+ * paging so a fast >8 MiB child does not falsely lose its already-received head. */
68
+ private primeBuffered;
69
+ spawnStructured(request: StructuredSpawnRequest): Promise<StructuredExecProcess>;
70
+ attachRun(runId: string): Promise<StructuredRunState | null>;
71
+ adoptRun(runId: string): Promise<StructuredExecProcess | null>;
72
+ listRuns(): Promise<StructuredRunState[]>;
73
+ forgetRun(runId: string): void;
74
+ dropHandle(runId: string, handle: RemoteRun): void;
75
+ disconnect(): void;
76
+ private onDisconnect;
77
+ private scheduleReconnect;
78
+ private rejectPending;
79
+ }
80
+ export {};