@evandu/super-pi 0.1.139 → 0.1.141

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 (57) hide show
  1. package/agents/super-pi-task/core.ts +418 -1
  2. package/agents/super-pi-task/index.ts +115 -0
  3. package/dist/.super-pi-check-inputs.json +2 -2
  4. package/dist/client/.super-pi-client-sources.json +7 -5
  5. package/dist/client/assets/{CodeMirrorEditor-H7SHGhdS.js → CodeMirrorEditor-Dm5IDKy9.js} +2 -2
  6. package/dist/client/assets/{dist-CZ9Krsno.js → dist-4baaMdqn.js} +1 -1
  7. package/dist/client/assets/{dist-Jw-GQ-re.js → dist-B-edBMDa.js} +1 -1
  8. package/dist/client/assets/{dist-zUJqdbfo.js → dist-BGaKp7BG.js} +1 -1
  9. package/dist/client/assets/{dist-2XXLO-IZ.js → dist-BMbwbGH9.js} +1 -1
  10. package/dist/client/assets/{dist-NlopeJ5R.js → dist-BqXU6S8h.js} +1 -1
  11. package/dist/client/assets/{dist-BEM6tttY.js → dist-BvCNiV_z.js} +1 -1
  12. package/dist/client/assets/{dist-CukPKrEn.js → dist-C7UQGfmz.js} +1 -1
  13. package/dist/client/assets/{dist-BfUeRJKr.js → dist-CMDW84Eh.js} +1 -1
  14. package/dist/client/assets/{dist-C8NllalC.js → dist-CN1j5LO2.js} +1 -1
  15. package/dist/client/assets/{dist-CUNctZ5v.js → dist-CNgQgdyS.js} +1 -1
  16. package/dist/client/assets/{dist-C_ftUQZN.js → dist-CSDLiQKt.js} +1 -1
  17. package/dist/client/assets/{dist-cP2vnTP_.js → dist-D0-9TI_k.js} +1 -1
  18. package/dist/client/assets/{dist-POoQ4j29.js → dist-D0eIQjoI.js} +1 -1
  19. package/dist/client/assets/{dist-BoucVzKA.js → dist-D7YZUqfA.js} +1 -1
  20. package/dist/client/assets/{dist-BokzcFLE.js → dist-DQWPdnen.js} +1 -1
  21. package/dist/client/assets/{dist-DJ8SwQl_.js → dist-DWj0wg8M.js} +1 -1
  22. package/dist/client/assets/{dist-BxTGjU84.js → dist-DtO-7WEZ.js} +1 -1
  23. package/dist/client/assets/{dist-D3Ta0y4e.js → dist-F7KOq8Xm.js} +1 -1
  24. package/dist/client/assets/{dist-DLKVFZhO.js → dist-GAPsnCM8.js} +1 -1
  25. package/dist/client/assets/{dist-CuMnZOGE.js → dist-LCDxbTTI.js} +1 -1
  26. package/dist/client/assets/{dist-CSdzvQnf.js → dist-M3BFsjal.js} +1 -1
  27. package/dist/client/assets/{dist-C-MUXK_t.js → dist-PQZBepmy.js} +1 -1
  28. package/dist/client/assets/index-BJIfLtDs.css +1 -0
  29. package/dist/client/assets/{index-B32VZVYx.js → index-DHsjaI7m.js} +13 -13
  30. package/dist/client/index.html +2 -2
  31. package/dist/server/agents.d.ts +2 -0
  32. package/dist/server/agents.js +10 -3
  33. package/dist/server/application/conversation-service.d.ts +15 -1
  34. package/dist/server/application/conversation-service.js +18 -2
  35. package/dist/server/application/run-service.d.ts +1 -0
  36. package/dist/server/application/run-service.js +121 -20
  37. package/dist/server/application/session-orchestrator-service.d.ts +113 -0
  38. package/dist/server/application/session-orchestrator-service.js +241 -0
  39. package/dist/server/command-handler.js +4 -0
  40. package/dist/server/http-server.js +135 -0
  41. package/dist/server/index.js +20 -2
  42. package/dist/server/runtime.d.ts +1 -0
  43. package/dist/server/runtime.js +6 -2
  44. package/dist/server/server-context.d.ts +2 -0
  45. package/dist/server/session-ledger.d.ts +60 -0
  46. package/dist/server/session-ledger.js +76 -0
  47. package/dist/server/store.d.ts +10 -0
  48. package/dist/server/store.js +38 -0
  49. package/dist/server/subagent-runs.d.ts +31 -1
  50. package/dist/server/subagent-runs.js +30 -13
  51. package/dist/server/task-tool.d.ts +18 -0
  52. package/dist/server/task-tool.js +85 -0
  53. package/dist/server/types.d.ts +56 -1
  54. package/dist/server/ws-server.d.ts +52 -1
  55. package/dist/server/ws-server.js +151 -35
  56. package/package.json +4 -2
  57. package/dist/client/assets/index-Bdym8BPo.css +0 -1
@@ -7,6 +7,21 @@ import { describeThrown, describeThrownWithStack } from "./failure-text.js";
7
7
  * a network peer that has disappeared can therefore make the buffer grow
8
8
  * without throwing. Once this threshold is crossed we drop that connection
9
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).
10
25
  */
11
26
  export const MAX_WS_BUFFERED_BYTES = 8 * 1024 * 1024;
