@co0ontty/wand 3.1.1 → 4.0.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 (62) hide show
  1. package/dist/auth.d.ts +19 -5
  2. package/dist/auth.js +83 -45
  3. package/dist/build-info.json +3 -3
  4. package/dist/cert.d.ts +1 -1
  5. package/dist/cert.js +124 -74
  6. package/dist/config.js +25 -8
  7. package/dist/express-async.d.ts +6 -0
  8. package/dist/express-async.js +28 -0
  9. package/dist/git-quick-commit.d.ts +2 -0
  10. package/dist/git-quick-commit.js +215 -76
  11. package/dist/git-utils.d.ts +4 -0
  12. package/dist/git-utils.js +60 -11
  13. package/dist/git-worktree.d.ts +8 -1
  14. package/dist/git-worktree.js +406 -41
  15. package/dist/models.d.ts +34 -4
  16. package/dist/models.js +334 -48
  17. package/dist/process-manager.d.ts +22 -30
  18. package/dist/process-manager.js +374 -441
  19. package/dist/provider-history-scanner.d.ts +54 -0
  20. package/dist/provider-history-scanner.js +354 -0
  21. package/dist/request-limits.d.ts +1 -0
  22. package/dist/request-limits.js +8 -0
  23. package/dist/resume-policy.d.ts +2 -0
  24. package/dist/resume-policy.js +5 -0
  25. package/dist/runtime-config.d.ts +16 -0
  26. package/dist/runtime-config.js +49 -0
  27. package/dist/server-file-routes.d.ts +17 -0
  28. package/dist/server-file-routes.js +653 -0
  29. package/dist/server-session-routes.d.ts +16 -3
  30. package/dist/server-session-routes.js +170 -149
  31. package/dist/server-settings-routes.d.ts +43 -0
  32. package/dist/server-settings-routes.js +225 -0
  33. package/dist/server-update-routes.d.ts +61 -0
  34. package/dist/server-update-routes.js +215 -0
  35. package/dist/server.d.ts +6 -4
  36. package/dist/server.js +350 -1313
  37. package/dist/session-logger.d.ts +32 -2
  38. package/dist/session-logger.js +145 -15
  39. package/dist/session-registry.d.ts +27 -0
  40. package/dist/session-registry.js +153 -0
  41. package/dist/session-transport.d.ts +31 -0
  42. package/dist/session-transport.js +82 -0
  43. package/dist/storage.d.ts +24 -6
  44. package/dist/storage.js +291 -44
  45. package/dist/structured-claude-adapter.d.ts +19 -0
  46. package/dist/structured-claude-adapter.js +117 -0
  47. package/dist/structured-codex-adapter.d.ts +3 -0
  48. package/dist/structured-codex-adapter.js +29 -0
  49. package/dist/structured-opencode-adapter.d.ts +11 -0
  50. package/dist/structured-opencode-adapter.js +115 -0
  51. package/dist/structured-provider-common.d.ts +11 -0
  52. package/dist/structured-provider-common.js +77 -0
  53. package/dist/structured-session-manager.d.ts +32 -35
  54. package/dist/structured-session-manager.js +551 -605
  55. package/dist/types.d.ts +10 -0
  56. package/dist/update-helper.js +5 -1
  57. package/dist/web-ui/content/scripts.js +32 -32
  58. package/dist/web-ui/embedded-assets.d.ts +1 -1
  59. package/dist/web-ui/embedded-assets.js +2 -2
  60. package/dist/ws-broadcast.d.ts +16 -1
  61. package/dist/ws-broadcast.js +124 -58
  62. package/package.json +2 -1
@@ -4,15 +4,22 @@
4
4
  */
5
5
  import { WebSocketServer } from "ws";
6
6
  import type { CardExpandDefaults, SessionSnapshot, ProcessEvent } from "./types.js";
7
+ import { type AuthService } from "./auth.js";
7
8
  export type { ProcessEvent } from "./types.js";
