@evandu/super-pi 0.1.137 → 0.1.139

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/agents/super-pi-knowledge/index.ts +11 -13
  2. package/agents/super-pi-memory/index.ts +11 -5
  3. package/bin/super-pi.js +7 -11
  4. package/dist/.super-pi-check-inputs.json +2 -2
  5. package/dist/client/.super-pi-client-sources.json +6 -6
  6. package/dist/client/assets/{CodeMirrorEditor-CkA_5D5r.js → CodeMirrorEditor-H7SHGhdS.js} +2 -2
  7. package/dist/client/assets/{dist-DcdzfM-n.js → dist-2XXLO-IZ.js} +1 -1
  8. package/dist/client/assets/{dist-ufRHdQ8G.js → dist-BEM6tttY.js} +1 -1
  9. package/dist/client/assets/{dist-9Fm8XKu4.js → dist-BfUeRJKr.js} +1 -1
  10. package/dist/client/assets/{dist-CrxmpY93.js → dist-BokzcFLE.js} +1 -1
  11. package/dist/client/assets/{dist-BfEmujEC.js → dist-BoucVzKA.js} +1 -1
  12. package/dist/client/assets/{dist-B02Erd0t.js → dist-BxTGjU84.js} +1 -1
  13. package/dist/client/assets/{dist-DGhdkud4.js → dist-C-MUXK_t.js} +1 -1
  14. package/dist/client/assets/{dist-k6bOz9RS.js → dist-C8NllalC.js} +1 -1
  15. package/dist/client/assets/{dist-C2tSd-AY.js → dist-CSdzvQnf.js} +1 -1
  16. package/dist/client/assets/{dist-BySjislr.js → dist-CUNctZ5v.js} +1 -1
  17. package/dist/client/assets/{dist-B3Mfuv1D.js → dist-CZ9Krsno.js} +1 -1
  18. package/dist/client/assets/{dist-DbovsYeH.js → dist-C_ftUQZN.js} +1 -1
  19. package/dist/client/assets/{dist-j9X7j381.js → dist-CuMnZOGE.js} +1 -1
  20. package/dist/client/assets/{dist-D-LtG92p.js → dist-CukPKrEn.js} +1 -1
  21. package/dist/client/assets/{dist-b9CfAgzG.js → dist-D3Ta0y4e.js} +1 -1
  22. package/dist/client/assets/{dist-BsqzrZ5R.js → dist-DJ8SwQl_.js} +1 -1
  23. package/dist/client/assets/{dist-BqGjijI6.js → dist-DLKVFZhO.js} +1 -1
  24. package/dist/client/assets/{dist-MC2XuZgq.js → dist-Jw-GQ-re.js} +1 -1
  25. package/dist/client/assets/{dist-D_umYm0o.js → dist-NlopeJ5R.js} +1 -1
  26. package/dist/client/assets/{dist-CsNytPug.js → dist-POoQ4j29.js} +1 -1
  27. package/dist/client/assets/{dist-DdVs8Y0X.js → dist-cP2vnTP_.js} +1 -1
  28. package/dist/client/assets/{dist-DL4Q05ts.js → dist-zUJqdbfo.js} +1 -1
  29. package/dist/client/assets/{index-DVmE8H4Y.js → index-B32VZVYx.js} +13 -13
  30. package/dist/client/assets/index-Bdym8BPo.css +1 -0
  31. package/dist/client/index.html +2 -2
  32. package/dist/server/application/conversation-service.d.ts +13 -4
  33. package/dist/server/application/conversation-service.js +112 -61
  34. package/dist/server/application/run-service.d.ts +13 -0
  35. package/dist/server/application/run-service.js +255 -69
  36. package/dist/server/application/scheduled-task-service.js +8 -1
  37. package/dist/server/application/workspace-service.js +16 -1
  38. package/dist/server/feishu-cli.js +15 -4
  39. package/dist/server/file-index.d.ts +30 -0
  40. package/dist/server/file-index.js +49 -4
  41. package/dist/server/files.d.ts +21 -0
  42. package/dist/server/files.js +38 -0
  43. package/dist/server/http-server.js +1455 -1412
  44. package/dist/server/index.js +3 -4
  45. package/dist/server/logging.js +56 -3
  46. package/dist/server/pi-session-pool.d.ts +2 -0
  47. package/dist/server/pi-session-pool.js +1 -0
  48. package/dist/server/pi-session.d.ts +118 -0
  49. package/dist/server/pi-session.js +132 -10
  50. package/dist/server/runtime.d.ts +47 -0
  51. package/dist/server/runtime.js +183 -84
  52. package/dist/server/store.d.ts +44 -1
  53. package/dist/server/store.js +114 -12
  54. package/dist/server/subagent-runs.js +18 -7
  55. package/dist/server/subagents.js +7 -0
  56. package/dist/server/tool-runner.js +13 -4
  57. package/dist/server/types.d.ts +27 -0
  58. package/dist/server/ws-server.d.ts +34 -5
  59. package/dist/server/ws-server.js +170 -46
  60. package/package.json +15 -1
  61. package/scripts/diagnose.mjs +161 -58
  62. package/dist/client/assets/index-FxTrUapG.css +0 -1
