@co0ontty/wand 1.64.1 → 1.65.0-beta.g14437f5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
- "commit": "83d24814b236f60f1a44a92e5b894985bb34b956",
3
- "builtAt": "2026-06-14T14:27:59.151Z",
4
- "version": "1.64.1",
5
- "channel": "stable"
2
+ "commit": "14437f5d049c70494f90239e85b16479d05dbf1d",
3
+ "builtAt": "2026-06-15T03:26:09.437Z",
4
+ "version": "1.65.0-beta.g14437f5",
5
+ "channel": "beta"
6
6
  }
@@ -3,13 +3,22 @@
3
3
  * Cards that are collapsed by default have their large results replaced
4
4
  * with a summary, and clients fetch full content on-demand via API.
5
5
  */
6
- import type { CardExpandDefaults, ConversationTurn } from "./types.js";
6
+ import type { CardExpandDefaults, ContentBlock, ConversationTurn } from "./types.js";
7
7
  /**
8
8
  * 默认窗口大小:init/resync/快照/REST 默认只下发最近这么多条 turn,更早的由客户端
9
9
  * 滚动到顶时按需分页拉取。移动端 WebSocket 单帧上限(iOS 默认 1 MiB)下,长会话一次
10
10
  * 全量下发会撑爆帧导致反复断连——窗口化是根治手段,64MB 提帧只是兜底。
11
11
  */
12
12
  export declare const MESSAGE_WINDOW_SIZE = 40;