12
27
  /** Maximum size of one inbound WebSocket message. Attachments are uploaded
@@ -24,10 +39,12 @@ export function safeSend(socket, payload) {
24
39
  return false;
25
40
  const bufferedAmount = Number(socket.bufferedAmount ?? 0);
26
41
  if (Number.isFinite(bufferedAmount) && bufferedAmount > MAX_WS_BUFFERED_BYTES) {
27
- try {
28
- socket.terminate();
29
- }
30
- catch { /* close races are already being cleaned up */ }
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.
31
48
  return false;
32
49
  }
33
50
  try {
@@ -56,7 +73,19 @@ export class WsBroadcaster {
56
73
  fileIndexScopes = new Map();
57
74
  coalescedSockets = new Set();
58
75
  pendingStreams = new Map();
59
- stateFlushScheduled = false;
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";
60
89
  constructor(getState) {
61
90
  this.getState = getState;
62
91
  }
@@ -137,6 +166,19 @@ export class WsBroadcaster {
137
166
  pending = { events, timer: setTimeout(() => this.flushStreaming(socket), 16) };
138
167
  this.pendingStreams.set(socket, pending);
139
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
+ }
140
182
  // Only adjacent fragments from the same stream can merge. A different kind,
141
183
  // answer offset or lifecycle flag is an ordering boundary, even within 16ms.
142
184
  const previous = pending.events.at(-1);
@@ -156,7 +198,7 @@ export class WsBroadcaster {
156
198
  this.broadcastState();
157
199
  return;
158
200
  }
159
- const streaming = event.type === "message.delta" || event.type === "message.thinking";
201
+ const streaming = event.type === "message.delta" || event.type === "message.thinking" || event.type === "subagent.event";
160
202
  // Serialize common events once, after finding a recipient. Coalesced text stays per socket;
161
203
  // conversation snapshots are projected and serialized once per scope in broadcastState().
162
204
  let payload;
@@ -173,41 +215,90 @@ export class WsBroadcaster {
173
215
  }
174
216
  }
175
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;
176
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
+ }
177
242
  if (this.stateFlushScheduled)
178
243
  return;
179
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 "连接中 / 断连 / 连接中".
180
261
  queueMicrotask(() => {
181
262
  this.stateFlushScheduled = false;
182
- const groups = new Map();
183
- for (const socket of this.sockets) {
184
- if (socket.readyState !== WebSocket.OPEN)
185
- continue;
186
- const scope = this.scopes.get(socket);
187
- const key = scope ? `focused:${JSON.stringify(scope.conversationId ?? null)}` : "legacy";
188
- let group = groups.get(key);
189
- if (!group) {
190
- group = { scope, sockets: [] };
191
- groups.set(key, group);
192
- }
193
- group.sockets.push(socket);
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);
194
277
  }
195
- for (const { scope, sockets } of groups.values()) {
196
- const snapshotStartedAt = performance.now();
197
- const state = this.getState(scope);
198
- const snapshotMs = performance.now() - snapshotStartedAt;
199
- const serializeStartedAt = performance.now();
200
- const cachedStatePayload = JSON.stringify({ type: "state", state });
201
- const serializeMs = performance.now() - serializeStartedAt;
202
- if (process.env.SUPER_PI_TRACE_LATENCY === "1") {
203
- 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"}`);
204
- }
205
- for (const socket of sockets) {
206
- this.flushStreaming(socket);
207
- safeSend(socket, cachedStatePayload);
208
- }
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"}`);
209
289
  }
210
- });
290
+ for (const socket of sockets) {
291
+ this.flushStreaming(socket);
292
+ safeSend(socket, cachedStatePayload);
293
+ }
294
+ }
295
+ }
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;
211
302
  }
212
303
  getStateSnapshot(scope) {
213
304
  return this.getState(scope);
@@ -269,8 +360,30 @@ export class WsServer {
269
360
  setupConnectionHandling() {
270
361
  this.websocketServer.on("connection", (socket, request) => {
271
362
  const url = new URL(request.url ?? "/ws", "http://localhost");
272
- const scope = url.searchParams.get("state") === "focused"
273
- ? { conversationId: url.searchParams.get("conversationId") || undefined } : undefined;
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;
274
387
  this.broadcaster.register(socket, scope);
275
388
  socket.on("error", (error) => {
276
389
  console.warn(`Super Pi: WebSocket error: ${error instanceof Error ? error.message : String(error)}`);
@@ -450,6 +563,9 @@ export class WsServer {
450
563
  * Gracefully closes all active WebSocket connections and terminates the server.
451
564
  */
452
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();
453
569
  for (const socket of this.broadcaster.getSockets()) {
454
570
  socket.close(1001, "server-shutdown");
455
571
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.139",
3
+ "version": "0.1.141",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -74,12 +74,14 @@
74
74
  "scripts/image-input-real-pi.test.mjs",
75
75
  "scripts/image-upload.test.mjs",
76
76
  "scripts/extension-ui-e2e.test.mjs",
77
- "scripts/session-picker.test.mjs",
77
+ "scripts/session-picker.test.mjs", "scripts/session-orchestrator.test.mjs", "scripts/session-orchestrator-tools.test.mjs",
78
+ "scripts/session-tree-collapse.test.mjs",
78
79
  "scripts/workspace-cwd.test.mjs",
79
80
  "scripts/subagents.test.mjs",
80
81
  "scripts/subagent-noise.test.mjs",
81
82
  "scripts/subagent-response-receipt.test.mjs",
82
83
  "scripts/subagent-runs.test.mjs",
84
+ "scripts/ws-flood-regression.test.mjs",
83
85
  "scripts/conversation-reconcile.test.mjs",
84
86
  "scripts/subagent-dispatch.test.mjs",
85
87
  "scripts/workflow-plan.test.mjs",