@@ -721,6 +721,47 @@ export class Store {
721
721
  const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
722
722
  return workspace ? { ...workspace } : undefined;
723
723
  }
724
+ /** Read the app-wide default without cloning conversations or transcripts. */
725
+ defaultModel() {
726
+ const model = this.state.defaultModel;
727
+ return model ? { ...model } : undefined;
728
+ }
729
+ /** Read one detached conversation for lifecycle operations. */
730
+ conversation(conversationId) {
731
+ const conversation = this.state.conversations.find((item) => item.id === conversationId);
732
+ return conversation ? structuredClone(conversation) : undefined;
733
+ }
734
+ /** Return every pending question in one conversation without cloning unrelated history. */
735
+ pendingQuestions(conversationId) {
736
+ const conversation = this.state.conversations.find((item) => item.id === conversationId);
737
+ if (!conversation)
738
+ return [];
739
+ const pending = [];
740
+ for (const message of conversation.messages) {
741
+ for (let index = 0; index < (message.toolCalls?.length ?? 0); index += 1) {
742
+ const question = message.toolCalls?.[index]?.question;
743
+ if (message.toolCalls?.[index]?.label === "question" && question?.status === "pending") {
744
+ pending.push({ messageId: message.id, index, question: structuredClone(question) });
745
+ }
746
+ }
747
+ }
748
+ return pending;
749
+ }
750
+ /**
751
+ * THE SCHEDULED TASKS, WITHOUT CLONING THE STATE TO FIND THEM.
752
+ *
753
+ * The same argument as the readers above, with a sharper edge: the scheduled-task clock sweeps
754
+ * every fifteen seconds and used to read its three-to-zero tasks through `snapshot()` - a deep
755
+ * clone of the whole application, every fifteen seconds, whether the user has any scheduled task or
756
+ * not. On a server holding a long transcript that is a full copy of the app four times a minute,
757
+ * produced for a list that is usually empty.
758
+ *
759
+ * The tasks are returned as a shallow copy of the array: the sweep reads `enabled` and
760
+ * `nextRunAt` and then hands the whole task back to the store, which owns the real one.
761
+ */
762
+ scheduledTasks() {
763
+ return this.state.scheduledTasks ? [...this.state.scheduledTasks] : [];
764
+ }
724
765
  /** Small detached rows used when refreshing each conversation's scoped model catalog. */