13
+ /**
14
+ * 块级窗口的默认预算:init/REST 默认只下发最近这么多个「内容块」(跨 turn 累计,
15
+ * 必要时切掉最旧那条 turn 的头部),更早的块由客户端滚动到顶时按需分页拉取。
16
+ * turn 级窗口(MESSAGE_WINDOW_SIZE)对「单条 turn 携带上百块」的长任务无能为力——
17
+ * 一条流式 assistant turn 可膨胀到 1MB+,整条下发会撑爆移动端 WS 帧、拖慢打开。
18
+ * 块级窗口是对这种会话的根治手段。仅在客户端显式带 blockBudget 时启用(iOS),
19
+ * Web/Android 走原有 turn 级路径不受影响。
20
+ */
21
+ export declare const MESSAGE_BLOCK_WINDOW = 60;
13
22
  export interface WindowedMessages {
14
23
  /** 已截断 + 窗口化后的 turn 列表(最近 windowSize 条)。 */
15
24
  messages: ConversationTurn[];
@@ -18,6 +27,24 @@ export interface WindowedMessages {
18
27
  /** 完整历史的 turn 总数(客户端据此判断是否还有更早的可加载)。 */
19
28
  messageTotal: number;
20
29
  }
30
+ export interface BlockWindowedMessages extends WindowedMessages {
31
+ /** messages[0] 被切掉的头部块数(0 表示该 turn 完整;>0 表示其更早的块需翻页)。 */
32
+ leadingBlockOffset: number;
33
+ /** turn messageOffset 的完整块数(客户端据此判断该 turn 是否已全部加载)。 */
34
+ leadingBlockTotal: number;
35
+ }
36
+ /**
37
+ * 块级窗口:取完整历史「最近 blockBudget 个内容块」并做 transport 截断。
38
+ * 从最新 turn 往回累计块数,能整条放下就整条放,放不下的那条(最旧的入窗 turn)
39
+ * 只取其尾部若干块,并通过 leadingBlockOffset 告知客户端「这条 turn 还有更早的块」。
40
+ * 客户端先按块翻完这条 turn 的头部,再按 turn 往前翻更早的整条。
41
+ */
42
+ export declare function blockWindowMessagesForTransport(all: ConversationTurn[] | undefined, cardDefaults: CardExpandDefaults, blockBudget?: number): BlockWindowedMessages;
43
+ /**
44
+ * 块级翻页:取某条 turn 的 content[start, end) 这一段(已做 transport 截断)。
45
+ * 客户端滚动到顶、且当前最旧 turn 仍有更早块时调用,end = 客户端当前 leadingBlockOffset。
46
+ */
47
+ export declare function sliceTurnBlocksForTransport(turn: ConversationTurn, start: number, end: number, cardDefaults: CardExpandDefaults): ContentBlock[];
21
48
  /**
22
49
  * 取完整历史的「最近 windowSize 条」并对其做 transport 截断,附带 offset/total 元数据。
23
50
  * 客户端持有的永远是一段连续的「后缀」(最近的若干条),更早的按 offset 往前翻页。
@@ -11,6 +11,89 @@ const SUMMARY_LENGTH = 100;
11
11
  * 全量下发会撑爆帧导致反复断连——窗口化是根治手段,64MB 提帧只是兜底。
12
12
  */
13
13
  export const MESSAGE_WINDOW_SIZE = 40;
14
+ /**
15
+ * 块级窗口的默认预算:init/REST 默认只下发最近这么多个「内容块」(跨 turn 累计,
16
+ * 必要时切掉最旧那条 turn 的头部),更早的块由客户端滚动到顶时按需分页拉取。
17
+ * turn 级窗口(MESSAGE_WINDOW_SIZE)对「单条 turn 携带上百块」的长任务无能为力——
18
+ * 一条流式 assistant turn 可膨胀到 1MB+,整条下发会撑爆移动端 WS 帧、拖慢打开。
19
+ * 块级窗口是对这种会话的根治手段。仅在客户端显式带 blockBudget 时启用(iOS),
20
+ * Web/Android 走原有 turn 级路径不受影响。
21
+ */
22
+ export const MESSAGE_BLOCK_WINDOW = 60;
23
+ /**
24
+ * 块级窗口:取完整历史「最近 blockBudget 个内容块」并做 transport 截断。
25
+ * 从最新 turn 往回累计块数,能整条放下就整条放,放不下的那条(最旧的入窗 turn)
26
+ * 只取其尾部若干块,并通过 leadingBlockOffset 告知客户端「这条 turn 还有更早的块」。
27
+ * 客户端先按块翻完这条 turn 的头部,再按 turn 往前翻更早的整条。
28
+ */
29
+ export function blockWindowMessagesForTransport(all, cardDefaults, blockBudget = MESSAGE_BLOCK_WINDOW) {
30
+ const turns = all ?? [];
31
+ const total = turns.length;
32
+ if (total === 0) {
33
+ return { messages: [], messageOffset: 0, messageTotal: 0, leadingBlockOffset: 0, leadingBlockTotal: 0 };
34
+ }
35
+ const budget = Math.max(1, blockBudget);
36
+ let startTurn = total - 1;
37
+ let leadingBlockOffset = 0;
38
+ let acc = 0;
39
+ for (let i = total - 1; i >= 0; i--) {
40
+ const n = turns[i].content.length;
41
+ if (i === total - 1) {
42
+ // 最新一条 turn 必须入窗:整条放得下就整条,放不下取尾部 budget 块。
43
+ if (n <= budget) {
44
+ acc = n;
45
+ startTurn = i;
46
+ leadingBlockOffset = 0;
47
+ }
48
+ else {
49
+ startTurn = i;
50
+ leadingBlockOffset = n - budget;
51
+ acc = budget;
52
+ break;
53
+ }
54
+ }
55
+ else if (acc + n <= budget) {
56
+ acc += n;
57
+ startTurn = i;
58
+ leadingBlockOffset = 0;
59
+ }
60
+ else {
61
+ const remaining = budget - acc;
62
+ if (remaining > 0) {
63
+ startTurn = i;
64
+ leadingBlockOffset = n - remaining;
65
+ acc += remaining;
66
+ }
67
+ break;
68
+ }
69
+ }
70
+ const windowedTurns = [];
71
+ for (let i = startTurn; i < total; i++) {
72
+ if (i === startTurn && leadingBlockOffset > 0) {
73
+ windowedTurns.push({ ...turns[i], content: turns[i].content.slice(leadingBlockOffset) });
74
+ }
75
+ else {
76
+ windowedTurns.push(turns[i]);
77
+ }
78
+ }
79
+ return {
80
+ messages: truncateMessagesForTransport(windowedTurns, cardDefaults),
81
+ messageOffset: startTurn,
82
+ messageTotal: total,
83
+ leadingBlockOffset,
84
+ leadingBlockTotal: turns[startTurn].content.length,
85
+ };
86
+ }
87
+ /**
88
+ * 块级翻页:取某条 turn 的 content[start, end) 这一段(已做 transport 截断)。
89
+ * 客户端滚动到顶、且当前最旧 turn 仍有更早块时调用,end = 客户端当前 leadingBlockOffset。
90
+ */
91
+ export function sliceTurnBlocksForTransport(turn, start, end, cardDefaults) {
92
+ const blocks = turn.content.slice(start, end);
93
+ if (blocks.length === 0)
94
+ return [];
95
+ return truncateMessagesForTransport([{ ...turn, content: blocks }], cardDefaults)[0].content;
96
+ }
14
97
  /**
15
98
  * 取完整历史的「最近 windowSize 条」并对其做 transport 截断,附带 offset/total 元数据。
16
99
  * 客户端持有的永远是一段连续的「后缀」(最近的若干条),更早的按 offset 往前翻页。
@@ -1,7 +1,7 @@
1
1
  import express from "express";
2
2
  import { SessionInputError } from "./process-manager.js";
3
3
  import { normalizeMode } from "./config.js";
4
- import { truncateMessagesForTransport, windowMessagesForTransport } from "./message-truncator.js";
4
+ import { blockWindowMessagesForTransport, sliceTurnBlocksForTransport, truncateMessagesForTransport, windowMessagesForTransport } from "./message-truncator.js";
5
5
  import { checkSessionWorktreeMergeability, cleanupSessionWorktree, getWorktreeMergeErrorCode, mergeSessionWorktree, WorktreeMergeError } from "./git-worktree.js";
6
6
  import { getGitStatus, QuickCommitError, runQuickCommit, runTagHead, runPush, generateCommitMessageOnly, } from "./git-quick-commit.js";
7
7
  import { getErrorMessage } from "./error-utils.js";
@@ -599,6 +599,22 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
599
599
  ? processes.getPtyTranscript(snapshot.id) ?? snapshot.output
600
600
  : snapshot.output;
601
601
  if (req.query.format === "chat") {
602
+ // 客户端带 blockBudget(iOS)走块级窗口:只回最近 N 个块(必要时切掉最旧 turn 的头部),
603
+ // 根治「单条 turn 上百块/1MB」的长任务打开慢。Web/Android 不带该参数,走原 turn 级窗口。
604
+ const rawBudget = parseInt(String(req.query.blockBudget ?? ""), 10);
605
+ if (Number.isFinite(rawBudget) && rawBudget > 0) {
606
+ const windowed = blockWindowMessagesForTransport(snapshot.messages ?? [], config.cardDefaults ?? {}, rawBudget);
607
+ res.json({
608
+ ...snapshot,
609
+ output: transcriptOutput,
610
+ messages: windowed.messages,
611
+ messageOffset: windowed.messageOffset,
612
+ messageTotal: windowed.messageTotal,
613
+ leadingBlockOffset: windowed.leadingBlockOffset,
614
+ leadingBlockTotal: windowed.leadingBlockTotal,
615
+ });
616
+ return;
617
+ }
602
618
  // 与 WS init 对齐:只回最近一窗 turn + offset/total,更早的走 /messages 翻页。
603
619
  const windowed = windowMessagesForTransport(snapshot.messages ?? [], config.cardDefaults ?? {});
604
620
  res.json({
@@ -623,6 +639,26 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
623
639
  }
624
640
  const all = snapshot.messages ?? [];
625
641
  const total = all.length;
642
+ // 块级翻页(iOS):?turn=<i>&blockOffset=<当前 leading 偏移>&blockLimit=<N>
643
+ // 取该 turn 的 [start, blockOffset) 段(start = max(0, blockOffset - blockLimit))。
644
+ const rawTurn = parseInt(String(req.query.turn ?? ""), 10);
645
+ if (Number.isFinite(rawTurn)) {
646
+ const turnIndex = Math.min(Math.max(rawTurn, 0), Math.max(total - 1, 0));
647
+ const turn = all[turnIndex];
648
+ if (!turn) {
649
+ res.json({ turnIndex, blocks: [], blockOffset: 0, blockTotal: 0 });
650
+ return;
651
+ }
652
+ const blockTotal = turn.content.length;
653
+ const rawBlockLimit = parseInt(String(req.query.blockLimit ?? ""), 10);
654
+ const rawBlockOffset = parseInt(String(req.query.blockOffset ?? ""), 10);
655
+ const blockLimit = Math.min(Math.max(Number.isFinite(rawBlockLimit) ? rawBlockLimit : 40, 1), 200);
656
+ const blockEnd = Math.min(Math.max(Number.isFinite(rawBlockOffset) ? rawBlockOffset : blockTotal, 0), blockTotal);
657
+ const blockStart = Math.max(0, blockEnd - blockLimit);
658
+ const blocks = sliceTurnBlocksForTransport(turn, blockStart, blockEnd, config.cardDefaults ?? {});
659
+ res.json({ turnIndex, blocks, blockOffset: blockStart, blockTotal });
660
+ return;
661
+ }
626
662
  const rawLimit = parseInt(String(req.query.limit ?? ""), 10);
627
663
  const rawOffset = parseInt(String(req.query.offset ?? ""), 10);
628
664
  const limit = Math.min(Math.max(Number.isFinite(rawLimit) ? rawLimit : 40, 1), 200);
@@ -35,6 +35,11 @@ export declare class WsBroadcastManager {
35
35
  * and the first incremental update.
36
36
  */
37
37
  private sendInit;
38
+ /**
39
+ * 按客户端偏好窗口化一段完整 messages:opted-in 的块级窗口(iOS)会附带
40
+ * leadingBlockOffset/leadingBlockTotal;否则 turn 级窗口(字段与改动前一致)。
41
+ */
42
+ private windowForClient;
38
43
  private broadcast;
39
44
  private processWsQueue;
40
45
  }
