@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.
- package/dist/auth.d.ts +19 -5
- package/dist/auth.js +83 -45
- package/dist/build-info.json +3 -3
- package/dist/cert.d.ts +1 -1
- package/dist/cert.js +124 -74
- package/dist/config.js +25 -8
- package/dist/express-async.d.ts +6 -0
- package/dist/express-async.js +28 -0
- package/dist/git-quick-commit.d.ts +2 -0
- package/dist/git-quick-commit.js +215 -76
- package/dist/git-utils.d.ts +4 -0
- package/dist/git-utils.js +60 -11
- package/dist/git-worktree.d.ts +8 -1
- package/dist/git-worktree.js +406 -41
- package/dist/models.d.ts +34 -4
- package/dist/models.js +334 -48
- package/dist/process-manager.d.ts +22 -30
- package/dist/process-manager.js +374 -441
- package/dist/provider-history-scanner.d.ts +54 -0
- package/dist/provider-history-scanner.js +354 -0
- package/dist/request-limits.d.ts +1 -0
- package/dist/request-limits.js +8 -0
- package/dist/resume-policy.d.ts +2 -0
- package/dist/resume-policy.js +5 -0
- package/dist/runtime-config.d.ts +16 -0
- package/dist/runtime-config.js +49 -0
- package/dist/server-file-routes.d.ts +17 -0
- package/dist/server-file-routes.js +653 -0
- package/dist/server-session-routes.d.ts +16 -3
- package/dist/server-session-routes.js +170 -149
- package/dist/server-settings-routes.d.ts +43 -0
- package/dist/server-settings-routes.js +225 -0
- package/dist/server-update-routes.d.ts +61 -0
- package/dist/server-update-routes.js +215 -0
- package/dist/server.d.ts +6 -4
- package/dist/server.js +350 -1313
- package/dist/session-logger.d.ts +32 -2
- package/dist/session-logger.js +145 -15
- package/dist/session-registry.d.ts +27 -0
- package/dist/session-registry.js +153 -0
- package/dist/session-transport.d.ts +31 -0
- package/dist/session-transport.js +82 -0
- package/dist/storage.d.ts +24 -6
- package/dist/storage.js +291 -44
- package/dist/structured-claude-adapter.d.ts +19 -0
- package/dist/structured-claude-adapter.js +117 -0
- package/dist/structured-codex-adapter.d.ts +3 -0
- package/dist/structured-codex-adapter.js +29 -0
- package/dist/structured-opencode-adapter.d.ts +11 -0
- package/dist/structured-opencode-adapter.js +115 -0
- package/dist/structured-provider-common.d.ts +11 -0
- package/dist/structured-provider-common.js +77 -0
- package/dist/structured-session-manager.d.ts +32 -35
- package/dist/structured-session-manager.js +551 -605
- package/dist/types.d.ts +10 -0
- package/dist/update-helper.js +5 -1
- package/dist/web-ui/content/scripts.js +32 -32
- package/dist/web-ui/embedded-assets.d.ts +1 -1
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/ws-broadcast.d.ts +16 -1
- package/dist/ws-broadcast.js +124 -58
- package/package.json +2 -1
package/dist/ws-broadcast.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/ws-broadcast.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
312
|
+
return boundedEvent;
|
|
282
313
|
if (client.blockBudget && client.blockBudget > 0) {
|
|
283
314
|
return {
|
|
284
|
-
...
|
|
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
|
-
...
|
|
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
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
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
|
-
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
+
"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",
|