725
766
  catalogConversationTargets() {
726
767
  return this.state.conversations.map(({ id, archivedAt, model }) => ({
@@ -788,8 +829,10 @@ export class Store {
788
829
  snapshot() {
789
830
  const startedAt = process.env.SUPER_PI_TRACE_LATENCY === "1" ? performance.now() : 0;
790
831
  const value = structuredClone(this.state);
832
+ // Do not stringify here just to count bytes: that makes tracing add a second full serialization
833
+ // and mislabels that extra work as clone time. The broadcaster measures the actual JSON payload.
791
834
  if (startedAt)
792
- console.warn(`[super-pi latency] phase=state.snapshot bytes=${JSON.stringify(value).length} cloneMs=${Math.round(performance.now() - startedAt)}`);
835
+ console.warn(`[super-pi latency] phase=state.snapshot cloneMs=${Math.round(performance.now() - startedAt)}`);
793
836
  return value;
794
837
  }
795
838
  /** Cheap scalar/entity reads for hot paths. Never expose the live mutable state object. */
@@ -981,14 +1024,32 @@ export class Store {
981
1024
  * Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
982
1025
  * sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
983
1026
  */
984
- clientState() {
985
- const state = this.snapshot();
986
- const closed = new Set(state.workspaces.filter((item) => item.closedAt).map((item) => item.id));
987
- if (!closed.size)
988
- return state;
989
- state.workspaces = state.workspaces.filter((item) => !closed.has(item.id));
990
- state.conversations = state.conversations.filter((item) => !closed.has(item.workspaceId));
991
- return state;
1027
+ clientState(scope) {
1028
+ const closed = new Set(this.state.workspaces.filter((item) => item.closedAt).map((item) => item.id));
1029
+ const workspaces = this.state.workspaces.filter((item) => !closed.has(item.id));
1030
+ const visible = this.state.conversations.filter((item) => !closed.has(item.workspaceId));
1031
+ // Legacy sockets still receive complete visible transcripts. Project before cloning so a closed
1032
+ // workspace's retained history is never copied just to discard it afterwards.
1033
+ if (scope === undefined)
1034
+ return structuredClone({ ...this.state, workspaces, conversations: visible });
1035
+ // A focused socket keeps every sidebar row, but owns only the selected transcript. An empty scope
1036
+ // deliberately loads none; a missing/closed selection must not expose retained workspace history.
1037
+ const selectedId = visible.find((item) => item.id === scope.conversationId)?.id;
1038
+ const conversations = visible.map(({ messages, ...metadata }) => ({
1039
+ ...metadata,
1040
+ messages: metadata.id === selectedId ? messages : [],
1041
+ messagesLoaded: metadata.id === selectedId,
1042
+ hasPendingQuestion: messages.some((message) => message.toolCalls?.some((call) => call.question?.status === "pending")),
1043
+ }));
1044
+ return structuredClone({
1045
+ ...this.state,
1046
+ workspaces,
1047
+ conversations,
1048
+ subagentRuns: this.state.subagentRuns.filter((run) => run.conversationId === selectedId),
1049
+ ...(this.state.workflows ? { workflows: this.state.workflows.filter((run) => run.conversationId === selectedId) } : {}),
1050
+ ...(this.state.todosByConversation ? { todosByConversation: selectedId && this.state.todosByConversation[selectedId]
1051
+ ? { [selectedId]: this.state.todosByConversation[selectedId] } : {} } : {}),
1052
+ });
992
1053
  }
993
1054
  async createConversation(workspaceId, configuration) {
994
1055
  const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
@@ -1007,6 +1068,10 @@ export class Store {
1007
1068
  if (workspace.defaultAgentId)
1008
1069
  conversation.agentId = workspace.defaultAgentId;
1009
1070
  if (configuration) {
1071
+ // WHY THIS CONVERSATION IS NOT ON THE MODEL THE USER SET. Set once, at birth, and never
1072
+ // rewritten: it describes how this conversation came to be, not what is true right now.
1073
+ if (configuration.modelNotice !== undefined)
1074
+ conversation.modelNotice = configuration.modelNotice;
1010
1075
  if (configuration.model !== undefined) {
1011
1076
  if (configuration.model)
1012
1077
  conversation.model = { ...configuration.model };
@@ -1477,7 +1542,7 @@ export class Store {
1477
1542
  };
1478
1543
  calls.push(structuredClone(placed));
1479
1544
  conversation.updatedAt = now();
1480
- await this.persist();
1545
+ await this.persistToolCallMessage(conversationId, messageId);
1481
1546
  return structuredClone(calls);
1482
1547
  }
1483
1548
  /**
@@ -1521,7 +1586,7 @@ export class Store {
1521
1586
  // finished tool block that vanishes the moment the call ends is a block that taught the reader
1522
1587
  // nothing. The client prefers `live` when both are present, falling back to `result`.
1523
1588
  conversation.updatedAt = now();
1524
- await this.persist();
1589
+ await this.persistToolCallMessage(conversationId, messageId);
1525
1590
  return structuredClone(calls);
1526
1591
  }
1527
1592
  /**
@@ -1533,6 +1598,26 @@ export class Store {
1533
1598
  * Every field is `undefined`-tested for the same reason `settleToolCall` does it: `""` and `[]` are
1534
1599
  * values a tool can genuinely produce, and treating them as "not reported" would erase a real answer.
1535
1600
  */
1601
+ /**
1602
+ * THE TOOL-CALL WRITES, PERSISTED AS ONE MESSAGE INSTEAD OF THE WHOLE APPLICATION.
1603
+ *
1604
+ * A tool call's lifecycle touches exactly ONE thing: the `toolCalls` array of ONE message. The
1605
+ * write used to be `this.persist()`, which hands the ENTIRE state to the repository - every
1606
+ * workspace, every conversation, every message of every conversation - to be serialized and
1607
+ * written. A turn that calls five tools therefore wrote the whole application ten to fifteen
1608
+ * times, and every one of those writes was proportional to the whole history rather than to the
1609
+ * one row that changed (LAT-020).
1610
+ *
1611
+ * `message.replace` is the transaction that means 「this one message now reads like this」, and it
1612
+ * is exactly the write this is: the row's content did not change, only its tool calls did.
1613
+ */
1614
+ async persistToolCallMessage(conversationId, messageId) {
1615
+ const conversation = this.requireConversation(conversationId);
1616
+ const message = conversation.messages.find((item) => item.id === messageId);
1617
+ if (!message)
1618
+ return;
1619
+ await this.persistTransaction({ kind: "message.replace", conversationId, message: structuredClone(message), updatedAt: conversation.updatedAt });
1620
+ }
1536
1621
  async updateToolCall(conversationId, messageId, index, patch) {
1537
1622
  const conversation = this.requireConversation(conversationId);
1538
1623
  const message = conversation.messages.find((item) => item.id === messageId);
@@ -1569,11 +1654,28 @@ export class Store {
1569
1654
  // the call was being written are gone by the time it runs.
1570
1655
  if (patch.live !== undefined)
1571
1656
  call.live = patch.live;
1657
+ // WHERE THE CALL HAPPENED, corrected when the call is announced. The placeholder stamped the
1658
+ // offsets of the moment it was made, and that moment is not the moment the call belongs to
1659
+ // (UX-008).
1660
+ if (patch.at !== undefined)
1661
+ call.at = patch.at;
1662
+ if (patch.thinkingAt !== undefined)
1663
+ call.thinkingAt = patch.thinkingAt;
1664
+ // WHICH REASONING BLOCK THIS CALL BELONGS TO. It is written when the call is ANNOUNCED, not
1665
+ // when its placeholder was made, and it is written HERE because the placeholder row already
1666
+ // exists by then - a placeholder created while the model was still writing the arguments
1667
+ // cannot know whether the reasoning was over by the time the call was made (UX-008).
1668
+ //
1669
+ // `null` IS A VALUE, NOT AN ABSENCE: it says the call is outside every reasoning block, and
1670
+ // the browser needs to hear that difference from "not decided yet". So the check is
1671
+ // `!== undefined`, never a truthiness test.
1672
+ if (patch.thinkingBlock !== undefined)
1673
+ call.thinkingBlock = patch.thinkingBlock;
1572
1674
  // The question rides on the call rather than beside it, so this is the only write it needs.
1573
1675
  if (patch.question !== undefined)
1574
1676
  call.question = patch.question;
1575
1677
  conversation.updatedAt = now();
1576
- await this.persist();
1678
+ await this.persistToolCallMessage(conversationId, messageId);
1577
1679
  return structuredClone(calls);
1578
1680
  }
1579
1681
  /**
@@ -125,7 +125,22 @@ export class SubagentRunRecorder {
125
125
  // Generated HERE, not by `run()`, because the record has to exist first - see this file's header.
126
126
  const id = input.id ?? newSubagentRunId();
127
127
  const started = { id, conversationId, agentId, task, dispatchedBy, parentRunId: input.parentRunId, depth: input.depth ?? 0, loopId: input.loopId, role: input.role, originMessageId: input.originMessageId, originCallRow: input.originCallRow, status: "running", startedAt: new Date().toISOString(), sessionId: id };
128
- await this.hooks.store.beginSubagentRun(started);
128
+ // Own cancellation before the first persistence await. A conversation stop must also reach a
129
+ // human dispatch whose initial row is still being written, before a supervisor/child exists.
130
+ const controller = new AbortController();
131
+ this.controllers.set(id, { controller, conversationId });
132
+ const onCallerAbort = () => controller.abort();
133
+ input.signal?.addEventListener("abort", onCallerAbort, { once: true });
134
+ if (input.signal?.aborted)
135
+ controller.abort();
136
+ try {
137
+ await this.hooks.store.beginSubagentRun(started);
138
+ }
139
+ catch (error) {
140
+ input.signal?.removeEventListener("abort", onCallerAbort);
141
+ this.controllers.delete(id);
142
+ throw error;
143
+ }
129
144
  // The recorder's own copy, held separately from the store's. Mutating the object `beginSubagentRun`
130
145
  // was handed would reach into the store's state behind its back, and every snapshot it hands out
131
146
  // would then move under its reader.
@@ -219,13 +234,9 @@ export class SubagentRunRecorder {
219
234
  * run happened.
220
235
  */
221
236
  const elapsedMs = () => Math.max(0, Date.now() - new Date(started.startedAt).getTime());
222
- // One controller for both abort sources: the caller's signal and `abortAll` are the same button from
223
- // the child's point of view, and `run()` takes exactly one.
224
- const controller = new AbortController();
225
- this.controllers.set(id, { controller, conversationId });
226
- const onCallerAbort = () => controller.abort();
227
- input.signal?.addEventListener("abort", onCallerAbort, { once: true });
228
237
  try {
238
+ if (controller.signal.aborted)
239
+ throw new SubagentCancelled(id);
229
240
  const result = await this.supervisor.run({
230
241
  id,
231
242
  conversationId,
@@ -265,6 +265,13 @@ export class SubagentSupervisor {
265
265
  args.push("--provider", chosen.provider, "--model", chosen.modelId);
266
266
  if (agent.thinkingLevel)
267
267
  args.push("--thinking", agent.thinkingLevel);
268
+ // Cancellation may arrive during filesystem/config/model preparation, before the child or its
269
+ // abort listener exists. Refuse the spawn instead of starting work after the user pressed stop.
270
+ if (signal?.aborted) {
271
+ if (memoryToken)
272
+ this.memory?.release(memoryToken);
273
+ throw new SubagentCancelled(id);
274
+ }
268
275
  // npm exposes global CLIs as .cmd shims on Windows; Node requires a shell to launch those shims.
269
276
  const child = spawn(shellQuote(this.pi.command), args, { cwd: childCwd, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32", ...(memoryToken ? { env: { ...process.env, SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT, SUPER_PI_MEMORY_TOKEN: memoryToken, SUPER_PI_MEMORY_READ_ONLY: "1" } } : {}) });
270
277
  if (memoryToken)
@@ -156,8 +156,15 @@ export class ToolRunner {
156
156
  // -I ignores user Python configuration and user site packages. No shell means params never become code.
157
157
  const child = spawn(python.path, ["-I", "-u", input.script], { cwd: input.cwd, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TEMP: process.env.TEMP, TMP: process.env.TMP, PYTHONIOENCODING: "utf-8", PYTHONNOUSERSITE: "1", SUPER_PI_TOOL_ID: input.toolId } });
158
158
  active.child = child;
159
- child.stdout.on("data", (data) => { active.stdout = clipped(`${active.stdout}${data.toString("utf8")}`); });
160
- child.stderr.on("data", (data) => logLine(active, data.toString("utf8")));
159
+ // A PIPE CARRIES BYTES, AND A READ BOUNDARY CAN LAND INSIDE A CHARACTER. A Chinese character is
160
+ // three bytes, so decoding one chunk at a time turns every character that straddles a boundary
161
+ // into U+FFFD - the same output comes out damaged on one machine and whole on another. The
162
+ // stream decodes instead, and Node keeps an incomplete character until the rest of its bytes
163
+ // arrive.
164
+ child.stdout.setEncoding("utf8");
165
+ child.stderr.setEncoding("utf8");
166
+ child.stdout.on("data", (text) => { active.stdout = clipped(`${active.stdout}${text}`); });
167
+ child.stderr.on("data", (text) => logLine(active, text));
161
168
  child.once("error", (error) => this.finish(id, "failed", error.message));
162
169
  child.once("close", (code) => {
163
170
  if (active.run.status !== "running")
@@ -210,8 +217,10 @@ function runCapture(python, args, timeoutMs, onOutput) {
210
217
  }
211
218
  catch { } resolve({ ok: false, code: null, stdout, error: `timed out after ${timeoutMs}ms` }); }, timeoutMs);
212
219
  timer.unref();
213
- child.stdout.on("data", (data) => { const text = data.toString("utf8"); stdout += text; onOutput?.(text); });
214
- child.stderr.on("data", (data) => { const text = data.toString("utf8"); stderr += text; onOutput?.(text); });
220
+ child.stdout.setEncoding("utf8");
221
+ child.stderr.setEncoding("utf8");
222
+ child.stdout.on("data", (text) => { stdout += text; onOutput?.(text); });
223
+ child.stderr.on("data", (text) => { stderr += text; onOutput?.(text); });
215
224
  child.once("error", (error) => { if (settled)
216
225
  return; settled = true; clearTimeout(timer); resolve({ ok: false, code: null, stdout, error: error.message }); });
217
226
  child.once("close", (code) => { if (settled)
@@ -464,6 +464,10 @@ export type Conversation = {
464
464
  status: RunStatus;
465
465
  messages: Message[];
466
466
  archivedAt?: string;
467
+ /** Transport projection only: false means messages were omitted, rather than an empty transcript. */
468
+ messagesLoaded?: boolean;
469
+ /** Pending question summary, retained when a focused snapshot omits this conversation's messages. */
470
+ hasPendingQuestion?: boolean;
467
471
  /** Pi's own resolved transcript path, observed from this conversation's live worker. It is only a
468
472
  * locator: revert still verifies the file header and every retained boundary before writing. */
469
473
  piSessionFile?: string;
@@ -484,6 +488,21 @@ export type Conversation = {
484
488
  * would answer "what happened" with something the reader has to go and decode.
485
489
  */
486
490
  error?: string;
491
+ /**
492
+ * WHY THIS CONVERSATION IS NOT ON THE MODEL THE USER SET FOR IT - in words a person reads.
493
+ *
494
+ * A default model can stop being usable between one session and the next: Pi may drop the model
495
+ * from its list, or the workspace's own model rules may forbid it. The conversation then starts on
496
+ * Pi's own default, which is the right behaviour - but doing it SILENTLY is not: the user set a
497
+ * default, they set a rule, and nobody told them the two contradicted each other (TEST-062).
498
+ *
499
+ * It lives on the conversation rather than on the workspace because that is where the decision
500
+ * was made: a default can be usable for one workspace and unusable for another, and two
501
+ * conversations of the same workspace can disagree if the rules changed in between. It is set
502
+ * ONCE, at birth, and never rewritten afterwards - it describes how this conversation came to be,
503
+ * not what is true right now.
504
+ */
505
+ modelNotice?: string;
487
506
  /** Absent means "Pi's own default", which is why a conversation created before the picker existed still works. */
488
507
  model?: ModelSelection;
489
508
  thinkingLevel?: string;
@@ -1194,6 +1213,14 @@ export type ClientCommandBody =
1194
1213
  type: "stream.configure";
1195
1214
  coalesce: boolean;
1196
1215
  }
1216
+ /** Selects this socket's transcript; omitted conversationId requests sidebar summaries only. */
1217
+ | {
1218
+ type: "files.index.subscribe";
1219
+ workspaceId?: string;
1220
+ } | {
1221
+ type: "state.subscribe";
1222
+ conversationId?: string;
1223
+ }
1197
1224
  /** Asks for the agent catalog read from disk. Answered by `agent.catalog`. */
1198
1225
  | {
1199
1226
  type: "agent.catalog.fetch";
@@ -4,6 +4,25 @@ import type { AppState, ServerEvent } from "./types.js";
4
4
  import type { EventBroadcaster } from "./server-context.js";
5
5
  import type { CommandHandler } from "./command-handler.js";
6
6
  import type { CatalogService } from "./application/catalog-service.js";
7
+ /**
8
+ * A WebSocket is a live transport, not an unbounded queue. `ws.send()` only
9
+ * copies bytes into the socket's userland buffer; a browser that is frozen or
10
+ * a network peer that has disappeared can therefore make the buffer grow
11
+ * without throwing. Once this threshold is crossed we drop that connection
12
+ * and let the normal close path clean up its resources.
13
+ */
14
+ export declare const MAX_WS_BUFFERED_BYTES: number;
15
+ /** Maximum size of one inbound WebSocket message. Attachments are uploaded
16
+ * over HTTP and commands contain references, so multi-megabyte WS frames are
17
+ * almost certainly malformed or abusive. */
18
+ export declare const MAX_WS_PAYLOAD_BYTES: number;
19
+ /**
20
+ * Send one frame without allowing a race with close/error to escape as an
21
+ * uncaught exception. The `ws` package can throw synchronously after the
22
+ * readyState check (for example when a close happens between the two calls),
23
+ * and an unhandled `error` event can otherwise terminate the Node process.
24
+ */
25
+ export declare function safeSend(socket: WebSocket, payload: string): boolean;
7
26
  /**
8
27
  * Concrete implementation of EventBroadcaster delivering server events
9
28
  * across active WebSocket connections.
@@ -11,21 +30,31 @@ import type { CatalogService } from "./application/catalog-service.js";
11
30
  export declare class WsBroadcaster implements EventBroadcaster {
12
31
  private readonly getState;
13
32
  private readonly sockets;
33
+ private readonly scopes;
34
+ private readonly fileIndexScopes;
14
35
  private readonly coalescedSockets;
15
36
  private readonly pendingStreams;
16
- private cachedState?;
17
- private cachedStatePayload?;
18
37
  private stateFlushScheduled;
19
- constructor(getState: () => AppState);
20
- register(socket: WebSocket): void;
38
+ constructor(getState: (scope?: {
39
+ conversationId?: string;
40
+ }) => AppState);
41
+ register(socket: WebSocket, scope?: {
42
+ conversationId?: string;
43
+ }): void;
21
44
  unregister(socket: WebSocket): void;
45
+ subscribe(socket: WebSocket, conversationId?: string): void;
46
+ sendState(socket: WebSocket): void;
47
+ subscribeFileIndex(socket: WebSocket, workspaceId?: string): void;
48
+ private receives;
22
49
  configureStreaming(socket: WebSocket, coalesce: boolean): void;
23
50
  private flushStreaming;
24
51
  private enqueueStreaming;
25
52
  getSockets(): Set<WebSocket>;
26
53
  broadcast(event: ServerEvent): void;
27
54
  broadcastState(): void;
28
- getStateSnapshot(): AppState;
55
+ getStateSnapshot(scope?: {
56
+ conversationId?: string;
57
+ }): AppState;
29
58
  }
30
59
  export interface WsServerOptions {
31
60
  httpServer: NodeHttpServer;