@@ -5,7 +5,7 @@
5
5
  import { WebSocket } from "ws";
6
6
  import { EventEmitter } from "node:events";
7
7
  import { readSessionCookie, validateSession } from "./auth.js";
8
- import { windowMessagesForTransport } from "./message-truncator.js";
8
+ import { blockWindowMessagesForTransport, windowMessagesForTransport } from "./message-truncator.js";
9
9
  // ── Constants ──
10
10
  const MAX_QUEUE_SIZE = 500;
11
11
  const OUTPUT_DEBOUNCE_MS = 16;
@@ -70,6 +70,11 @@ export class WsBroadcastManager {
70
70
  try {
71
71
  const msg = JSON.parse(data.toString());
72
72
  if (msg.type === "subscribe" && msg.sessionId) {
73
+ // 客户端可在 subscribe 时声明块级窗口预算(iOS);后续 init/resync/广播
74
+ // 全量快照都按它块级窗口化。不带则保持 turn 级(Web/Android)。
75
+ if (typeof msg.blockBudget === "number" && msg.blockBudget > 0) {
76
+ client.blockBudget = msg.blockBudget;
77
+ }
73
78
  const snapshot = getSession(msg.sessionId);
74
79
  if (snapshot) {
75
80
  this.sendInit(client, msg.sessionId, snapshot, false);
@@ -222,10 +227,9 @@ export class WsBroadcastManager {
222
227
  * and the first incremental update.
223
228
  */
224
229
  sendInit(client, sessionId, snapshot, resync) {
225
- // 只下发最近 MESSAGE_WINDOW_SIZE turn,附 offset/total;更早的客户端按需翻页。
226
- const windowed = snapshot.messages
227
- ? windowMessagesForTransport(snapshot.messages, this.getCardDefaults())
228
- : { messages: undefined, messageOffset: 0, messageTotal: 0 };
230
+ // 块级窗口客户端(iOS)只下发最近 blockBudget 个块;其余走 turn 级窗口。
231
+ // 两种都附 offset/total,更早的客户端按需翻页。
232
+ const windowed = this.windowForClient(client, snapshot.messages);
229
233
  const seq = (client.outputSeqBySession.get(sessionId) ?? 0) + 1;
230
234
  client.outputSeqBySession.set(sessionId, seq);
231
235
  client.pendingResyncSessions.delete(sessionId);
@@ -236,40 +240,78 @@ export class WsBroadcastManager {
236
240
  ...(resync ? { resync: true } : {}),
237
241
  data: {
238
242
  ...snapshot,
239
- messages: windowed.messages,
240
- messageOffset: windowed.messageOffset,
241
- messageTotal: windowed.messageTotal,
243
+ ...windowed,
242
244
  output: snapshot.output,
243
245
  },
244
246
  }));
245
247
  }
248
+ /**
249
+ * 按客户端偏好窗口化一段完整 messages:opted-in 的块级窗口(iOS)会附带
250
+ * leadingBlockOffset/leadingBlockTotal;否则 turn 级窗口(字段与改动前一致)。
251
+ */
252
+ windowForClient(client, messages) {
253
+ if (!messages) {
254
+ return { messages: undefined, messageOffset: 0, messageTotal: 0 };
255
+ }
256
+ if (client.blockBudget && client.blockBudget > 0) {
257
+ const w = blockWindowMessagesForTransport(messages, this.getCardDefaults(), client.blockBudget);
258
+ return {
259
+ messages: w.messages,
260
+ messageOffset: w.messageOffset,
261
+ messageTotal: w.messageTotal,
262
+ leadingBlockOffset: w.leadingBlockOffset,
263
+ leadingBlockTotal: w.leadingBlockTotal,
264
+ };
265
+ }
266
+ const w = windowMessagesForTransport(messages, this.getCardDefaults());
267
+ return { messages: w.messages, messageOffset: w.messageOffset, messageTotal: w.messageTotal };
268
+ }
246
269
  broadcast(event) {
247
270
  // 非增量事件若带完整 messages(结构化 output/ended 快照、PTY 非流式 chat 快照),
248
271
  // 在这个统一出口窗口化——避免逐个 emit 点各自处理、也防止超大帧撑爆移动端 WS。
249
272
  // 增量事件只带 lastMessage,不含 messages 数组,不受影响。
273
+ // 块级窗口客户端(iOS)需按各自预算切,所以这里改为「按客户端」窗口化;
274
+ // turn 级(Web/Android)的结果跨客户端一致,缓存一次复用,避免重复计算。
250
275
  const data = event.data;
251
- if (data && !data.incremental && Array.isArray(data.messages)) {
252
- const windowed = windowMessagesForTransport(data.messages, this.getCardDefaults());
253
- event = {
254
- ...event,
255
- data: {
256
- ...data,
257
- messages: windowed.messages,
258
- messageOffset: windowed.messageOffset,
259
- messageTotal: windowed.messageTotal,
260
- },
261
- };
262
- }
276
+ const hasFullMessages = !!(data && !data.incremental && Array.isArray(data.messages));
277
+ const rawMessages = hasFullMessages
278
+ ? data.messages
279
+ : undefined;
280
+ let turnWindowedEvent;
281
+ const eventForClient = (client) => {
282
+ if (!hasFullMessages)
283
+ return event;
284
+ if (client.blockBudget && client.blockBudget > 0) {
285
+ return {
286
+ ...event,
287
+ data: { ...data, ...this.windowForClient(client, rawMessages) },
288
+ };
289
+ }
290
+ if (!turnWindowedEvent) {
291
+ const w = windowMessagesForTransport(rawMessages, this.getCardDefaults());
292
+ turnWindowedEvent = {
293
+ ...event,
294
+ data: {
295
+ ...data,
296
+ messages: w.messages,
297
+ messageOffset: w.messageOffset,
298
+ messageTotal: w.messageTotal,
299
+ },
300
+ };
301
+ }
302
+ return turnWindowedEvent;
303
+ };
263
304
  for (const client of this.clients) {
264
305
  if (client.ws.readyState !== WebSocket.OPEN)
265
306
  continue;
307
+ const clientEvent = eventForClient(client);
266
308
  // Stamp output events with a per-(client, session) sequence number so
267
309
  // the client can detect a gap caused by backpressure drops.
268
- let outgoing = event;
310
+ let outgoing = clientEvent;
269
311
  if (event.type === "output") {
270
312
  const seq = (client.outputSeqBySession.get(event.sessionId) ?? 0) + 1;
271
313
  client.outputSeqBySession.set(event.sessionId, seq);
272
- outgoing = { ...event, seq };
314
+ outgoing = { ...clientEvent, seq };
273
315
  }
274
316
  // Apply backpressure if queue is too large. We mark the session as
275
317
  // needing a resync rather than silently discarding — the client will
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.64.1",
3
+ "version": "1.65.0-beta.g14437f5",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {