@co0ontty/wand 4.72.0 → 4.72.1

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.
@@ -16,10 +16,6 @@
16
16
  * 在 `max-width: 639.98px` 归零,成对出现,不能各自为政——否则会出现
17
17
  * 「JS 认为停靠、CSS 不补 padding」的覆盖态。
18
18
  */
19
- /** 停靠所需的最小宽度,与 CSS `min-width: 640px` 断点一致。 */
20
- export declare const SIDEBAR_PUSH_MIN_WIDTH = 640;
21
- /** 触摸设备停靠所需的最小高度,与原生端 `usesWideListDetail` 一致。 */
22
- export declare const SIDEBAR_PUSH_MIN_HEIGHT = 480;
23
19
  export interface SidebarLayoutViewport {
24
20
  readonly width: number;
25
21
  readonly height: number;
@@ -17,9 +17,9 @@
17
17
  * 「JS 认为停靠、CSS 不补 padding」的覆盖态。
18
18
  */
19
19
  /** 停靠所需的最小宽度,与 CSS `min-width: 640px` 断点一致。 */
20
- export const SIDEBAR_PUSH_MIN_WIDTH = 640;
20
+ const SIDEBAR_PUSH_MIN_WIDTH = 640;
21
21
  /** 触摸设备停靠所需的最小高度,与原生端 `usesWideListDetail` 一致。 */
22
- export const SIDEBAR_PUSH_MIN_HEIGHT = 480;
22
+ const SIDEBAR_PUSH_MIN_HEIGHT = 480;
23
23
  /** true = 抽屉(覆盖主内容);false = 停靠(推开主内容)。 */
24
24
  export function usesSidebarDrawer(viewport) {
25
25
  if (!(viewport.width >= SIDEBAR_PUSH_MIN_WIDTH))
@@ -13,8 +13,6 @@ export interface WsSessionPort {
13
13
  getTerminalState?(id: string): PtyTerminalSnapshot | null;
14
14
  sendPtyInput?(id: string, input: string, shortcutKey?: string, userInput?: boolean): void;
15
15
  resizePty?(id: string, cols: number, rows: number): void;
16
- pausePtyOutput?(id: string): void;
17
- resumePtyOutput?(id: string): void;
18
16
  }
19
17
  export declare class WsBroadcastManager {
20
18
  private wss;
@@ -23,7 +21,6 @@ export declare class WsBroadcastManager {
23
21
  private heartbeatTimer?;
24
22
  private disposed;
25
23
  private port?;
26
- private readonly ptyPauseRefs;
27
24
  private getCardDefaults;
28
25
  private useHttps;
29
26
  private authService?;
@@ -68,9 +65,20 @@ export declare class WsBroadcastManager {
68
65
  */
69
66
  private resumeAndQueueResyncNotices;
70
67
  private discardClient;
71
- private acquirePtyPause;
72
- private releasePtyPause;
73
- private releaseAllPtyPauses;
68
+ /**
69
+ * 客户端 ack 掉一段已渲染字节。积压回落到低水位就从「降级」恢复,并请该客户端
70
+ * 用终端快照重建屏幕(降级期间它的屏幕停在最后一帧,中间帧已经丢掉了)。
71
+ */
72
+ private handlePtyAck;
73
+ /**
74
+ * 客户端积压追赶完成 → 请它用终端快照重建屏幕。
75
+ *
76
+ * 走客户端已支持的 `resync_required` 通道(客户端会回 `resync`,服务端用
77
+ * init 下发 terminalState 快照),不引入新协议。限流见 PTY_RESYNC_MIN_INTERVAL_MS。
78
+ */
79
+ private requestPtyResync;
80
+ /** 把一条 resync 通知塞进该客户端的发送队列(不绕过队列,也不绕过背压)。 */
81
+ private queueResyncNotice;
74
82
  private sendPtyError;
75
83
  private processWsQueue;
76
84
  }
@@ -26,6 +26,12 @@ const HEARTBEAT_INTERVAL_MS = 20_000;
26
26
  const HEARTBEAT_STALE_MS = 45_000;
27
27
  const PTY_UNACKED_HIGH_WATER = 512 * 1024;
28
28
  const PTY_UNACKED_LOW_WATER = 128 * 1024;
29
+ /**
30
+ * 同一个(客户端, 会话)两次「请重建屏幕」的最小间隔。慢性慢客户端追上来后会
31
+ * 立刻再积压,没有这个下限就会变成「重建 → 立刻积压 → 再重建」的循环,每次重建
32
+ * 都要下发一份终端快照。
33
+ */
34
+ const PTY_RESYNC_MIN_INTERVAL_MS = 5_000;
29
35
  const MAX_PTY_INPUT_CHARS = 128 * 1024;
30
36
  function asEventData(data) {
31
37
  return data && typeof data === "object" && !Array.isArray(data)
@@ -60,7 +66,6 @@ export class WsBroadcastManager {
60
66
  heartbeatTimer;
61
67
  disposed = false;
62
68
  port;
63
- ptyPauseRefs = new Map();
64
69
  getCardDefaults;
65
70
  useHttps;
66
71
  authService;
@@ -116,7 +121,6 @@ export class WsBroadcastManager {
116
121
  sendQueue: [],
117
122
  sendInProgress: false,
118
123
  backpressurePaused: false,
119
- lastOutputBySession: new Map(),
120
124
  outputSeqBySession: new Map(),
121
125
  pendingResyncSessions: new Set(),
122
126
  lastSeenAt: Date.now(),
@@ -147,16 +151,13 @@ export class WsBroadcastManager {
147
151
  }
148
152
  // 默认仍是历史的“切换当前会话”;分屏池显式用 mode:add 叠加订阅。
149
153
  if (msg.mode !== "add") {
150
- this.releaseAllPtyPauses(client);
151
154
  client.ptySubscriptions.clear();
152
155
  }
153
- else {
154
- this.releasePtyPause(client, msg.sessionId);
155
- }
156
156
  client.ptySubscriptions.set(msg.sessionId, {
157
157
  supportsAck: msg.capabilities?.ptyAck === true,
158
158
  unackedBytes: 0,
159
- paused: false,
159
+ degraded: false,
160
+ lastResyncNoticeAt: 0,
160
161
  });
161
162
  this.flushOutput(msg.sessionId);
162
163
  const snapshot = this.port?.getSession(msg.sessionId) ?? null;
@@ -172,7 +173,6 @@ export class WsBroadcastManager {
172
173
  }
173
174
  }
174
175
  else if (msg.type === "unsubscribe" && msg.sessionId) {
175
- this.releasePtyPause(client, msg.sessionId);
176
176
  client.ptySubscriptions.delete(msg.sessionId);
177
177
  }
178
178
  else if (msg.type === "resync" && msg.sessionId) {
@@ -204,13 +204,7 @@ export class WsBroadcastManager {
204
204
  }
205
205
  }
206
206
  else if (msg.type === "pty_ack" && msg.sessionId && Number.isFinite(msg.bytes)) {
207
- const subscription = client.ptySubscriptions.get(msg.sessionId);
208
- if (!subscription?.supportsAck)
209
- return;
210
- subscription.unackedBytes = Math.max(0, subscription.unackedBytes - Math.max(0, Math.floor(msg.bytes)));
211
- if (subscription.paused && subscription.unackedBytes <= PTY_UNACKED_LOW_WATER) {
212
- this.releasePtyPause(client, msg.sessionId);
213
- }
207
+ this.handlePtyAck(client, msg.sessionId, msg.bytes);
214
208
  }
215
209
  else if (msg.type === "pong") {
216
210
  // 应用层 pong(对我们下发的 {type:"ping"} 的响应)。lastSeenAt
@@ -436,12 +430,22 @@ export class WsBroadcastManager {
436
430
  }
437
431
  const subscription = client.ptySubscriptions.get(event.sessionId);
438
432
  const usesPtyAckFlowControl = isRawPtyOutput && subscription?.supportsAck === true;
433
+ if (usesPtyAckFlowControl && subscription.degraded) {
434
+ // 已经积压到高水位的客户端:后面这些原始字节对它是**过期的 TUI 帧**(终端
435
+ // 每帧整屏重绘,中间帧没有任何保留价值),停发这些分片;等它 ack 追上来
436
+ // 再由 requestPtyResync 让它按终端快照重建屏幕。
437
+ //
438
+ // 历史实现在这里是「暂停整个会话的 PTY 读取」,结果是:一个客户端
439
+ // (手机切后台 / WebView 被节流 / 标签页休眠)停止 ack,整个会话对所有
440
+ // 客户端冻结,连会话日志和落库也一起停,而且只能靠那个客户端 ack 回落
441
+ // 才恢复;手机不回来就永久冻住。降级只影响积压的这一个客户端。
442
+ continue;
443
+ }
439
444
  if (usesPtyAckFlowControl) {
440
445
  outgoing = { ...outgoing, ptyBytes };
441
446
  subscription.unackedBytes += ptyBytes;
442
- if (subscription.unackedBytes >= PTY_UNACKED_HIGH_WATER) {
443
- this.acquirePtyPause(client, event.sessionId);
444
- }
447
+ if (subscription.unackedBytes >= PTY_UNACKED_HIGH_WATER)
448
+ subscription.degraded = true;
445
449
  }
446
450
  // Backpressure only gates new business messages. The send pump must
447
451
  // keep draining messages that were already accepted; otherwise a queue
@@ -490,7 +494,6 @@ export class WsBroadcastManager {
490
494
  }
491
495
  }
492
496
  discardClient(client, terminate) {
493
- this.releaseAllPtyPauses(client);
494
497
  client.sendQueue.length = 0;
495
498
  client.pendingResyncSessions.clear();
496
499
  client.sendInProgress = false;
@@ -503,34 +506,45 @@ export class WsBroadcastManager {
503
506
  catch { /* ignore */ }
504
507
  }
505
508
  }
506
- acquirePtyPause(client, sessionId) {
509
+ /**
510
+ * 客户端 ack 掉一段已渲染字节。积压回落到低水位就从「降级」恢复,并请该客户端
511
+ * 用终端快照重建屏幕(降级期间它的屏幕停在最后一帧,中间帧已经丢掉了)。
512
+ */
513
+ handlePtyAck(client, sessionId, bytes) {
507
514
  const subscription = client.ptySubscriptions.get(sessionId);
508
- if (!subscription || subscription.paused)
515
+ if (!subscription?.supportsAck)
509
516
  return;
510
- subscription.paused = true;
511
- const next = (this.ptyPauseRefs.get(sessionId) ?? 0) + 1;
512
- this.ptyPauseRefs.set(sessionId, next);
513
- if (next === 1)
514
- this.port?.pausePtyOutput?.(sessionId);
517
+ subscription.unackedBytes = Math.max(0, subscription.unackedBytes - Math.max(0, Math.floor(bytes)));
518
+ if (subscription.degraded && subscription.unackedBytes <= PTY_UNACKED_LOW_WATER) {
519
+ subscription.degraded = false;
520
+ this.requestPtyResync(client, sessionId);
521
+ }
515
522
  }
516
- releasePtyPause(client, sessionId) {
523
+ /**
524
+ * 客户端积压追赶完成 → 请它用终端快照重建屏幕。
525
+ *
526
+ * 走客户端已支持的 `resync_required` 通道(客户端会回 `resync`,服务端用
527
+ * init 下发 terminalState 快照),不引入新协议。限流见 PTY_RESYNC_MIN_INTERVAL_MS。
528
+ */
529
+ requestPtyResync(client, sessionId) {
517
530
  const subscription = client.ptySubscriptions.get(sessionId);
518
- if (!subscription?.paused)
531
+ if (!subscription)
519
532
  return;
520
- subscription.paused = false;
521
- const next = Math.max(0, (this.ptyPauseRefs.get(sessionId) ?? 1) - 1);
522
- if (next === 0) {
523
- this.ptyPauseRefs.delete(sessionId);
524
- this.port?.resumePtyOutput?.(sessionId);
525
- }
526
- else {
527
- this.ptyPauseRefs.set(sessionId, next);
528
- }
533
+ const now = Date.now();
534
+ if (now - subscription.lastResyncNoticeAt < PTY_RESYNC_MIN_INTERVAL_MS)
535
+ return;
536
+ subscription.lastResyncNoticeAt = now;
537
+ this.queueResyncNotice(client, sessionId, "pty_backlog_drop");
529
538
  }
530
- releaseAllPtyPauses(client) {
531
- for (const sessionId of client.ptySubscriptions.keys()) {
532
- this.releasePtyPause(client, sessionId);
539
+ /** 把一条 resync 通知塞进该客户端的发送队列(不绕过队列,也不绕过背压)。 */
540
+ queueResyncNotice(client, sessionId, reason) {
541
+ if (client.ws.readyState !== WebSocket.OPEN)
542
+ return;
543
+ client.sendQueue.push(JSON.stringify({ type: "resync_required", sessionId, reason }));
544
+ if (client.sendQueue.length >= MAX_QUEUE_SIZE) {
545
+ client.backpressurePaused = true;
533
546
  }
547
+ this.processWsQueue(client);
534
548
  }
535
549
  sendPtyError(client, sessionId, error) {
536
550
  if (client.ws.readyState !== WebSocket.OPEN)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "4.72.0",
3
+ "version": "4.72.1",
4
4
  "description": "A web console for Claude Code, Codex, OpenCode, and other local CLI tools.",
5
5
  "type": "module",
6
6
  "bin": {