8
9
  export declare class WsBroadcastManager {
9
10
  private wss;
10
11
  private clients;
11
12
  private outputDebounceCache;
12
13
  private heartbeatTimer?;
14
+ private disposed;
13
15
  private getCardDefaults;
14
16
  private useHttps;
15
- constructor(wss: WebSocketServer, getCardDefaults?: () => CardExpandDefaults, useHttps?: boolean);
17
+ private authService?;
18
+ constructor(wss: WebSocketServer, getCardDefaults?: () => CardExpandDefaults, useHttps?: boolean, authService?: Pick<AuthService, "validateSession">);
19
+ /** Immediately disconnect all authenticated clients after global revocation. */
20
+ disconnectAll(): void;
21
+ /** Stop timers, discard deferred output, and terminate every client. */
22
+ dispose(): void;
16
23
  /** Set up connection handling. Should be called once during server startup. */
17
24
  setup(getSession: (id: string) => SessionSnapshot | null): void;
18
25
  /**
@@ -40,5 +47,13 @@ export declare class WsBroadcastManager {
40
47
  */
41
48
  private windowForClient;
42
49
  private broadcast;
50
+ /**
51
+ * Resume accepting business messages after the queue crosses the low-water
52
+ * mark, then enqueue the resync notices owed for output dropped while
53
+ * paused. Notices are themselves bounded by the same queue capacity; any
54
+ * remainder stays in the set and is appended by a later drain cycle.
55
+ */
56
+ private resumeAndQueueResyncNotices;
57
+ private discardClient;
43
58
  private processWsQueue;
44
59
  }
@@ -3,10 +3,14 @@
3
3
  * Handles debounced output events, backpressure control, and client subscriptions.
4
4
  */
5
5
  import { WebSocket } from "ws";
6
- import { readSessionCookie, validateSession } from "./auth.js";
6
+ import { readSessionCookie } from "./auth.js";
7
7
  import { blockWindowMessagesForTransport, windowMessagesForTransport } from "./message-truncator.js";
8
+ import { boundSessionEventData, toSessionDetailDTO } from "./session-transport.js";
8
9
  // ── Constants ──
9
10
  const MAX_QUEUE_SIZE = 500;
11
+ const QUEUE_RESUME_SIZE = Math.floor(MAX_QUEUE_SIZE * 0.8);
12
+ const SEND_BATCH_SIZE = 8;
13
+ const MAX_BLOCK_BUDGET = 2_000;
10
14
  const OUTPUT_DEBOUNCE_MS = 16;
11
15
  /**
12
16
  * 服务端心跳节奏。20s 一次,比常见 NAT/代理空闲超时(30~60s)更短,可以保活;
@@ -25,18 +29,48 @@ export class WsBroadcastManager {
25
29
  clients = new Set();
26
30
  outputDebounceCache = new Map();
27
31
  heartbeatTimer;
32
+ disposed = false;
28
33
  getCardDefaults;
29
34
  useHttps;
30
- constructor(wss, getCardDefaults, useHttps = false) {
35
+ authService;
36
+ constructor(wss, getCardDefaults, useHttps = false, authService) {
31
37
  this.wss = wss;
32
38
  this.getCardDefaults = getCardDefaults ?? (() => ({}));
33
39
  this.useHttps = useHttps;
40
+ this.authService = authService;
41
+ }
42
+ /** Immediately disconnect all authenticated clients after global revocation. */
43
+ disconnectAll() {
44
+ for (const client of Array.from(this.clients)) {
45
+ this.discardClient(client, true);
46
+ }
47
+ }
48
+ /** Stop timers, discard deferred output, and terminate every client. */
49
+ dispose() {
50
+ if (this.disposed)
51
+ return;
52
+ this.disposed = true;
53
+ if (this.heartbeatTimer) {
54
+ clearInterval(this.heartbeatTimer);
55
+ this.heartbeatTimer = undefined;
56
+ }
57
+ for (const { timer } of this.outputDebounceCache.values()) {
58
+ clearTimeout(timer);
59
+ }
60
+ this.outputDebounceCache.clear();
61
+ this.disconnectAll();
34
62
  }
