@evandu/super-pi 0.1.138 → 0.1.140
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/agents/super-pi-knowledge/index.ts +11 -13
- package/agents/super-pi-memory/index.ts +11 -5
- package/agents/super-pi-task/core.ts +418 -1
- package/agents/super-pi-task/index.ts +115 -0
- package/bin/super-pi.js +7 -11
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +7 -6
- package/dist/client/assets/{CodeMirrorEditor-CVImD0nh.js → CodeMirrorEditor-DvZeOLny.js} +2 -2
- package/dist/client/assets/{dist-CiEW7LQW.js → dist-3BuyVG3H.js} +1 -1
- package/dist/client/assets/{dist-faxwvvOU.js → dist-BCUfH4nP.js} +1 -1
- package/dist/client/assets/{dist-yONALz0V.js → dist-BIIMtU9W.js} +1 -1
- package/dist/client/assets/{dist-CKI1xVeE.js → dist-C-QmRwbd.js} +1 -1
- package/dist/client/assets/{dist-4uPxkRdw.js → dist-CTkzLBdb.js} +1 -1
- package/dist/client/assets/{dist-CUACXe1Y.js → dist-C_VuSpx8.js} +1 -1
- package/dist/client/assets/{dist-BwQM8_Mk.js → dist-CaUsvSz1.js} +1 -1
- package/dist/client/assets/{dist-Clf26yld.js → dist-Cb1gxS57.js} +1 -1
- package/dist/client/assets/{dist-YB4GJD3u.js → dist-CqDSeoUk.js} +1 -1
- package/dist/client/assets/{dist-DozjvPAZ.js → dist-D7yfnKNC.js} +1 -1
- package/dist/client/assets/{dist-Cocw5XJ_.js → dist-DA8yW4lK.js} +1 -1
- package/dist/client/assets/{dist-amySbADh.js → dist-DAcS4Kj8.js} +1 -1
- package/dist/client/assets/{dist-B75OT6A9.js → dist-DN0M4DAX.js} +1 -1
- package/dist/client/assets/{dist-At94VK1P.js → dist-DRgWZqFH.js} +1 -1
- package/dist/client/assets/{dist-BIgRwYsI.js → dist-DdfW8Gjv.js} +1 -1
- package/dist/client/assets/{dist-DhEa5hHR.js → dist-DlQ8PMvv.js} +1 -1
- package/dist/client/assets/{dist-quQ6u6Rw.js → dist-Dvca0LeR.js} +1 -1
- package/dist/client/assets/{dist-BqblSnWL.js → dist-HJdP1yxN.js} +1 -1
- package/dist/client/assets/{dist-pnGHl3nq.js → dist-gBAr9yXF.js} +1 -1
- package/dist/client/assets/{dist-1H_gIENV.js → dist-gyzurUDR.js} +1 -1
- package/dist/client/assets/{dist-DSCjkO-y.js → dist-qG53hT1b.js} +1 -1
- package/dist/client/assets/{dist-CNP-Q5Gs.js → dist-r4Kq1ABA.js} +1 -1
- package/dist/client/assets/{index-CNoiRdJC.js → index-CNbh1fL2.js} +14 -14
- package/dist/client/assets/index-DCSFD4VV.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/conversation-service.d.ts +13 -4
- package/dist/server/application/conversation-service.js +112 -61
- package/dist/server/application/run-service.d.ts +13 -0
- package/dist/server/application/run-service.js +255 -69
- package/dist/server/application/scheduled-task-service.js +8 -1
- package/dist/server/application/session-orchestrator-service.d.ts +113 -0
- package/dist/server/application/session-orchestrator-service.js +234 -0
- package/dist/server/application/workspace-service.js +16 -1
- package/dist/server/feishu-cli.js +15 -4
- package/dist/server/file-index.d.ts +30 -0
- package/dist/server/file-index.js +49 -4
- package/dist/server/files.d.ts +21 -0
- package/dist/server/files.js +38 -0
- package/dist/server/http-server.js +1580 -1402
- package/dist/server/index.js +23 -6
- package/dist/server/logging.js +56 -3
- package/dist/server/pi-session-pool.d.ts +2 -0
- package/dist/server/pi-session-pool.js +1 -0
- package/dist/server/pi-session.d.ts +118 -0
- package/dist/server/pi-session.js +132 -10
- package/dist/server/runtime.d.ts +48 -0
- package/dist/server/runtime.js +189 -86
- package/dist/server/server-context.d.ts +2 -0
- package/dist/server/session-ledger.d.ts +60 -0
- package/dist/server/session-ledger.js +76 -0
- package/dist/server/store.d.ts +44 -1
- package/dist/server/store.js +114 -12
- package/dist/server/subagent-runs.d.ts +31 -1
- package/dist/server/subagent-runs.js +48 -20
- package/dist/server/subagents.js +7 -0
- package/dist/server/task-tool.d.ts +18 -0
- package/dist/server/task-tool.js +85 -0
- package/dist/server/tool-runner.js +13 -4
- package/dist/server/types.d.ts +50 -0
- package/dist/server/ws-server.d.ts +86 -6
- package/dist/server/ws-server.js +291 -51
- package/package.json +16 -2
- package/scripts/diagnose.mjs +161 -58
- package/dist/client/assets/index-FxTrUapG.css +0 -1
package/dist/server/ws-server.js
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
import { WebSocket, WebSocketServer } from "ws";
|
|
2
2
|
import { isAuthorized } from "./http-server.js";
|
|
3
3
|
import { describeThrown, describeThrownWithStack } from "./failure-text.js";
|
|
4
|
+
/**
|
|
5
|
+
* A WebSocket is a live transport, not an unbounded queue. `ws.send()` only
|
|
6
|
+
* copies bytes into the socket's userland buffer; a browser that is frozen or
|
|
7
|
+
* a network peer that has disappeared can therefore make the buffer grow
|
|
8
|
+
* without throwing. Once this threshold is crossed we drop that connection
|
|
9
|
+
* and let the normal close path clean up its resources.
|
|
10
|
+
*
|
|
11
|
+
* AND YET DROPPING IT IS THE WRONG ANSWER FOR A BUSY SERVER. The threshold was
|
|
12
|
+
* written for a peer that has gone away - a frozen tab, a pulled cable. A server
|
|
13
|
+
* that is merely DELIVERING TOO MUCH hits the same number: a sub-agent that
|
|
14
|
+
* streams text emits one `subagent.event` per delta, each of which asks for a
|
|
15
|
+
* full state broadcast, and a browser that cannot parse frames as fast as they
|
|
16
|
+
* arrive lets the buffer grow past the threshold while it is perfectly alive.
|
|
17
|
+
* The browser then reports code 1006 and reconnects, only to be dropped again -
|
|
18
|
+
* "连接中 / 断连 / 连接中" forever - and the user's work is interrupted rather
|
|
19
|
+
* than slowed.
|
|
20
|
+
*
|
|
21
|
+
* So a frame that does not fit is DROPPED, not the connection: the next frame
|
|
22
|
+
* carries the same state, and a browser that is behind will catch up on the next
|
|
23
|
+
* one instead of being disconnected and losing its place. The connection is
|
|
24
|
+
* only terminated when the socket itself is gone (readyState is not OPEN).
|
|
25
|
+
*/
|
|
26
|
+
export const MAX_WS_BUFFERED_BYTES = 8 * 1024 * 1024;
|
|
27
|
+
/** Maximum size of one inbound WebSocket message. Attachments are uploaded
|
|
28
|
+
* over HTTP and commands contain references, so multi-megabyte WS frames are
|
|
29
|
+
* almost certainly malformed or abusive. */
|
|
30
|
+
export const MAX_WS_PAYLOAD_BYTES = 4 * 1024 * 1024;
|
|
31
|
+
/**
|
|
32
|
+
* Send one frame without allowing a race with close/error to escape as an
|
|
33
|
+
* uncaught exception. The `ws` package can throw synchronously after the
|
|
34
|
+
* readyState check (for example when a close happens between the two calls),
|
|
35
|
+
* and an unhandled `error` event can otherwise terminate the Node process.
|
|
36
|
+
*/
|
|
37
|
+
export function safeSend(socket, payload) {
|
|
38
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
39
|
+
return false;
|
|
40
|
+
const bufferedAmount = Number(socket.bufferedAmount ?? 0);
|
|
41
|
+
if (Number.isFinite(bufferedAmount) && bufferedAmount > MAX_WS_BUFFERED_BYTES) {
|
|
42
|
+
// DROP THE FRAME, KEEP THE CONNECTION. A peer that is merely behind is not
|
|
43
|
+
// a peer that is gone: the next broadcast carries the same state, so the
|
|
44
|
+
// browser catches up instead of being disconnected and losing its place.
|
|
45
|
+
// Terminating here is what produced "连接中 / 断连 / 连接中" forever on a
|
|
46
|
+
// server that was delivering sub-agent output faster than the browser could
|
|
47
|
+
// parse it - and the browser's own code 1006 was only the symptom.
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
socket.send(payload);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
console.warn(`Super Pi: WebSocket send failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
+
try {
|
|
57
|
+
socket.terminate();
|
|
58
|
+
}
|
|
59
|
+
catch { /* close races are already being cleaned up */ }
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
4
63
|
/**
|
|
5
64
|
* Concrete implementation of EventBroadcaster delivering server events
|
|
6
65
|
* across active WebSocket connections.
|
|
@@ -8,16 +67,31 @@ import { describeThrown, describeThrownWithStack } from "./failure-text.js";
|
|
|
8
67
|
export class WsBroadcaster {
|
|
9
68
|
getState;
|
|
10
69
|
sockets = new Set();
|
|
70
|
+
// Undefined preserves the original full-state protocol. An object, including {}, opts into
|
|
71
|
+
// conversation-focused snapshots from the very first frame of the connection.
|
|
72
|
+
scopes = new Map();
|
|
73
|
+
fileIndexScopes = new Map();
|
|
11
74
|
coalescedSockets = new Set();
|
|
12
75
|
pendingStreams = new Map();
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
76
|
+
/**
|
|
77
|
+
* DIAGNOSTIC ONLY - sends every requested state frame immediately instead of merging them.
|
|
78
|
+
*
|
|
79
|
+
* The legacy flood switch (`SUPER_PI_LEGACY_SUBAGENT_FLOOD=1`) restores the pre-fix WIRING: one
|
|
80
|
+
* full state broadcast per sub-agent event. But the 150ms merge window would still fold those
|
|
81
|
+
* broadcasts into one frame, so the "before" measurement would not actually be "before" - it
|
|
82
|
+
* would show a flood that the fixed server also survives, and the A/B comparison would prove
|
|
83
|
+
* nothing. This flag removes the window as well, so the probe can reproduce the real pre-fix
|
|
84
|
+
* wire shape: one frame per request, sent at once.
|
|
85
|
+
*
|
|
86
|
+
* Never set outside a probe or a regression test that is asserting the old behaviour.
|
|
87
|
+
*/
|
|
88
|
+
sendStateImmediately = process.env.SUPER_PI_LEGACY_SUBAGENT_FLOOD === "1";
|
|
16
89
|
constructor(getState) {
|
|
17
90
|
this.getState = getState;
|
|
18
91
|
}
|
|
19
|
-
register(socket) {
|
|
92
|
+
register(socket, scope) {
|
|
20
93
|
this.sockets.add(socket);
|
|
94
|
+
this.scopes.set(socket, scope);
|
|
21
95
|
}
|
|
22
96
|
unregister(socket) {
|
|
23
97
|
this.coalescedSockets.delete(socket);
|
|
@@ -25,8 +99,49 @@ export class WsBroadcaster {
|
|
|
25
99
|
if (pending)
|
|
26
100
|
clearTimeout(pending.timer);
|
|
27
101
|
this.pendingStreams.delete(socket);
|
|
102
|
+
this.scopes.delete(socket);
|
|
103
|
+
this.fileIndexScopes.delete(socket);
|
|
28
104
|
this.sockets.delete(socket);
|
|
29
105
|
}
|
|
106
|
+
subscribe(socket, conversationId) {
|
|
107
|
+
// Finish the old transcript before replacing it. A later delta then belongs to the new scope,
|
|
108
|
+
// and the authoritative snapshot reconstructs anything not streamed while it was unselected.
|
|
109
|
+
this.flushStreaming(socket);
|
|
110
|
+
this.scopes.set(socket, conversationId ? { conversationId } : {});
|
|
111
|
+
this.sendState(socket);
|
|
112
|
+
}
|
|
113
|
+
sendState(socket) {
|
|
114
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
115
|
+
return;
|
|
116
|
+
this.flushStreaming(socket);
|
|
117
|
+
safeSend(socket, JSON.stringify({ type: "state", state: this.getState(this.scopes.get(socket)) }));
|
|
118
|
+
}
|
|
119
|
+
subscribeFileIndex(socket, workspaceId) {
|
|
120
|
+
if (workspaceId)
|
|
121
|
+
this.fileIndexScopes.set(socket, workspaceId);
|
|
122
|
+
else
|
|
123
|
+
this.fileIndexScopes.delete(socket);
|
|
124
|
+
}
|
|
125
|
+
receives(socket, event) {
|
|
126
|
+
const scope = this.scopes.get(socket);
|
|
127
|
+
if (!scope)
|
|
128
|
+
return true;
|
|
129
|
+
switch (event.type) {
|
|
130
|
+
case "files.index":
|
|
131
|
+
return this.fileIndexScopes.get(socket) === event.workspaceId;
|
|
132
|
+
case "message.delta":
|
|
133
|
+
case "message.thinking":
|
|
134
|
+
case "message.tools":
|
|
135
|
+
return event.conversationId === scope.conversationId;
|
|
136
|
+
case "subagent.event":
|
|
137
|
+
return event.run.conversationId === scope.conversationId;
|
|
138
|
+
case "workflow.event":
|
|
139
|
+
return event.workflow.conversationId === scope.conversationId;
|
|
140
|
+
default:
|
|
141
|
+
// Lifecycle and summary changes still reach the sidebar for every conversation.
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
30
145
|
configureStreaming(socket, coalesce) {
|
|
31
146
|
if (coalesce)
|
|
32
147
|
this.coalescedSockets.add(socket);
|
|
@@ -42,8 +157,7 @@ export class WsBroadcaster {
|
|
|
42
157
|
clearTimeout(pending.timer);
|
|
43
158
|
this.pendingStreams.delete(socket);
|
|
44
159
|
for (const event of pending.events.values())
|
|
45
|
-
|
|
46
|
-
socket.send(JSON.stringify(event));
|
|
160
|
+
safeSend(socket, JSON.stringify(event));
|
|
47
161
|
}
|
|
48
162
|
enqueueStreaming(socket, event) {
|
|
49
163
|
let pending = this.pendingStreams.get(socket);
|
|
@@ -52,6 +166,19 @@ export class WsBroadcaster {
|
|
|
52
166
|
pending = { events, timer: setTimeout(() => this.flushStreaming(socket), 16) };
|
|
53
167
|
this.pendingStreams.set(socket, pending);
|
|
54
168
|
}
|
|
169
|
+
// A subagent.event is a whole-run SNAPSHOT whose output is the accumulated text so far: within one
|
|
170
|
+
// 16ms window the LATEST snapshot for a run supersedes every earlier one (they differ only by more
|
|
171
|
+
// text). Keeping the newest replaces a per-delta frame burst with at most one frame per run per
|
|
172
|
+
// window - the O(n²) burst the flood fix exists for - while the card still updates at ~60fps.
|
|
173
|
+
if (event.type === "subagent.event") {
|
|
174
|
+
const superseded = pending.events.findIndex((queued) => queued.type === "subagent.event" && queued.run.id === event.run.id);
|
|
175
|
+
if (superseded >= 0) {
|
|
176
|
+
pending.events[superseded] = event;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
pending.events.push(event);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
55
182
|
// Only adjacent fragments from the same stream can merge. A different kind,
|
|
56
183
|
// answer offset or lifecycle flag is an ordering boundary, even within 16ms.
|
|
57
184
|
const previous = pending.events.at(-1);
|
|
@@ -67,53 +194,114 @@ export class WsBroadcaster {
|
|
|
67
194
|
return this.sockets;
|
|
68
195
|
}
|
|
69
196
|
broadcast(event) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
* share, and their cost is one small string.
|
|
79
|
-
*/
|
|
80
|
-
const startedAt = performance.now();
|
|
81
|
-
const payload = streaming ? undefined : JSON.stringify(event);
|
|
82
|
-
if (!streaming && event.type === "state" && process.env.SUPER_PI_TRACE_LATENCY === "1")
|
|
83
|
-
console.warn(`[super-pi latency] phase=state.broadcast bytes=${payload?.length ?? 0} sockets=${this.sockets.size} serializeMs=${Math.round(performance.now() - startedAt)}`);
|
|
197
|
+
if (event.type === "state") {
|
|
198
|
+
this.broadcastState();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const streaming = event.type === "message.delta" || event.type === "message.thinking" || event.type === "subagent.event";
|
|
202
|
+
// Serialize common events once, after finding a recipient. Coalesced text stays per socket;
|
|
203
|
+
// conversation snapshots are projected and serialized once per scope in broadcastState().
|
|
204
|
+
let payload;
|
|
84
205
|
for (const socket of this.sockets) {
|
|
85
|
-
if (socket.readyState !== WebSocket.OPEN)
|
|
206
|
+
if (socket.readyState !== WebSocket.OPEN || !this.receives(socket, event))
|
|
86
207
|
continue;
|
|
87
208
|
if (streaming && this.coalescedSockets.has(socket))
|
|
88
209
|
this.enqueueStreaming(socket, event);
|
|
89
210
|
else {
|
|
90
211
|
// A state/tool/lifecycle event is a transcript boundary. Flush its preceding text first.
|
|
91
212
|
this.flushStreaming(socket);
|
|
92
|
-
|
|
213
|
+
payload ??= JSON.stringify(event);
|
|
214
|
+
safeSend(socket, payload);
|
|
93
215
|
}
|
|
94
216
|
}
|
|
95
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* BROADCAST STATES IN A TIME WINDOW, NOT IN A MICROTASK.
|
|
220
|
+
*
|
|
221
|
+
* The microtask flag this replaces merged only the calls that arrived within the
|
|
222
|
+
* SAME tick. A sub-agent that streams text emits one `subagent.event` per delta,
|
|
223
|
+
* each asking for a full state broadcast, and those deltas arrive across many
|
|
224
|
+
* ticks - so a single sub-agent run could ask for HUNDREDS of full state
|
|
225
|
+
* broadcasts. On a machine whose state is large, that is a flood the browser
|
|
226
|
+
* cannot drain: its parse queue falls behind, the socket buffer grows, and the
|
|
227
|
+
* connection is dropped (the browser reports 1006).
|
|
228
|
+
*
|
|
229
|
+
* A window turns a burst into a handful of frames. The state a browser receives
|
|
230
|
+
* is the LATEST state - the intermediate ones carried nothing the last one did
|
|
231
|
+
* not - so nothing is lost by sending fewer of them. The only thing that
|
|
232
|
+
* changes is how promptly the browser sees a change, and 150ms is below the
|
|
233
|
+
* threshold at which a person notices.
|
|
234
|
+
*/
|
|
235
|
+
stateFlushScheduled = false;
|
|
96
236
|
broadcastState() {
|
|
237
|
+
// DIAGNOSTIC PATH: the pre-fix wire shape, one frame per request, no merge window.
|
|
238
|
+
if (this.sendStateImmediately) {
|
|
239
|
+
this.flushState();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
97
242
|
if (this.stateFlushScheduled)
|
|
98
243
|
return;
|
|
99
244
|
this.stateFlushScheduled = true;
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
// HOW MANY STATE FRAMES WENT OUT IN THE LAST SECOND.
|
|
247
|
+
//
|
|
248
|
+
// This is the flood detector, and it is a RATE, not a delay. A server that is behaving sends a
|
|
249
|
+
// handful of frames a second: one per user action, one per turn boundary. A server that is
|
|
250
|
+
// flooding sends hundreds - a sub-agent's delta used to ask for one full state frame each.
|
|
251
|
+
//
|
|
252
|
+
// The NORMAL path stays exactly as it always was: a microtask merge, so the frame goes out at the
|
|
253
|
+
// end of this tick. That matters for more than latency: "the user sent a message" and "the turn
|
|
254
|
+
// opened" are two broadcasts, and if they are merged into one frame the user's own message never
|
|
255
|
+
// appears on its own - it appears already accompanied by the assistant placeholder. A message
|
|
256
|
+
// that arrives half-written is a message the user does not believe they sent.
|
|
257
|
+
//
|
|
258
|
+
// The DEGRADED path is for a server that is genuinely flooding: requests are then merged into one
|
|
259
|
+
// frame per window, because a browser that cannot parse frames as fast as they arrive gets its
|
|
260
|
+
// connection dropped (the browser reports 1006) and the user sees "连接中 / 断连 / 连接中".
|
|
100
261
|
queueMicrotask(() => {
|
|
101
262
|
this.stateFlushScheduled = false;
|
|
102
|
-
this.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
263
|
+
this.flushState();
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
flushState() {
|
|
267
|
+
const groups = new Map();
|
|
268
|
+
for (const socket of this.sockets) {
|
|
269
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
270
|
+
continue;
|
|
271
|
+
const scope = this.scopes.get(socket);
|
|
272
|
+
const key = scope ? `focused:${JSON.stringify(scope.conversationId ?? null)}` : "legacy";
|
|
273
|
+
let group = groups.get(key);
|
|
274
|
+
if (!group) {
|
|
275
|
+
group = { scope, sockets: [] };
|
|
276
|
+
groups.set(key, group);
|
|
277
|
+
}
|
|
278
|
+
group.sockets.push(socket);
|
|
279
|
+
}
|
|
280
|
+
for (const { scope, sockets } of groups.values()) {
|
|
281
|
+
const snapshotStartedAt = performance.now();
|
|
282
|
+
const state = this.getState(scope);
|
|
283
|
+
const snapshotMs = performance.now() - snapshotStartedAt;
|
|
284
|
+
const serializeStartedAt = performance.now();
|
|
285
|
+
const cachedStatePayload = JSON.stringify({ type: "state", state });
|
|
286
|
+
const serializeMs = performance.now() - serializeStartedAt;
|
|
287
|
+
if (process.env.SUPER_PI_TRACE_LATENCY === "1") {
|
|
288
|
+
console.warn(`[super-pi latency] phase=state.snapshot.ready bytes=${Buffer.byteLength(cachedStatePayload, "utf8")} snapshotMs=${Math.round(snapshotMs)} serializeMs=${Math.round(serializeMs)} sockets=${sockets.length} scope=${scope ? "focused" : "legacy"}`);
|
|
289
|
+
}
|
|
290
|
+
for (const socket of sockets) {
|
|
108
291
|
this.flushStreaming(socket);
|
|
109
|
-
socket
|
|
292
|
+
safeSend(socket, cachedStatePayload);
|
|
110
293
|
}
|
|
111
|
-
|
|
112
|
-
this.cachedStatePayload = undefined;
|
|
113
|
-
});
|
|
294
|
+
}
|
|
114
295
|
}
|
|
115
|
-
|
|
116
|
-
|
|
296
|
+
/**
|
|
297
|
+
* Releases the pending state window, so a shutting-down server does not hold the event loop
|
|
298
|
+
* open on a timer whose flush would send to sockets that are already closed.
|
|
299
|
+
*/
|
|
300
|
+
dispose() {
|
|
301
|
+
this.stateFlushScheduled = false;
|
|
302
|
+
}
|
|
303
|
+
getStateSnapshot(scope) {
|
|
304
|
+
return this.getState(scope);
|
|
117
305
|
}
|
|
118
306
|
}
|
|
119
307
|
import { checkDiskVersion } from "./version.js";
|
|
@@ -135,7 +323,11 @@ export class WsServer {
|
|
|
135
323
|
this.accessToken = options.accessToken;
|
|
136
324
|
this.rootDir = options.rootDir;
|
|
137
325
|
this.onSocketClose = options.onSocketClose;
|
|
138
|
-
|
|
326
|
+
// Remote clients are commonly separated from the server by a high-latency link. State snapshots can
|
|
327
|
+
// contain a long transcript, so sending them uncompressed turns every lifecycle event into a multi-MB
|
|
328
|
+
// transfer. Per-message deflate keeps the protocol unchanged while making the wire cost proportional
|
|
329
|
+
// to the actual information in the snapshot. The threshold avoids spending CPU on token-sized events.
|
|
330
|
+
this.websocketServer = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_PAYLOAD_BYTES, perMessageDeflate: { threshold: 1024, serverNoContextTakeover: true } });
|
|
139
331
|
this.setupUpgrade(options.httpServer);
|
|
140
332
|
this.setupConnectionHandling();
|
|
141
333
|
}
|
|
@@ -167,7 +359,39 @@ export class WsServer {
|
|
|
167
359
|
}
|
|
168
360
|
setupConnectionHandling() {
|
|
169
361
|
this.websocketServer.on("connection", (socket, request) => {
|
|
170
|
-
|
|
362
|
+
const url = new URL(request.url ?? "/ws", "http://localhost");
|
|
363
|
+
// HOW A CLIENT ASKS FOR A SCOPE, AND WHAT IT GETS WHEN IT DOES NOT ASK.
|
|
364
|
+
//
|
|
365
|
+
// `undefined` means the WHOLE application: every workspace, every conversation and every
|
|
366
|
+
// message. On a machine with a long history that is tens of megabytes in the FIRST FRAME.
|
|
367
|
+
//
|
|
368
|
+
// There are three cases, and they are NOT the same thing:
|
|
369
|
+
//
|
|
370
|
+
// ?state=focused -> the focused projection (sidebar + the selected transcript)
|
|
371
|
+
// ?conversationId=<id> -> the focused projection of that one conversation
|
|
372
|
+
// (neither) -> the legacy full state, exactly as before this change
|
|
373
|
+
//
|
|
374
|
+
// The middle case is what the current browser sends (it sends both). The third case is a
|
|
375
|
+
// client running an OLD bundle, and it MUST keep getting the full state: a focused frame
|
|
376
|
+
// with no conversationId carries NO transcripts at all, so an old client that never sends
|
|
377
|
+
// `state.subscribe` would connect and see an empty application. Dropping it silently is
|
|
378
|
+
// worse than a big first frame.
|
|
379
|
+
//
|
|
380
|
+
// And the big first frame is no longer FATAL: `safeSend` drops an oversized frame instead
|
|
381
|
+
// of terminating the connection, so a browser that cannot digest 42 MB at once is merely
|
|
382
|
+
// SLOW on that first frame - it is not disconnected, and it does not enter the
|
|
383
|
+
// "连接中 / 断连 / 连接中" loop. The disconnect is fixed at the buffer, not at the scope.
|
|
384
|
+
const wantsFocused = url.searchParams.get("state") === "focused";
|
|
385
|
+
const conversationId = url.searchParams.get("conversationId") || undefined;
|
|
386
|
+
const scope = wantsFocused || conversationId ? { conversationId } : undefined;
|
|
387
|
+
this.broadcaster.register(socket, scope);
|
|
388
|
+
socket.on("error", (error) => {
|
|
389
|
+
console.warn(`Super Pi: WebSocket error: ${error instanceof Error ? error.message : String(error)}`);
|
|
390
|
+
try {
|
|
391
|
+
socket.terminate();
|
|
392
|
+
}
|
|
393
|
+
catch { /* already closed */ }
|
|
394
|
+
});
|
|
171
395
|
/**
|
|
172
396
|
* The peer address, captured ONCE per connection rather than read per message.
|
|
173
397
|
*
|
|
@@ -184,18 +408,18 @@ export class WsServer {
|
|
|
184
408
|
* only the shells belonging to the socket that went away.
|
|
185
409
|
*/
|
|
186
410
|
const terminalSessionIds = new Set();
|
|
187
|
-
|
|
411
|
+
this.broadcaster.sendState(socket);
|
|
188
412
|
if (this.rootDir) {
|
|
189
|
-
socket
|
|
413
|
+
safeSend(socket, JSON.stringify({ type: "server.version", version: checkDiskVersion(this.rootDir) }));
|
|
190
414
|
}
|
|
191
415
|
void this.catalogService.agentCatalog().then((catalog) => {
|
|
192
416
|
if (socket.readyState === WebSocket.OPEN) {
|
|
193
|
-
socket
|
|
417
|
+
safeSend(socket, JSON.stringify({ type: "agent.catalog", catalog }));
|
|
194
418
|
}
|
|
195
419
|
}).catch((error) => console.warn(`Super Pi: agent catalog for a new connection failed: ${error instanceof Error ? error.message : "unknown error"}`));
|
|
196
420
|
void this.catalogService.subagentCatalog().then((catalog) => {
|
|
197
421
|
if (socket.readyState === WebSocket.OPEN) {
|
|
198
|
-
socket
|
|
422
|
+
safeSend(socket, JSON.stringify({ type: "subagent.catalog", catalog }));
|
|
199
423
|
}
|
|
200
424
|
}).catch((error) => console.warn(`Super Pi: sub-agent catalog for a new connection failed: ${error instanceof Error ? error.message : "unknown error"}`));
|
|
201
425
|
/**
|
|
@@ -219,7 +443,7 @@ export class WsServer {
|
|
|
219
443
|
if (this.rootDir) {
|
|
220
444
|
const v = checkDiskVersion(this.rootDir);
|
|
221
445
|
if (v.needsRestart) {
|
|
222
|
-
socket
|
|
446
|
+
safeSend(socket, JSON.stringify({ type: "server.version", version: v }));
|
|
223
447
|
}
|
|
224
448
|
}
|
|
225
449
|
}, 30_000);
|
|
@@ -249,18 +473,34 @@ export class WsServer {
|
|
|
249
473
|
}
|
|
250
474
|
catch {
|
|
251
475
|
console.error("Super Pi command error: received a frame that is not valid JSON");
|
|
252
|
-
socket
|
|
476
|
+
safeSend(socket, JSON.stringify({ type: "error", message: "收到一条无法解析的命令,已忽略。" }));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || typeof parsed.type !== "string") {
|
|
480
|
+
safeSend(socket, JSON.stringify({ type: "error", message: "收到一条格式无效的命令,已忽略。" }));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (parsed.type === "files.index.subscribe") {
|
|
484
|
+
this.broadcaster.subscribeFileIndex(socket, typeof parsed.workspaceId === "string" ? parsed.workspaceId : undefined);
|
|
253
485
|
return;
|
|
254
486
|
}
|
|
255
487
|
if (parsed.type === "stream.configure") {
|
|
256
488
|
this.broadcaster.configureStreaming(socket, parsed.coalesce);
|
|
257
489
|
return;
|
|
258
490
|
}
|
|
491
|
+
if (parsed.type === "state.subscribe") {
|
|
492
|
+
if (parsed.conversationId !== undefined && typeof parsed.conversationId !== "string") {
|
|
493
|
+
safeSend(socket, JSON.stringify({ type: "error", message: "会话订阅标识必须是字符串。" }));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
this.broadcaster.subscribe(socket, parsed.conversationId);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
259
499
|
// A send or selected queued turn is a streaming operation. It must leave the socket's command
|
|
260
500
|
// chain immediately: waiting for the model turn would block later priority/cancel commands and
|
|
261
501
|
// make a second send miss the durable queue. The service owns per-conversation run claims.
|
|
262
502
|
const runIndependently = parsed.type === "message.send" || parsed.type === "message.sendQueued"
|
|
263
|
-
|| parsed.type === "message.interrupt" || parsed.type === "run.stop";
|
|
503
|
+
|| parsed.type === "message.interrupt" || parsed.type === "run.stop" || parsed.type === "message.revert";
|
|
264
504
|
// ...WHICH MEANS A SEND CAN OVERTAKE A REVERT SENT JUST BEFORE IT, because the revert below is
|
|
265
505
|
// queued behind whatever this socket was already doing. The service only learns it is cutting
|
|
266
506
|
// when the command EXECUTES, so a send handled in between would find the conversation open and
|
|
@@ -275,14 +515,11 @@ export class WsServer {
|
|
|
275
515
|
if (command.type === "terminal.open")
|
|
276
516
|
terminalSessionIds.add(command.sessionId);
|
|
277
517
|
const entityId = await this.commandHandler.handleCommand(command, (event) => {
|
|
278
|
-
|
|
279
|
-
socket.send(JSON.stringify(event));
|
|
280
|
-
}
|
|
518
|
+
safeSend(socket, JSON.stringify(event));
|
|
281
519
|
}, remoteAddress);
|
|
282
520
|
// Void-returning configuration commands also need an ack to release optimistic UI state.
|
|
283
|
-
if (command.operationRequestId
|
|
284
|
-
socket
|
|
285
|
-
}
|
|
521
|
+
if (command.operationRequestId)
|
|
522
|
+
safeSend(socket, JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: true, entityId }));
|
|
286
523
|
}
|
|
287
524
|
catch (error) {
|
|
288
525
|
// A FAILED COMMAND WRITES ITSELF DOWN BEFORE IT ANSWERS.
|
|
@@ -294,10 +531,10 @@ export class WsServer {
|
|
|
294
531
|
console.error("Super Pi command error:", describeThrownWithStack(error));
|
|
295
532
|
const reason = describeThrown(error);
|
|
296
533
|
if (command?.operationRequestId) {
|
|
297
|
-
socket
|
|
534
|
+
safeSend(socket, JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: false, message: reason }));
|
|
298
535
|
return;
|
|
299
536
|
}
|
|
300
|
-
socket
|
|
537
|
+
safeSend(socket, JSON.stringify({
|
|
301
538
|
type: "error",
|
|
302
539
|
message: reason || "服务端没有说明这次失败的原因。",
|
|
303
540
|
}));
|
|
@@ -326,6 +563,9 @@ export class WsServer {
|
|
|
326
563
|
* Gracefully closes all active WebSocket connections and terminates the server.
|
|
327
564
|
*/
|
|
328
565
|
close() {
|
|
566
|
+
// A pending state window must not outlive the server: the timer holds the event loop
|
|
567
|
+
// open, and a flush after the sockets are closed would send to a dead peer.
|
|
568
|
+
this.broadcaster.dispose();
|
|
329
569
|
for (const socket of this.broadcaster.getSockets()) {
|
|
330
570
|
socket.close(1001, "server-shutdown");
|
|
331
571
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evandu/super-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.140",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
"scripts/test-runner.test.mjs",
|
|
47
47
|
"scripts/stream-checkpoint.test.mjs",
|
|
48
48
|
"scripts/stream-transport.test.mjs",
|
|
49
|
+
"scripts/remote-transport.test.mjs",
|
|
50
|
+
"scripts/browser-safe-port.test.mjs",
|
|
49
51
|
"scripts/inline-thinking.test.mjs",
|
|
50
52
|
"scripts/inline-thinking-close-tag.test.mjs",
|
|
51
53
|
"scripts/pi-session.test.mjs",
|
|
@@ -72,12 +74,13 @@
|
|
|
72
74
|
"scripts/image-input-real-pi.test.mjs",
|
|
73
75
|
"scripts/image-upload.test.mjs",
|
|
74
76
|
"scripts/extension-ui-e2e.test.mjs",
|
|
75
|
-
"scripts/session-picker.test.mjs",
|
|
77
|
+
"scripts/session-picker.test.mjs", "scripts/session-orchestrator.test.mjs", "scripts/session-orchestrator-tools.test.mjs",
|
|
76
78
|
"scripts/workspace-cwd.test.mjs",
|
|
77
79
|
"scripts/subagents.test.mjs",
|
|
78
80
|
"scripts/subagent-noise.test.mjs",
|
|
79
81
|
"scripts/subagent-response-receipt.test.mjs",
|
|
80
82
|
"scripts/subagent-runs.test.mjs",
|
|
83
|
+
"scripts/ws-flood-regression.test.mjs",
|
|
81
84
|
"scripts/conversation-reconcile.test.mjs",
|
|
82
85
|
"scripts/subagent-dispatch.test.mjs",
|
|
83
86
|
"scripts/workflow-plan.test.mjs",
|
|
@@ -97,6 +100,13 @@
|
|
|
97
100
|
"scripts/background-result-one-per-turn.test.mjs",
|
|
98
101
|
"scripts/thinking-status-promotion.test.mjs",
|
|
99
102
|
"scripts/thinking-delivery-once.test.mjs",
|
|
103
|
+
"scripts/thinking-tool-order.test.mjs",
|
|
104
|
+
"scripts/question-behind-body.test.mjs",
|
|
105
|
+
"scripts/workspace-default-model-rules.test.mjs",
|
|
106
|
+
"scripts/tool-call-persist-count.test.mjs",
|
|
107
|
+
"scripts/conversation-service-narrow-reads.test.mjs",
|
|
108
|
+
"scripts/revert-evicts-live-session.test.mjs",
|
|
109
|
+
"scripts/cdp-retry-bounded.test.mjs",
|
|
100
110
|
"scripts/question-call-session-path.test.mjs",
|
|
101
111
|
"scripts/withdrawn-message-persistence.test.mjs",
|
|
102
112
|
"scripts/git-write-endpoint-guard.test.mjs",
|
|
@@ -116,6 +126,7 @@
|
|
|
116
126
|
"scripts/files-missing-path.test.mjs",
|
|
117
127
|
"scripts/file-changes.test.mjs",
|
|
118
128
|
"scripts/file-index.test.mjs",
|
|
129
|
+
"scripts/file-index-write-refresh.test.mjs",
|
|
119
130
|
"scripts/file-name-index.test.mjs",
|
|
120
131
|
"scripts/file-content-search.test.mjs",
|
|
121
132
|
"scripts/file-diff-client.test.mjs",
|
|
@@ -199,15 +210,18 @@
|
|
|
199
210
|
"scripts/rail-workspace-follows-session-browser.test.mjs",
|
|
200
211
|
"scripts/git-tab-browser.test.mjs",
|
|
201
212
|
"scripts/workspace-reopen-browser.test.mjs",
|
|
213
|
+
"scripts/workspace-identity-browser.test.mjs",
|
|
202
214
|
"scripts/sidebar-tools-browser.test.mjs",
|
|
203
215
|
"scripts/tool-runtime-browser.test.mjs",
|
|
204
216
|
"scripts/session-context-menu-browser.test.mjs",
|
|
205
217
|
"scripts/tool-call-blocks-browser.test.mjs",
|
|
218
|
+
"scripts/model-notice-browser.test.mjs",
|
|
206
219
|
"scripts/assistant-working-indicator-browser.test.mjs",
|
|
207
220
|
"scripts/thinking-first-delta-once-browser.test.mjs",
|
|
208
221
|
"scripts/files-tab-browser.test.mjs",
|
|
209
222
|
"scripts/editor-blame-browser.test.mjs",
|
|
210
223
|
"scripts/file-panel-external-poll-browser.test.mjs",
|
|
224
|
+
"scripts/editor-save-race-browser.test.mjs",
|
|
211
225
|
"scripts/file-quickopen-browser.test.mjs",
|
|
212
226
|
"scripts/file-content-search-browser.test.mjs",
|
|
213
227
|
"scripts/file-changes-browser.test.mjs",
|