@co0ontty/wand 4.73.0 → 4.75.1-beta.gbf7dafd
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.
- package/dist/build-info.json +4 -4
- package/dist/claude-sdk-runner.d.ts +2 -3
- package/dist/config.d.ts +1 -1
- package/dist/config.js +28 -0
- package/dist/message-truncator.js +3 -3
- package/dist/models.d.ts +39 -2
- package/dist/models.js +302 -9
- package/dist/native/darwin-arm64/wand-render +0 -0
- package/dist/native/darwin-arm64/wand-render.sha256 +1 -1
- package/dist/native/darwin-arm64/wand-render.version +1 -1
- package/dist/native/darwin-arm64/wand-structured-renderd +0 -0
- package/dist/native/darwin-arm64/wand-structured-renderd.sha256 +1 -0
- package/dist/native/darwin-arm64/wand-structured-renderd.version +1 -0
- package/dist/native/darwin-x64/wand-render +0 -0
- package/dist/native/darwin-x64/wand-render.sha256 +1 -1
- package/dist/native/darwin-x64/wand-render.version +1 -1
- package/dist/native/darwin-x64/wand-structured-renderd +0 -0
- package/dist/native/darwin-x64/wand-structured-renderd.sha256 +1 -0
- package/dist/native/darwin-x64/wand-structured-renderd.version +1 -0
- package/dist/native/linux-arm64/wand-render +0 -0
- package/dist/native/linux-arm64/wand-render.sha256 +1 -1
- package/dist/native/linux-arm64/wand-render.version +1 -1
- package/dist/native/linux-arm64/wand-structured-renderd +0 -0
- package/dist/native/linux-arm64/wand-structured-renderd.sha256 +1 -0
- package/dist/native/linux-arm64/wand-structured-renderd.version +1 -0
- package/dist/native/linux-x64/wand-render +0 -0
- package/dist/native/linux-x64/wand-render.sha256 +1 -1
- package/dist/native/linux-x64/wand-render.version +1 -1
- package/dist/native/linux-x64/wand-structured-renderd +0 -0
- package/dist/native/linux-x64/wand-structured-renderd.sha256 +1 -0
- package/dist/native/linux-x64/wand-structured-renderd.version +1 -0
- package/dist/process-manager.d.ts +2 -1
- package/dist/process-manager.js +15 -3
- package/dist/pty-terminal-state.d.ts +17 -0
- package/dist/pty-terminal-state.js +51 -1
- package/dist/render-daemon-client.d.ts +33 -2
- package/dist/render-daemon-client.js +66 -16
- package/dist/render-host.d.ts +27 -3
- package/dist/render-host.js +156 -17
- package/dist/server-session-routes.js +42 -3
- package/dist/server-task-routes.js +3 -3
- package/dist/server.js +12 -4
- package/dist/session-ai-context.d.ts +1 -1
- package/dist/session-ai-context.js +15 -4
- package/dist/session-logger.d.ts +2 -0
- package/dist/session-logger.js +44 -1
- package/dist/structured-grok-adapter.js +22 -0
- package/dist/structured-provider-common.d.ts +11 -9
- package/dist/structured-provider-common.js +60 -18
- package/dist/task-types.d.ts +2 -1
- package/dist/terminal-daemon-server.js +27 -4
- package/dist/tui/commands.js +183 -10
- package/dist/types.d.ts +11 -9
- package/dist/wand-task-sync.js +2 -1
- package/dist/web-ui/content/scripts.js +78 -72
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/content/tailwind.css +9 -0
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/web-ui/thinking-efforts.d.ts +24 -0
- package/dist/web-ui/thinking-efforts.js +81 -0
- package/dist/ws-broadcast.js +11 -4
- package/package.json +1 -1
package/dist/render-host.js
CHANGED
|
@@ -6,10 +6,76 @@ import { getErrorMessage } from "./error-utils.js";
|
|
|
6
6
|
import { isExecutableFile, readRenderBinaryVersion, resolveRenderBinaryPath } from "./render-binary.js";
|
|
7
7
|
import { connectExistingRenderClient } from "./render-daemon-client.js";
|
|
8
8
|
import { renderPaths } from "./render-protocol.js";
|
|
9
|
+
import { compareSemver } from "./version-utils.js";
|
|
9
10
|
import { connectExistingTerminalHost, createTerminalHost, TerminalDaemonClient } from "./terminal-daemon-client.js";
|
|
10
11
|
import { InProcessTerminalHost, } from "./terminal-host.js";
|
|
11
12
|
/** Render 的 socket/token 就绪等待窗口,与 legacy daemon 保持一致。 */
|
|
12
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
|
+
}
|
|
13
79
|
/**
|
|
14
80
|
* 升级期无损路由:旧会话留在 legacy terminald,新会话一律进 Render。
|
|
15
81
|
* MIGRATION-COMPAT: 旧 PTY/Render v1 路由须保留到用户另行要求删除;
|
|
@@ -167,17 +233,23 @@ export async function createUpgradeAwareTerminalHost(configPath, options = {}) {
|
|
|
167
233
|
* 不另起第二个 daemon;协议版本不符 → 明确抛错,**不做降级运行**。
|
|
168
234
|
*/
|
|
169
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);
|
|
170
239
|
const paths = renderPaths(configPath);
|
|
171
240
|
const endpointExists = process.platform !== "win32" && existsSync(paths.socketPath);
|
|
241
|
+
// 已经 adopt 过的客户端需要能自己把 daemon 拉回来:adopt 完 binaryPath 就丢了,
|
|
242
|
+
// 所以这里把解析结果一次性算好交给它。
|
|
243
|
+
const reviveDaemon = createRenderDaemonReviver(configPath, binaryPath);
|
|
172
244
|
if (endpointExists) {
|
|
173
|
-
const adopted = await connectExistingRenderClient(configPath, options.knownSessionIds);
|
|
245
|
+
const adopted = await connectExistingRenderClient(configPath, options.knownSessionIds, reviveDaemon);
|
|
174
246
|
if (adopted)
|
|
175
|
-
return adopted;
|
|
247
|
+
return adopt(adopted);
|
|
176
248
|
if (await isSocketListening(paths.socketPath)) {
|
|
177
249
|
// 有人监听却拒绝 adopt(协议版本不符 / token 不符):这是错误配置,必须报出来而不是绕开。
|
|
178
|
-
const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, options.knownSessionIds);
|
|
250
|
+
const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, options.knownSessionIds, reviveDaemon);
|
|
179
251
|
if (starting)
|
|
180
|
-
return starting;
|
|
252
|
+
return adopt(starting);
|
|
181
253
|
throw new Error(`A Render daemon is listening on ${paths.socketPath} but rejected adoption. Refusing to spawn a competing Render; ` +
|
|
182
254
|
"check the protocol version/token of the running binary, or stop it first.");
|
|
183
255
|
}
|
|
@@ -194,21 +266,82 @@ export async function createRenderTerminalHost(configPath, options = {}) {
|
|
|
194
266
|
// 绝不另起第二个 daemon(协议 §6 single instance)。
|
|
195
267
|
const livePid = readLiveRenderPid(paths.pidPath);
|
|
196
268
|
if (livePid !== null) {
|
|
197
|
-
const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, options.knownSessionIds);
|
|
269
|
+
const starting = await waitForRender(configPath, RENDER_READY_TIMEOUT_MS, options.knownSessionIds, reviveDaemon);
|
|
198
270
|
if (starting)
|
|
199
|
-
return starting;
|
|
271
|
+
return adopt(starting);
|
|
200
272
|
throw new Error(`Render process ${livePid} is alive but its socket ${paths.socketPath} is unavailable; refusing to spawn a second daemon.`);
|
|
201
273
|
}
|
|
202
|
-
const binaryPath = options.binaryPath ?? resolveRenderBinaryPath(configPath);
|
|
203
274
|
if (!binaryPath)
|
|
204
275
|
throw new Error("wand-render binary is required to start Render");
|
|
205
|
-
return spawnDetachedRender(configPath, binaryPath, options.knownSessionIds);
|
|
276
|
+
return spawnDetachedRender(configPath, binaryPath, options.knownSessionIds, reviveDaemon);
|
|
206
277
|
}
|
|
207
|
-
/**
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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) {
|
|
212
345
|
const child = spawn(binaryPath, ["-c", configPath], {
|
|
213
346
|
detached: true,
|
|
214
347
|
stdio: "ignore",
|
|
@@ -219,20 +352,26 @@ export async function spawnDetachedRender(configPath, binaryPath, knownSessionId
|
|
|
219
352
|
process.stderr.write(`[wand] Failed to spawn wand-render: ${getErrorMessage(error)}\n`);
|
|
220
353
|
});
|
|
221
354
|
child.unref();
|
|
222
|
-
|
|
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);
|
|
223
362
|
if (!client) {
|
|
224
363
|
// 自己生的进程自己收:就位超时(选错了二进制、二进制是 stub、或者它卡在启动)
|
|
225
364
|
// 时如果不回收,就会留下一个永久孤儿 daemon —— 它占着 socket 名字、下次启动会被
|
|
226
365
|
// 当成「活着但不接受领养」而直接阻塞启动。收尾是 best-effort,失败不影响报错。
|
|
227
|
-
reapUnreadyRender(
|
|
366
|
+
reapUnreadyRender(pid ?? undefined);
|
|
228
367
|
throw new Error(`Spawned ${binaryPath} but its socket/token (${paths.socketPath}) did not become ready within ${RENDER_READY_TIMEOUT_MS}ms.`);
|
|
229
368
|
}
|
|
230
369
|
return client;
|
|
231
370
|
}
|
|
232
|
-
async function waitForRender(configPath, timeoutMs, knownSessionIds) {
|
|
371
|
+
async function waitForRender(configPath, timeoutMs, knownSessionIds, reviveDaemon = null) {
|
|
233
372
|
const deadline = Date.now() + timeoutMs;
|
|
234
373
|
while (Date.now() < deadline) {
|
|
235
|
-
const client = await connectExistingRenderClient(configPath, knownSessionIds);
|
|
374
|
+
const client = await connectExistingRenderClient(configPath, knownSessionIds, reviveDaemon);
|
|
236
375
|
if (client)
|
|
237
376
|
return client;
|
|
238
377
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
@@ -577,7 +577,15 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
577
577
|
res.status(404).json({ error: "未找到该结构化会话。" });
|
|
578
578
|
return;
|
|
579
579
|
}
|
|
580
|
-
|
|
580
|
+
const all = snapshot.messages ?? [];
|
|
581
|
+
const offset = parseBoundedInteger(req.query.offset, Math.max(0, all.length - 40), 0, all.length);
|
|
582
|
+
const limit = parseBoundedInteger(req.query.limit, 40, 1, 200);
|
|
583
|
+
res.json({
|
|
584
|
+
id: snapshot.id,
|
|
585
|
+
messages: truncateMessagesForTransport(all.slice(offset, offset + limit), config.cardDefaults ?? {}),
|
|
586
|
+
offset,
|
|
587
|
+
total: all.length,
|
|
588
|
+
});
|
|
581
589
|
});
|
|
582
590
|
app.post("/api/structured-sessions/:id/messages", asyncRoute(async (req, res) => {
|
|
583
591
|
const input = String(req.body?.input ?? "");
|
|
@@ -1061,6 +1069,25 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1061
1069
|
}
|
|
1062
1070
|
res.json({ ok: true, deleted, failed });
|
|
1063
1071
|
});
|
|
1072
|
+
app.get("/api/sessions/:id/pty-history", asyncRoute(async (req, res) => {
|
|
1073
|
+
const snapshot = sessions.get(req.params.id);
|
|
1074
|
+
if (!snapshot || (snapshot.sessionKind ?? "pty") !== "pty") {
|
|
1075
|
+
res.status(404).json({ error: "未找到 PTY 会话。" });
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
const before = Number(req.query.before);
|
|
1079
|
+
const revision = Number(req.query.revision);
|
|
1080
|
+
if (!Number.isSafeInteger(before) || !Number.isSafeInteger(revision) || before < 1 || revision < 0) {
|
|
1081
|
+
res.status(400).json({ error: "无效的终端历史游标。" });
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
const page = await processes.getTerminalHistoryPage(snapshot.id, before, revision);
|
|
1085
|
+
if (!page) {
|
|
1086
|
+
res.status(409).json({ error: "终端历史已变化,请重新同步。" });
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
res.json(page);
|
|
1090
|
+
}));
|
|
1064
1091
|
app.get("/api/sessions/:id", (req, res) => {
|
|
1065
1092
|
const snapshot = sessions.get(req.params.id);
|
|
1066
1093
|
if (!snapshot) {
|
|
@@ -1071,8 +1098,8 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1071
1098
|
? processes.getPtyTranscript(snapshot.id) ?? snapshot.output
|
|
1072
1099
|
: snapshot.output;
|
|
1073
1100
|
if (req.query.format === "chat") {
|
|
1074
|
-
// 客户端带 blockBudget(iOS)走块级窗口:只回最近 N 个块(必要时切掉最旧 turn 的头部),
|
|
1075
|
-
// 根治「单条 turn 上百块/1MB
|
|
1101
|
+
// 客户端带 blockBudget(Web/iOS)走块级窗口:只回最近 N 个块(必要时切掉最旧 turn 的头部),
|
|
1102
|
+
// 根治「单条 turn 上百块/1MB」的长任务打开慢;未启用的客户端保持 turn 级窗口。
|
|
1076
1103
|
const rawBudget = req.query.blockBudget;
|
|
1077
1104
|
if (typeof rawBudget === "string" && /^\d+$/.test(rawBudget) && Number(rawBudget) > 0) {
|
|
1078
1105
|
const blockBudget = parseBoundedInteger(rawBudget, 1, 1, 2_000);
|
|
@@ -1110,6 +1137,18 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
1110
1137
|
}
|
|
1111
1138
|
const all = enrichStructuredMessages(snapshot.messages ?? [], snapshot.id);
|
|
1112
1139
|
const total = all.length;
|
|
1140
|
+
// Web history: fetch at most one block window *before* the currently
|
|
1141
|
+
// displayed turn. A 40-turn page can contain a single multi-MB assistant
|
|
1142
|
+
// turn, defeating the initial block budget entirely.
|
|
1143
|
+
if (typeof req.query.before === "string" && /^\d+$/.test(req.query.before)
|
|
1144
|
+
&& typeof req.query.blockBudget === "string" && /^\d+$/.test(req.query.blockBudget)) {
|
|
1145
|
+
const before = parseBoundedInteger(req.query.before, total, 0, total);
|
|
1146
|
+
const budget = parseBoundedInteger(req.query.blockBudget, 60, 1, 200);
|
|
1147
|
+
const windowed = blockWindowMessagesForTransport(all.slice(0, before), config.cardDefaults ?? {}, budget);
|
|
1148
|
+
res.json({ wandProtocolVersion: WAND_PROTOCOL_VERSION, ...windowed,
|
|
1149
|
+
offset: windowed.messageOffset, total });
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1113
1152
|
// 块级翻页(iOS):?turn=<i>&blockOffset=<当前 leading 偏移>&blockLimit=<N>
|
|
1114
1153
|
// 取该 turn 的 [start, blockOffset) 段(start = max(0, blockOffset - blockLimit))。
|
|
1115
1154
|
const rawTurn = parseInt(String(req.query.turn ?? ""), 10);
|
|
@@ -10,9 +10,9 @@ import { resolveSystemAiContext } from "./session-ai-context.js";
|
|
|
10
10
|
import { DEFAULT_WAND_TASK_AGENT_KIND, DEFAULT_WAND_TASK_AGENT_MODE, DEFAULT_WAND_TASK_PRIORITY, isWandTaskAgentKind, isWandTaskAgentMode, normalizeWandTaskAgentMode, WAND_MILESTONE_NAME_MAX_LENGTH } from "./task-types.js";
|
|
11
11
|
import { archiveBoardTask, ensureWorkspaceTaskForBoardTask, isAutoNameableBoardTask, moveSessionToWorkspaceTask, syncClosedBoardTask, syncUngroupedSessionsToBoard, syncWorkspaceTaskToBoard, taskAutoNameSignature, taskAutoNameSourceText } from "./wand-task-sync.js";
|
|
12
12
|
import { isSessionProvider, providerCliCommand } from "./session-provider.js";
|
|
13
|
+
import { isThinkingEffort } from "./structured-provider-common.js";
|
|
13
14
|
const STATUSES = new Set(["todo", "doing", "done", "archived"]);
|
|
14
15
|
const PRIORITIES = new Set(["none", "low", "medium", "high", "urgent"]);
|
|
15
|
-
const AGENT_EFFORTS = new Set(["off", "standard", "deep", "max"]);
|
|
16
16
|
const TASK_BOARD_LAST_AGENT_KEY = "pref:taskBoardLastAgent";
|
|
17
17
|
function defaultTaskBoardAgent() {
|
|
18
18
|
return { provider: "claude", model: "default", thinkingEffort: "off", mode: DEFAULT_WAND_TASK_AGENT_MODE, kind: DEFAULT_WAND_TASK_AGENT_KIND };
|
|
@@ -130,8 +130,8 @@ export function parseTaskAgent(value, fallbackMode = DEFAULT_WAND_TASK_AGENT_MOD
|
|
|
130
130
|
const model = text(body.model) || "default";
|
|
131
131
|
if (model.length > 128)
|
|
132
132
|
throw new Error("模型名称过长。");
|
|
133
|
-
const thinkingEffort =
|
|
134
|
-
if (!
|
|
133
|
+
const thinkingEffort = text(body.thinkingEffort) || "off";
|
|
134
|
+
if (!isThinkingEffort(thinkingEffort))
|
|
135
135
|
throw new Error("思考深度无效。");
|
|
136
136
|
const rawMode = body.mode === undefined || body.mode === null || body.mode === "" ? fallbackMode : body.mode;
|
|
137
137
|
if (!isWandTaskAgentMode(rawMode))
|
package/dist/server.js
CHANGED
|
@@ -17,7 +17,7 @@ import { ModelCatalogService } from "./models.js";
|
|
|
17
17
|
import { ProcessManager } from "./process-manager.js";
|
|
18
18
|
import { SessionLogger } from "./session-logger.js";
|
|
19
19
|
import { SessionRegistry } from "./session-registry.js";
|
|
20
|
-
import {
|
|
20
|
+
import { resolveSystemAiContext } from "./session-ai-context.js";
|
|
21
21
|
import { StructuredSessionManager } from "./structured-session-manager.js";
|
|
22
22
|
import { recordRecentPath, registerFileRoutes } from "./server-file-routes.js";
|
|
23
23
|
import { registerLocalPreviewRoutes } from "./server-local-preview-routes.js";
|
|
@@ -80,7 +80,8 @@ const AVATAR_CONTENT_TYPES = {
|
|
|
80
80
|
};
|
|
81
81
|
// ── Update check cache ──
|
|
82
82
|
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
83
|
-
|
|
83
|
+
/** CLI 模型目录和思考档位的探测间隔。页面另有更密的快照拉取。 */
|
|
84
|
+
const MODEL_CATALOG_AUTO_REFRESH_INTERVAL_MS = 10 * 60 * 1000;
|
|
84
85
|
/** Cached update result broadcast to new clients on connect. */
|
|
85
86
|
let cachedUpdateInfo = null;
|
|
86
87
|
const packageUpdateCache = new Map();
|
|
@@ -695,8 +696,7 @@ export async function startServer(config, configPath, options = {}) {
|
|
|
695
696
|
selectedModel: null,
|
|
696
697
|
thinkingEffort: config.defaultThinkingEffort,
|
|
697
698
|
};
|
|
698
|
-
|
|
699
|
-
ai = config.systemAi?.enabled ? { ...cli, systemAi: config.systemAi } : cli;
|
|
699
|
+
ai = resolveSystemAiContext(defaultSession, config);
|
|
700
700
|
}
|
|
701
701
|
try {
|
|
702
702
|
const optimized = await optimizePrompt(text, config.language ?? "", cwd, ai);
|
|
@@ -966,6 +966,14 @@ export async function startServer(config, configPath, options = {}) {
|
|
|
966
966
|
}
|
|
967
967
|
// Model discovery runs exclusively in the server. Its first result is
|
|
968
968
|
// persisted; later checks only write when catalog content changes.
|
|
969
|
+
// 变化后通知已连接的页面,下拉不用等用户点「刷新模型列表」。
|
|
970
|
+
modelCatalog.onChanged((result) => {
|
|
971
|
+
wsManager.emitEvent({
|
|
972
|
+
type: "notification",
|
|
973
|
+
sessionId: "__system__",
|
|
974
|
+
data: { kind: "models", revision: result.revision, refreshedAt: result.refreshedAt },
|
|
975
|
+
});
|
|
976
|
+
});
|
|
969
977
|
let modelCatalogRefreshTimer = null;
|
|
970
978
|
if (!testMode) {
|
|
971
979
|
void modelCatalog.refresh().catch(() => { });
|
|
@@ -16,6 +16,6 @@ export declare function resolveSessionProvider(snapshot: Pick<SessionSnapshot, "
|
|
|
16
16
|
/** Build the provider-specific settings used by session-adjacent AI actions. */
|
|
17
17
|
export declare function resolveSessionAiContext(snapshot: Pick<SessionSnapshot, "provider" | "structuredState" | "runner" | "command" | "selectedModel" | "thinkingEffort">, config: Pick<WandConfig, "defaultModel" | "defaultCodexModel" | "defaultOpenCodeModel" | "defaultGrokModel" | "defaultQoderModel" | "defaultPiModel" | "defaultThinkingEffort" | "inheritEnv">): SessionAiContext;
|
|
18
18
|
/** Build the source order for Wand-owned AI features such as titles. */
|
|
19
|
-
export declare function resolveSystemAiContext(snapshot: Parameters<typeof resolveSessionAiContext>[0], config: Parameters<typeof resolveSessionAiContext>[1] & Pick<WandConfig, "systemAi">): SessionAiContext;
|
|
19
|
+
export declare function resolveSystemAiContext(snapshot: Parameters<typeof resolveSessionAiContext>[0], config: Parameters<typeof resolveSessionAiContext>[1] & Pick<WandConfig, "systemAi" | "systemAiCli" | "systemAiModel">): SessionAiContext;
|
|
20
20
|
/** Build the AI context for quick-commit actions from their global preferences. */
|
|
21
21
|
export declare function resolveCommitAiContext(snapshot: Pick<SessionSnapshot, "provider" | "structuredState" | "runner" | "command" | "selectedModel" | "thinkingEffort">, config: Pick<WandConfig, "defaultModel" | "defaultCodexModel" | "defaultOpenCodeModel" | "defaultGrokModel" | "defaultQoderModel" | "defaultPiModel" | "defaultThinkingEffort" | "inheritEnv" | "commitAiSource" | "systemAi">, discoverApis?: typeof discoverCliSystemAiConfigs): SessionAiContext;
|
|
@@ -39,10 +39,21 @@ export function resolveSessionAiContext(snapshot, config) {
|
|
|
39
39
|
/** Build the source order for Wand-owned AI features such as titles. */
|
|
40
40
|
export function resolveSystemAiContext(snapshot, config) {
|
|
41
41
|
const sessionContext = resolveSessionAiContext(snapshot, config);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
? { ...sessionContext, systemAi: directApi }
|
|
45
|
-
|
|
42
|
+
if (config.systemAi?.enabled) {
|
|
43
|
+
const directApi = usableSystemAi(config.systemAi);
|
|
44
|
+
return directApi ? { ...sessionContext, systemAi: directApi } : sessionContext;
|
|
45
|
+
}
|
|
46
|
+
// Older installations without a system AI CLI preference still follow the
|
|
47
|
+
// current session. A chosen CLI must never inherit another provider's model.
|
|
48
|
+
if (!isSessionProvider(config.systemAiCli))
|
|
49
|
+
return sessionContext;
|
|
50
|
+
return {
|
|
51
|
+
...sessionContext,
|
|
52
|
+
provider: config.systemAiCli,
|
|
53
|
+
model: normalizeModel(config.systemAiModel)
|
|
54
|
+
?? normalizeModel(getDefaultModelForProvider(config, config.systemAiCli)),
|
|
55
|
+
thinkingEffort: config.defaultThinkingEffort,
|
|
56
|
+
};
|
|
46
57
|
}
|
|
47
58
|
/** Build the AI context for quick-commit actions from their global preferences. */
|
|
48
59
|
export function resolveCommitAiContext(snapshot, config, discoverApis = discoverCliSystemAiConfigs) {
|
package/dist/session-logger.d.ts
CHANGED
|
@@ -70,6 +70,8 @@ export declare class SessionLogger {
|
|
|
70
70
|
appendPtyOutput(sessionId: string, chunk: string): void;
|
|
71
71
|
/** Read the full PTY transcript including rotated logs, oldest first. */
|
|
72
72
|
readPtyOutput(sessionId: string): string | null;
|
|
73
|
+
/** Only read the tail needed by the transport, including rotated files. */
|
|
74
|
+
readPtyOutputTail(sessionId: string, maxBytes?: number): string | null;
|
|
73
75
|
/** Append a native mode NDJSON event */
|
|
74
76
|
appendStreamEvent(sessionId: string, event: unknown): void;
|
|
75
77
|
/** Append raw stdout chunk from a structured-mode child process. */
|
package/dist/session-logger.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, rmSync, appendFileSync, writeFileSync, readFileSync, existsSync, statSync, renameSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, rmSync, appendFileSync, writeFileSync, readFileSync, existsSync, statSync, renameSync, unlinkSync, openSync, readSync, closeSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
// ── Constants ──
|
|
@@ -148,6 +148,49 @@ export class SessionLogger {
|
|
|
148
148
|
return null;
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
|
+
/** Only read the tail needed by the transport, including rotated files. */
|
|
152
|
+
readPtyOutputTail(sessionId, maxBytes = 256 * 1024) {
|
|
153
|
+
try {
|
|
154
|
+
this.flushSession(sessionId);
|
|
155
|
+
const dir = this.ensureDir(sessionId);
|
|
156
|
+
const parts = [];
|
|
157
|
+
let remaining = Math.max(1, Math.min(maxBytes, 1024 * 1024));
|
|
158
|
+
for (let index = 0; index <= this.ptyLogMaxRotations && remaining > 0; index++) {
|
|
159
|
+
const file = path.join(dir, index ? `pty-output.log.${index}` : "pty-output.log");
|
|
160
|
+
const size = tryStatSize(file);
|
|
161
|
+
if (!size)
|
|
162
|
+
continue;
|
|
163
|
+
const length = Math.min(remaining, size);
|
|
164
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
165
|
+
const fd = openSync(file, "r");
|
|
166
|
+
try {
|
|
167
|
+
let read = 0;
|
|
168
|
+
while (read < length) {
|
|
169
|
+
const count = readSync(fd, buffer, read, length - read, size - length + read);
|
|
170
|
+
if (!count)
|
|
171
|
+
break;
|
|
172
|
+
read += count;
|
|
173
|
+
}
|
|
174
|
+
parts.unshift(buffer.subarray(0, read));
|
|
175
|
+
remaining -= read;
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
closeSync(fd);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!parts.length)
|
|
182
|
+
return null;
|
|
183
|
+
const tail = Buffer.concat(parts);
|
|
184
|
+
// A byte-range may begin inside a multibyte scalar; don't inject U+FFFD.
|
|
185
|
+
let start = 0;
|
|
186
|
+
while (start < tail.length && (tail[start] & 0xc0) === 0x80)
|
|
187
|
+
start++;
|
|
188
|
+
return tail.subarray(start).toString("utf8");
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
151
194
|
/** Append a native mode NDJSON event */
|
|
152
195
|
appendStreamEvent(sessionId, event) {
|
|
153
196
|
if (this.disposed)
|
|
@@ -53,7 +53,29 @@ function grokToolName(event) {
|
|
|
53
53
|
function grokToolInput(event) {
|
|
54
54
|
return asRecord(event.rawInput) ?? asRecord(event.input) ?? {};
|
|
55
55
|
}
|
|
56
|
+
/** Grok bash `rawOutput.output` is a byte array; the readable text is `output_for_prompt`. */
|
|
57
|
+
function grokByteText(value) {
|
|
58
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
59
|
+
return "";
|
|
60
|
+
const bytes = [];
|
|
61
|
+
for (const item of value) {
|
|
62
|
+
if (typeof item !== "number" || !Number.isInteger(item) || item < 0 || item > 255)
|
|
63
|
+
return "";
|
|
64
|
+
bytes.push(item);
|
|
65
|
+
}
|
|
66
|
+
return Buffer.from(bytes).toString("utf8");
|
|
67
|
+
}
|
|
56
68
|
function grokToolOutput(event) {
|
|
69
|
+
const raw = asRecord(event.rawOutput);
|
|
70
|
+
if (raw) {
|
|
71
|
+
if (typeof raw.output_for_prompt === "string" && raw.output_for_prompt)
|
|
72
|
+
return raw.output_for_prompt;
|
|
73
|
+
const fromBytes = grokByteText(raw.output);
|
|
74
|
+
if (fromBytes)
|
|
75
|
+
return fromBytes;
|
|
76
|
+
if (typeof raw.output === "string" && raw.output)
|
|
77
|
+
return raw.output;
|
|
78
|
+
}
|
|
57
79
|
const fromRaw = grokText(event.rawOutput);
|
|
58
80
|
if (fromRaw)
|
|
59
81
|
return fromRaw;
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
import type { SessionProvider, SessionRunner, SessionSnapshot, StructuredSessionState, WandConfig } from "./types.js";
|
|
2
|
-
/** 判断任意值是否为合法的 thinking effort
|
|
2
|
+
/** 判断任意值是否为合法的 thinking effort(旧四档,或 `provider:level`)。 */
|
|
3
3
|
export declare function isThinkingEffort(value: unknown): value is NonNullable<SessionSnapshot["thinkingEffort"]>;
|
|
4
|
+
/** 只取本 provider 的原生档位。别的 CLI 的前缀不能串过去。 */
|
|
5
|
+
export declare function prefixedThinkingEffort(provider: SessionProvider, effort: SessionSnapshot["thinkingEffort"]): string | null;
|
|
4
6
|
export declare function isStructuredRunnerForProvider(provider: SessionProvider, runner: unknown): runner is SessionRunner;
|
|
5
7
|
export declare function defaultStructuredRunner(provider: SessionProvider, configuredClaudeRunner?: WandConfig["structuredRunner"]): SessionRunner;
|
|
6
8
|
export declare function resolveStructuredRunner(provider: SessionProvider, requestedRunner: unknown, configuredClaudeRunner?: WandConfig["structuredRunner"]): SessionRunner;
|
|
7
9
|
export declare function defaultStructuredState(provider: SessionProvider, runner?: SessionRunner): StructuredSessionState;
|
|
8
10
|
export declare function normalizeThinkingEffort(value: unknown): SessionSnapshot["thinkingEffort"];
|
|
9
11
|
export declare function thinkingEffortToSdkBudget(effort: SessionSnapshot["thinkingEffort"]): number;
|
|
10
|
-
export declare function thinkingEffortToClaudeCliEffort(effort: SessionSnapshot["thinkingEffort"]):
|
|
12
|
+
export declare function thinkingEffortToClaudeCliEffort(effort: SessionSnapshot["thinkingEffort"]): string | null;
|
|
11
13
|
export declare function thinkingEffortToClaudeSlashEffort(effort: SessionSnapshot["thinkingEffort"]): string;
|
|
12
14
|
export declare function thinkingEffortToCodexReasoningEffort(effort: SessionSnapshot["thinkingEffort"]): string | null;
|
|
15
|
+
/** OpenCode `--variant`。旧四档仍是 low/high/max;`opencode:<level>` 原样传递。 */
|
|
16
|
+
export declare function thinkingEffortToOpenCodeVariant(effort: SessionSnapshot["thinkingEffort"]): string | null;
|
|
17
|
+
/** Qoder `--reasoning-effort`。旧四档仍是 low/high/max;`qoder:<level>` 原样传递。 */
|
|
18
|
+
export declare function thinkingEffortToQoderEffort(effort: SessionSnapshot["thinkingEffort"]): string | null;
|
|
13
19
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
20
|
+
* Grok `--effort`。当前 CLI 接受 xhigh/high/medium/low,不接受 `max`,
|
|
21
|
+
* 所以旧的「最大」和误存的 `grok:max` 都落到 xhigh。
|
|
16
22
|
*/
|
|
17
|
-
declare function
|
|
18
|
-
export declare const thinkingEffortToOpenCodeVariant: typeof thinkingEffortToLowHighMax;
|
|
19
|
-
export declare const thinkingEffortToGrokEffort: typeof thinkingEffortToLowHighMax;
|
|
20
|
-
export declare const thinkingEffortToQoderEffort: typeof thinkingEffortToLowHighMax;
|
|
23
|
+
export declare function thinkingEffortToGrokEffort(effort: SessionSnapshot["thinkingEffort"]): string | null;
|
|
21
24
|
export declare function thinkingEffortToPiLevel(effort: SessionSnapshot["thinkingEffort"]): string | null;
|
|
22
|
-
export {};
|