35
63
  /** Set up connection handling. Should be called once during server startup. */
36
64
  setup(getSession) {
65
+ if (this.disposed)
66
+ return;
37
67
  this.wss.on("connection", (ws, req) => {
68
+ if (this.disposed) {
69
+ ws.close(1012, "Server shutting down");
70
+ return;
71
+ }
38
72
  const sessionToken = readSessionCookie(req, this.useHttps);
39
- if (!sessionToken || !validateSession(sessionToken)) {
73
+ if (!sessionToken || !this.authService?.validateSession(sessionToken)) {
40
74
  ws.close(1008, "Unauthorized");
41
75
  return;
42
76
  }
@@ -70,8 +104,8 @@ export class WsBroadcastManager {
70
104
  if (msg.type === "subscribe" && msg.sessionId) {
71
105
  // 客户端可在 subscribe 时声明块级窗口预算(iOS);后续 init/resync/广播
72
106
  // 全量快照都按它块级窗口化。不带则保持 turn 级(Web/Android)。
73
- if (typeof msg.blockBudget === "number" && msg.blockBudget > 0) {
74
- client.blockBudget = msg.blockBudget;
107
+ if (Number.isSafeInteger(msg.blockBudget) && msg.blockBudget > 0) {
108
+ client.blockBudget = Math.min(msg.blockBudget, MAX_BLOCK_BUDGET);
75
109
  }
76
110
  const snapshot = getSession(msg.sessionId);
77
111
  if (snapshot) {
@@ -110,12 +144,7 @@ export class WsBroadcastManager {
110
144
  this.heartbeatTimer.unref?.();
111
145
  // 在 wss 关闭时停心跳。restart 路由会先 close 所有 client、再 server.close(),
112
146
  // wss 会跟着关,这里清掉 interval 防止泄漏。
113
- this.wss.on("close", () => {
114
- if (this.heartbeatTimer) {
115
- clearInterval(this.heartbeatTimer);
116
- this.heartbeatTimer = undefined;
117
- }
118
- });
147
+ this.wss.on("close", () => this.dispose());
119
148
  }
120
149
  /**
121
150
  * 心跳 tick:对每个 client 执行 stale 判定 + 主动 ping。
@@ -154,6 +183,8 @@ export class WsBroadcastManager {
154
183
  }
155
184
  /** Emit a process event to all subscribed WebSocket clients. */
156
185
  emitEvent(event) {
186
+ if (this.disposed)
187
+ return;
157
188
  // Debounce output events to reduce flicker during rapid streaming
158
189
  if (event.type === "output") {
159
190
  const existing = this.outputDebounceCache.get(event.sessionId);
@@ -236,11 +267,7 @@ export class WsBroadcastManager {
236
267
  sessionId,
237
268
  seq,
238
269
  ...(resync ? { resync: true } : {}),
239
- data: {
240
- ...snapshot,
241
- ...windowed,
242
- output: snapshot.output,
243
- },
270
+ data: toSessionDetailDTO(snapshot, { output: snapshot.output, ...windowed }),
244
271
  }));
245
272
  }
246
273
  /**
@@ -265,12 +292,16 @@ export class WsBroadcastManager {
265
292
  return { messages: w.messages, messageOffset: w.messageOffset, messageTotal: w.messageTotal };
266
293
  }
267
294
  broadcast(event) {
295
+ if (this.disposed)
296
+ return;
268
297
  // 非增量事件若带完整 messages(结构化 output/ended 快照、PTY 非流式 chat 快照),
269
298
  // 在这个统一出口窗口化——避免逐个 emit 点各自处理、也防止超大帧撑爆移动端 WS。
270
299
  // 增量事件只带 lastMessage,不含 messages 数组,不受影响。
271
300
  // 块级窗口客户端(iOS)需按各自预算切,所以这里改为「按客户端」窗口化;
272
301
  // turn 级(Web/Android)的结果跨客户端一致,缓存一次复用,避免重复计算。
273
- const data = event.data;
302
+ const boundedData = boundSessionEventData(event.data);
303
+ const boundedEvent = boundedData === event.data ? event : { ...event, data: boundedData };
304
+ const data = boundedData;
274
305
  const hasFullMessages = !!(data && !data.incremental && Array.isArray(data.messages));
275
306
  const rawMessages = hasFullMessages
276
307
  ? data.messages
@@ -278,17 +309,17 @@ export class WsBroadcastManager {
278
309
  let turnWindowedEvent;
279
310
  const eventForClient = (client) => {
280
311
  if (!hasFullMessages)
281
- return event;
312
+ return boundedEvent;
282
313
  if (client.blockBudget && client.blockBudget > 0) {
283
314
  return {
284
- ...event,
315
+ ...boundedEvent,
285
316
  data: { ...data, ...this.windowForClient(client, rawMessages) },
286
317
  };
287
318
  }
288
319
  if (!turnWindowedEvent) {
289
320
  const w = windowMessagesForTransport(rawMessages, this.getCardDefaults());
290
321
  turnWindowedEvent = {
291
- ...event,
322
+ ...boundedEvent,
292
323
  data: {
293
324
  ...data,
294
325
  messages: w.messages,
@@ -311,66 +342,101 @@ export class WsBroadcastManager {
311
342
  client.outputSeqBySession.set(event.sessionId, seq);
312
343
  outgoing = { ...clientEvent, seq };
313
344
  }
314
- // Apply backpressure if queue is too large. We mark the session as
315
- // needing a resync rather than silently discarding the client will
316
- // request a fresh snapshot once it sees the resync hint.
317
- if (client.sendQueue.length >= MAX_QUEUE_SIZE) {
345
+ // Backpressure only gates new business messages. The send pump must
346
+ // keep draining messages that were already accepted; otherwise a queue
347
+ // at the high-water mark can never reach the low-water mark again.
348
+ if (client.backpressurePaused || client.sendQueue.length >= MAX_QUEUE_SIZE) {
318
349
  client.backpressurePaused = true;
319
350
  if (event.type === "output")
320
351
  client.pendingResyncSessions.add(event.sessionId);
352
+ this.processWsQueue(client);
321
353
  continue;
322
354
  }
323
- if (client.backpressurePaused) {
324
- if (event.type === "output")
325
- client.pendingResyncSessions.add(event.sessionId);
326
- continue;
327
- }
328
- // If we owed this session a resync notice, prepend it now that the
329
- // queue has drained enough to actually deliver something.
330
- if (client.pendingResyncSessions.has(event.sessionId)) {
331
- client.pendingResyncSessions.delete(event.sessionId);
332
- const notice = JSON.stringify({
333
- type: "resync_required",
334
- sessionId: event.sessionId,
335
- reason: "backpressure_drop",
336
- });
337
- client.sendQueue.push(notice);
338
- }
339
355
  client.sendQueue.push(JSON.stringify(outgoing));
356
+ if (client.sendQueue.length >= MAX_QUEUE_SIZE) {
357
+ client.backpressurePaused = true;
358
+ }
340
359
  this.processWsQueue(client);
341
360
  }
342
361
  }
343
- processWsQueue(client) {
344
- if (client.sendInProgress || client.sendQueue.length === 0) {
362
+ /**
363
+ * Resume accepting business messages after the queue crosses the low-water
364
+ * mark, then enqueue the resync notices owed for output dropped while
365
+ * paused. Notices are themselves bounded by the same queue capacity; any
366
+ * remainder stays in the set and is appended by a later drain cycle.
367
+ */
368
+ resumeAndQueueResyncNotices(client) {
369
+ if (client.backpressurePaused && client.sendQueue.length >= QUEUE_RESUME_SIZE) {
345
370
  return;
346
371
  }
347
- if (client.backpressurePaused) {
348
- if (client.sendQueue.length < MAX_QUEUE_SIZE * 0.8) {
349
- client.backpressurePaused = false;
372
+ client.backpressurePaused = false;
373
+ for (const sessionId of client.pendingResyncSessions) {
374
+ if (client.sendQueue.length >= MAX_QUEUE_SIZE) {
375
+ client.backpressurePaused = true;
376
+ break;
350
377
  }
351
- else {
352
- return;
378
+ client.pendingResyncSessions.delete(sessionId);
379
+ client.sendQueue.push(JSON.stringify({
380
+ type: "resync_required",
381
+ sessionId,
382
+ reason: "backpressure_drop",
383
+ }));
384
+ }
385
+ }
386
+ discardClient(client, terminate) {
387
+ client.sendQueue.length = 0;
388
+ client.pendingResyncSessions.clear();
389
+ client.sendInProgress = false;
390
+ client.backpressurePaused = false;
391
+ this.clients.delete(client);
392
+ if (terminate) {
393
+ try {
394
+ client.ws.terminate();
353
395
  }
396
+ catch { /* ignore */ }
397
+ }
398
+ }
399
+ processWsQueue(client) {
400
+ if (this.disposed)
401
+ return;
402
+ if (client.sendInProgress || client.sendQueue.length === 0) {
403
+ return;
354
404
  }
355
405
  // Check socket state before dequeuing to avoid dropping messages
356
406
  if (client.ws.readyState !== WebSocket.OPEN) {
357
407
  // Socket closed — discard remaining queue and remove client
358
- client.sendQueue.length = 0;
359
- this.clients.delete(client);
408
+ this.discardClient(client, false);
360
409
  return;
361
410
  }
362
411
  client.sendInProgress = true;
363
- const batch = client.sendQueue.splice(0, Math.min(8, client.sendQueue.length));
412
+ const batch = client.sendQueue.splice(0, Math.min(SEND_BATCH_SIZE, client.sendQueue.length));
364
413
  let pending = batch.length;
414
+ let sendError;
415
+ const settleSend = (err) => {
416
+ if (err && !sendError)
417
+ sendError = err;
418
+ pending -= 1;
419
+ if (pending > 0)
420
+ return;
421
+ client.sendInProgress = false;
422
+ if (sendError) {
423
+ // A callback error means the socket can no longer be trusted. Drop
424
+ // the bounded application queue and force a reconnect/resubscribe.
425
+ this.discardClient(client, true);
426
+ return;
427
+ }
428
+ this.resumeAndQueueResyncNotices(client);
429
+ if (client.sendQueue.length > 0) {
430
+ setImmediate(() => this.processWsQueue(client));
431
+ }
432
+ };
365
433
  for (const msg of batch) {
366
- client.ws.send(msg, (err) => {
367
- if (--pending === 0) {
368
- client.sendInProgress = false;
369
- if (!err && client.sendQueue.length > 0) {
370
- setImmediate(() => this.processWsQueue(client));
371
- }
372
- }
373
- });
434
+ try {
435
+ client.ws.send(msg, settleSend);
436
+ }
437
+ catch (error) {
438
+ settleSend(error instanceof Error ? error : new Error(String(error)));
439
+ }
374
440
  }
375
441
  }
376
442
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "3.1.1",
3
+ "version": "4.0.0",
4
4
  "description": "A web console for Claude Code, Codex, OpenCode, and other local CLI tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@anthropic-ai/claude-agent-sdk": "^0.2.138",
40
+ "@anthropic-ai/sdk": "^0.81.0",
40
41
  "compression": "^1.8.1",
41
42
  "express": "^4.21.2",
42
43
  "multer": "^2.1.1",