@evandu/super-pi 0.1.132 → 0.1.135

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 (69) hide show
  1. package/dist/.super-pi-check-inputs.json +4 -0
  2. package/dist/client/.super-pi-client-sources.json +8 -8
  3. package/dist/client/assets/{CodeMirrorEditor-ktyL2PcX.js → CodeMirrorEditor-D6awRUZO.js} +2 -2
  4. package/dist/client/assets/{dist--igDAzRS.js → dist-1jSyapdW.js} +1 -1
  5. package/dist/client/assets/{dist-CPLCQDqy.js → dist-BGgidsaF.js} +1 -1
  6. package/dist/client/assets/{dist-DNZMXYNq.js → dist-BYMYC_2I.js} +1 -1
  7. package/dist/client/assets/{dist-vsMBLoxV.js → dist-BkVaPA_q.js} +1 -1
  8. package/dist/client/assets/{dist-B8OZ-n_C.js → dist-BoeM5xW7.js} +1 -1
  9. package/dist/client/assets/{dist-D_Zdnhm8.js → dist-C46474ys.js} +1 -1
  10. package/dist/client/assets/{dist-Cu75NyQ0.js → dist-CCkdCH6a.js} +1 -1
  11. package/dist/client/assets/{dist-BJi5HmH4.js → dist-CNuKauOq.js} +1 -1
  12. package/dist/client/assets/{dist-BAJCrbnX.js → dist-CubYGjAk.js} +1 -1
  13. package/dist/client/assets/{dist-8pdzh6h1.js → dist-Cwb-Lb5-.js} +1 -1
  14. package/dist/client/assets/{dist-BRjGckmQ.js → dist-D0Iw5XQA.js} +1 -1
  15. package/dist/client/assets/{dist-BjZUYxLd.js → dist-DCiuVvGU.js} +1 -1
  16. package/dist/client/assets/{dist-Hj_dWgPr.js → dist-DHBWUQhM.js} +1 -1
  17. package/dist/client/assets/{dist-CoszOk5k.js → dist-DOwrSiuY.js} +1 -1
  18. package/dist/client/assets/{dist-Bg9zLfex.js → dist-DeZsNbFx.js} +1 -1
  19. package/dist/client/assets/{dist-C6iAHGvb.js → dist-Dk3e6RfY.js} +1 -1
  20. package/dist/client/assets/{dist-Bv6P2Rcp.js → dist-DoDmoWTn.js} +1 -1
  21. package/dist/client/assets/{dist-CgmISAMr.js → dist-WkVUFX28.js} +1 -1
  22. package/dist/client/assets/{dist-DvHghuGd.js → dist-lZUWPQbT.js} +1 -1
  23. package/dist/client/assets/{dist-Zhdkfvng.js → dist-mPR8uuzk.js} +1 -1
  24. package/dist/client/assets/{dist-60usx__o.js → dist-spB2Q2CG.js} +1 -1
  25. package/dist/client/assets/{dist-CMscy2Nn.js → dist-yTTCht0J.js} +1 -1
  26. package/dist/client/assets/index-Dyta-HkQ.js +100 -0
  27. package/dist/client/assets/{index-D3MEG8uH.css → index-V93Kzott.css} +1 -1
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/active-memory.d.ts +1 -0
  30. package/dist/server/active-memory.js +6 -2
  31. package/dist/server/agents.js +21 -1
  32. package/dist/server/application/feishu-service.d.ts +57 -10
  33. package/dist/server/application/feishu-service.js +118 -24
  34. package/dist/server/application/run-service.d.ts +4 -0
  35. package/dist/server/application/run-service.js +123 -88
  36. package/dist/server/application/workspace-service.d.ts +8 -4
  37. package/dist/server/application/workspace-service.js +11 -10
  38. package/dist/server/failure-text.d.ts +18 -0
  39. package/dist/server/failure-text.js +44 -0
  40. package/dist/server/feishu-cli.d.ts +18 -2
  41. package/dist/server/feishu-cli.js +46 -12
  42. package/dist/server/feishu-config.d.ts +68 -0
  43. package/dist/server/feishu-config.js +84 -0
  44. package/dist/server/feishu.d.ts +187 -0
  45. package/dist/server/feishu.js +695 -0
  46. package/dist/server/http-server.js +36 -5
  47. package/dist/server/index.js +5 -2
  48. package/dist/server/knowledge-embedding-worker.d.ts +1 -0
  49. package/dist/server/knowledge-embedding-worker.js +27 -0
  50. package/dist/server/knowledge-vectors.d.ts +5 -3
  51. package/dist/server/knowledge-vectors.js +49 -47
  52. package/dist/server/knowledge.d.ts +1 -0
  53. package/dist/server/knowledge.js +6 -2
  54. package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
  55. package/dist/server/persistence/sqlite-state-repository.js +55 -1
  56. package/dist/server/pi-session-events.js +10 -2
  57. package/dist/server/pi-session-pool.d.ts +33 -1
  58. package/dist/server/pi-session-pool.js +20 -2
  59. package/dist/server/providers.d.ts +13 -0
  60. package/dist/server/providers.js +27 -2
  61. package/dist/server/runtime.d.ts +26 -1
  62. package/dist/server/runtime.js +98 -9
  63. package/dist/server/store.d.ts +61 -0
  64. package/dist/server/store.js +112 -14
  65. package/dist/server/types.d.ts +10 -0
  66. package/dist/server/ws-server.d.ts +3 -0
  67. package/dist/server/ws-server.js +35 -6
  68. package/package.json +25 -2
  69. package/dist/client/assets/index-ChyemF2I.js +0 -100
@@ -164,7 +164,11 @@ export interface AnswerInput {
164
164
  onToolPreparing?(call: ToolCallPreparing): void;
165
165
  /** Called while the primary model is emitting reasoning, so the UI can show live progress. A call means
166
166
  * the model is STILL THINKING - there is no separate flag, because Pi does not send one. */
167
- onThinking?(delta: string): void;
167
+ /** Called while the primary model is emitting reasoning, so the UI can show live progress. A call means
168
+ * the model is STILL THINKING - there is no separate flag, because Pi does not send one.
169
+ * The handler may be async: the reasoning signal is the same "the model is producing" signal as
170
+ * a text delta, and the caller waits for it so the state it writes is ordered with the delta. */
171
+ onThinking?(delta: string): void | Promise<void>;
168
172
  /** Called when the reasoning phase ends - the only signal that it has. */
169
173
  onThinkingEnd?(): void;
170
174
  }
@@ -560,6 +564,12 @@ export declare class PiRpcRuntime implements AgentRuntime {
560
564
  private readonly options;
561
565
  private memory?;
562
566
  setMemory(memory: ActiveMemory): void;
567
+ /**
568
+ * The memory and knowledge credentials minted for each LIVE session, so they can be released when
569
+ * the session goes. A credential whose session is gone must not keep working - that is exactly the
570
+ * shape of a secret with no owner.
571
+ */
572
+ private readonly sessionExtensionTokens;
563
573
  private knowledge?;
564
574
  setKnowledge(knowledge: KnowledgeBase): void;
565
575
  private readonly runs;
@@ -841,6 +851,21 @@ export declare class PiRpcRuntime implements AgentRuntime {
841
851
  * The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
842
852
  * own address, so it is the same string on every turn and is read once from the constructor's options.
843
853
  */
854
+ /**
855
+ * THE MEMORY AND KNOWLEDGE CREDENTIALS FOR A NEW SESSION.
856
+ *
857
+ * Both extensions read their environment ONCE, when they are loaded, so these are minted where the
858
+ * session is born - the same place, and for the same reason, as the session's capability. The
859
+ * one-shot path has always minted them (per turn); the session path minted none, so the memory and
860
+ * knowledge extensions returned before registering a tool or injecting a prompt, and both features
861
+ * were missing in production - the SDK session path is the DEFAULT backend, and only the test
862
+ * scripts take the one-shot path (ledger TEST-039).
863
+ *
864
+ * The endpoints are published as global environment variables (index.ts), and they are repeated
865
+ * here so the extension's contract is stated in one place: an endpoint and a credential travel
866
+ * together, and the credential is what proves the caller.
867
+ */
868
+ private mintSessionExtensionTokens;
844
869
  /**
845
870
  * The environment Super Pi's extensions read when they are loaded.
846
871
  *
@@ -289,6 +289,12 @@ export class PiRpcRuntime {
289
289
  options;
290
290
  memory;
291
291
  setMemory(memory) { this.memory = memory; }
292
+ /**
293
+ * The memory and knowledge credentials minted for each LIVE session, so they can be released when
294
+ * the session goes. A credential whose session is gone must not keep working - that is exactly the
295
+ * shape of a secret with no owner.
296
+ */
297
+ sessionExtensionTokens = new Map();
292
298
  knowledge;
293
299
  setKnowledge(knowledge) { this.knowledge = knowledge; }
294
300
  runs = new Map();
@@ -466,7 +472,16 @@ export class PiRpcRuntime {
466
472
  // ABOUT that session and stop being true with it.
467
473
  this.sessionHosts.delete(conversationId);
468
474
  this.reportedSessionFiles.delete(conversationId);
469
- }, options.sessionFactory ?? createPiSession, (conversationId) => mintCapability(conversationId), () => this.modelEpoch);
475
+ // The session held these credentials too, so they die with it.
476
+ const tokens = this.sessionExtensionTokens.get(conversationId);
477
+ if (tokens) {
478
+ if (tokens.memoryToken)
479
+ this.memory?.release(tokens.memoryToken);
480
+ if (tokens.knowledgeToken)
481
+ this.knowledge?.release(tokens.knowledgeToken);
482
+ this.sessionExtensionTokens.delete(conversationId);
483
+ }
484
+ }, options.sessionFactory ?? createPiSession, (conversationId) => mintCapability(conversationId), () => this.modelEpoch, (conversationId) => this.mintSessionExtensionTokens(conversationId));
470
485
  }
471
486
  /**
472
487
  * The configuration of the session that was built for a conversation, for tests to inspect.
@@ -628,6 +643,37 @@ export class PiRpcRuntime {
628
643
  * The TODO ENDPOINT is the one value here that is NOT per-turn derived from disk: it is this server's
629
644
  * own address, so it is the same string on every turn and is read once from the constructor's options.
630
645
  */
646
+ /**
647
+ * THE MEMORY AND KNOWLEDGE CREDENTIALS FOR A NEW SESSION.
648
+ *
649
+ * Both extensions read their environment ONCE, when they are loaded, so these are minted where the
650
+ * session is born - the same place, and for the same reason, as the session's capability. The
651
+ * one-shot path has always minted them (per turn); the session path minted none, so the memory and
652
+ * knowledge extensions returned before registering a tool or injecting a prompt, and both features
653
+ * were missing in production - the SDK session path is the DEFAULT backend, and only the test
654
+ * scripts take the one-shot path (ledger TEST-039).
655
+ *
656
+ * The endpoints are published as global environment variables (index.ts), and they are repeated
657
+ * here so the extension's contract is stated in one place: an endpoint and a credential travel
658
+ * together, and the credential is what proves the caller.
659
+ */
660
+ async mintSessionExtensionTokens(conversationId) {
661
+ const minted = {};
662
+ const env = {};
663
+ if (this.memory && (await this.memory.configuration()).enabled) {
664
+ minted.memoryToken = this.memory.issue(conversationId);
665
+ env.SUPER_PI_MEMORY_TOKEN = minted.memoryToken;
666
+ env.SUPER_PI_MEMORY_ENDPOINT = process.env.SUPER_PI_MEMORY_ENDPOINT ?? "";
667
+ }
668
+ if (this.knowledge && this.knowledge.enabled()) {
669
+ minted.knowledgeToken = this.knowledge.issue(conversationId);
670
+ env.SUPER_PI_KNOWLEDGE_TOKEN = minted.knowledgeToken;
671
+ env.SUPER_PI_KNOWLEDGE_ENDPOINT = process.env.SUPER_PI_KNOWLEDGE_ENDPOINT ?? "";
672
+ }
673
+ if (minted.memoryToken || minted.knowledgeToken)
674
+ this.sessionExtensionTokens.set(conversationId, minted);
675
+ return env;
676
+ }
631
677
  /**
632
678
  * The environment Super Pi's extensions read when they are loaded.
633
679
  *
@@ -895,7 +941,46 @@ export class PiRpcRuntime {
895
941
  const promptOptions = images?.length
896
942
  ? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) }
897
943
  : { preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) };
944
+ // A STOP THAT ARRIVED WHILE THIS SESSION WAS BEING BUILT MUST NOT SEND THE PROMPT.
945
+ //
946
+ // The window between "the person pressed stop" and "the prompt goes out" is the whole session
947
+ // build: Pi's resource loader, the extensions, the transcript, the model lookup - seconds, on a
948
+ // cold session. The abort listener above already noticed the cancellation, but `session.abort()`
949
+ // on a session that has not started running is a no-op, so nothing stopped the prompt that was
950
+ // about to be sent: the model was asked a question the person had already cancelled, and it
951
+ // answered it - which is why pressing stop looked like it "took effect" only once the reply was
952
+ // over.
953
+ //
954
+ // So the cancellation is checked HERE, at the last moment before the prompt goes out, and a
955
+ // cancelled turn ends with no question asked at all.
956
+ //
957
+ // `turnController.signal.aborted` is the OTHER half of the same fact: the turn's own controller
958
+ // is what `abortTurn()` trips, and it is tripped by the very same cancellation. Both are checked
959
+ // because they are tripped by different readers - one by the person, one by Pi's abort.
960
+ if (signal.aborted || turnController.signal.aborted) {
961
+ queue.push({ kind: "end" });
962
+ return;
963
+ }
898
964
  await session.prompt(prompt, promptOptions);
965
+ // THE CANCELLATION MAY HAVE ARRIVED WHILE PI WAS PREPARING THE PROMPT.
966
+ //
967
+ // `session.prompt()` is not one step: it runs Pi's input handlers (the extensions' hooks -
968
+ // Super Pi's own memory search is one of them), the compaction check and the auth check before
969
+ // the agent's run starts. An abort that lands in that window finds a session that has NOT
970
+ // started running, so Pi's abort has nothing to abort - and the prompt goes on to the model,
971
+ // which answers a question the person cancelled.
972
+ //
973
+ // The run IS started by the time `prompt()` resolves, so the abort is said AGAIN here. The
974
+ // second one is the one that lands.
975
+ if (signal.aborted) {
976
+ // The same 5s backstop `runtime.stop` uses: a wedged session must not hang the turn forever.
977
+ await Promise.race([
978
+ session.abort().catch((error) => {
979
+ console.warn(`PiRpcRuntime: aborting the session of ${conversation.id} after a late cancellation threw: ${error instanceof Error ? error.message : "unknown error"}`);
980
+ }),
981
+ new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000)),
982
+ ]);
983
+ }
899
984
  // `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
900
985
  // been ACCEPTED and the run has been started; the agent is still streaming, still executing
901
986
  // tools, still settling. A turn that ended here would release the reader while the model was
@@ -906,13 +991,10 @@ export class PiRpcRuntime {
906
991
  // pushes the terminal item when Pi settles, and the consumer below reads it. This promise
907
992
  // waits for that signal, and the race with the prompt's own failure is what keeps a turn that
908
993
  // never settles from hanging forever - a hung turn is a conversation that never answers.
909
- await Promise.race([
910
- queue.settledSignal,
911
- // The safety net. A session that was aborted, or a Pi that died mid-turn, may never settle,
912
- // and the turn still has to end - otherwise the conversation is stuck on a turn that will
913
- // never produce anything and the next message can never be sent.
914
- new Promise((resolvePromise) => setTimeout(resolvePromise, 5_000)),
915
- ]);
994
+ // `prompt()` only means Pi accepted the request. Do not end this generator on a wall-clock
995
+ // timeout: the session may still be streaming or executing a tool, and ending here drops
996
+ // subsequent events while falsely allowing the next prompt onto a busy session.
997
+ await queue.settledSignal;
916
998
  traceLatency(conversation.id, "session.settled", latencyStartedAt);
917
999
  // THE REVERT BOUNDARY, recorded at the same moment the one-shot path records it: the turn has
918
1000
  // settled, so every entry this turn wrote - the user message included - is in the transcript.
@@ -1144,7 +1226,7 @@ export class PiRpcRuntime {
1144
1226
  if (segment.kind === "text")
1145
1227
  yield segment.text;
1146
1228
  else if (segment.kind === "thinking")
1147
- input.onThinking?.(segment.text);
1229
+ await input.onThinking?.(segment.text);
1148
1230
  else
1149
1231
  input.onThinkingEnd?.();
1150
1232
  }
@@ -1394,6 +1476,13 @@ export class PiRpcRuntime {
1394
1476
  const imagePayloads = this.options.uploadsDir && images?.length
1395
1477
  ? await loadConversationImages(this.options.uploadsDir, conversation.id, images)
1396
1478
  : undefined;
1479
+ // A STOP THAT ARRIVED WHILE THE CHILD WAS STARTING MUST NOT SEND THE PROMPT.
1480
+ //
1481
+ // The same gap the session path has, and the same reason: `abort` above writes an `abort`
1482
+ // command to a child that has not been given anything yet, so it does nothing - and the prompt
1483
+ // went out anyway, and the model answered a question the person had already cancelled.
1484
+ if (signal.aborted)
1485
+ return;
1397
1486
  channel.write({ id: "prompt", type: "prompt", message: prompt, ...(imagePayloads?.length ? { images: imagePayloads } : {}) });
1398
1487
  traceLatency(conversation.id, "prompt.sent", latencyStartedAt);
1399
1488
  let settled = false;
@@ -11,6 +11,16 @@ import type { AppState, Conversation, Message, MessageDelivery, MessageImage, Mo
11
11
  * disk, or a failed write would leave a conversation whose model context is silently empty. The runtime is
12
12
  * therefore handed the id and the store is handed the same one back. See `ForkRequest.forkedConversationId`. */
13
13
  export declare function newConversationId(): string;
14
+ /**
15
+ * ONE FOLDER, ONE KEY.
16
+ *
17
+ * The same directory reaches the app spelled several ways - `F:\code\app` and `F:/code/app/` and
18
+ * `f:\code\app` are one folder - and two workspaces for one folder are one project split in half.
19
+ * The key folds the separators and the trailing slash away, and folds case away only where the
20
+ * filesystem folds it too: Windows and macOS treat `F:\Code` and `f:\code` as one directory, Linux
21
+ * does not, and pretending otherwise would merge two real projects into one.
22
+ */
23
+ export declare function workspacePathKey(path: string): string;
14
24
  /**
15
25
  * How many SETTLED sub-agent runs `state.json` keeps, so the file cannot grow without bound.
16
26
  *
@@ -101,6 +111,15 @@ export declare class Store {
101
111
  setDefaultModel(model?: ModelSelection): Promise<void>;
102
112
  setTerminalShell(shell?: import("./types.js").TerminalShell): Promise<void>;
103
113
  snapshot(): AppState;
114
+ /** Cheap scalar/entity reads for hot paths. Never expose the live mutable state object. */
115
+ hasWorkspace(workspaceId: string): boolean;
116
+ conversationStatus(conversationId: string): Conversation["status"] | undefined;
117
+ question(conversationId: string, messageId: string, index: number): ToolCallQuestion | undefined;
118
+ pendingQuestion(conversationId: string): {
119
+ messageId: string;
120
+ index: number;
121
+ question: ToolCallQuestion;
122
+ } | undefined;
104
123
  /**
105
124
  * Whether a conversation already holds a message with this id.
106
125
  *
@@ -126,6 +145,17 @@ export declare class Store {
126
145
  conversation: Conversation;
127
146
  workspace: Workspace;
128
147
  } | undefined;
148
+ /**
149
+ * THE PHYSICAL DIRECTORY IS THE WORKSPACE'S IDENTITY.
150
+ *
151
+ * A workspace is a folder, not a row in a table: two workspaces pointing at the same directory are
152
+ * the same project, and the app used to mint a fresh UUID for each one. That is what broke
153
+ * re-adding a workspace - the new id matched none of the conversations that belonged to that
154
+ * directory, so the history was unreachable (and, before the soft unmount, actually deleted).
155
+ *
156
+ * So the same folder always means the same workspace id, and the id is what the conversations
157
+ * point at.
158
+ */
129
159
  createWorkspace(name: string, path?: string): Promise<Workspace>;
130
160
  renameWorkspace(workspaceId: string, name: string): Promise<Workspace>;
131
161
  /**
@@ -151,7 +181,38 @@ export declare class Store {
151
181
  thinkingLevel?: string;
152
182
  agentId?: string | null;
153
183
  }): Promise<Workspace>;
184
+ /**
185
+ * TAKES A WORKSPACE OUT OF THE SIDEBAR. IT DOES NOT DELETE ANYTHING.
186
+ *
187
+ * This used to cascade: the workspace row AND every conversation that pointed at it were erased
188
+ * from the state, in one persist. That made the most natural thing a user could do - remove a
189
+ * workspace to tidy the sidebar, then add the same folder back - silently destroy the history of
190
+ * that project, and there was no way to get it back.
191
+ *
192
+ * So the removal is a SOFT UNMOUNT, the same shape as a session's archive: the workspace row
193
+ * stays (with `closedAt` on it), the conversations stay, the uploads stay, and nothing is
194
+ * broadcast for either (see `clientState`). Adding the same folder again re-opens the very same
195
+ * workspace - same id, same conversations - which is the whole point of the physical path being
196
+ * the workspace's identity.
197
+ *
198
+ * The returned list is the conversations that were STOPPED, not deleted: a turn that was still
199
+ * running under a workspace that just left the sidebar cannot keep burning a model session
200
+ * behind the user's back, and a pending question would otherwise wait for an answer that the
201
+ * sidebar can no longer reach.
202
+ */
154
203
  deleteWorkspace(workspaceId: string): Promise<string[]>;
204
+ /**
205
+ * THE STATE THE CLIENT IS ALLOWED TO SEE.
206
+ *
207
+ * `snapshot()` is the truth of what the store HOLDS; this is the truth of what is ON SCREEN. The
208
+ * difference is the closed workspaces: one that was removed from the sidebar is still in the store
209
+ * - that is exactly why re-adding the folder brings its history back - but it is not part of the
210
+ * app any more, so neither it nor its conversations are broadcast.
211
+ *
212
+ * Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
213
+ * sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
214
+ */
215
+ clientState(): AppState;
155
216
  createConversation(workspaceId: string, configuration?: {
156
217
  agentId?: string;
157
218
  model?: ModelSelection | null;
@@ -11,6 +11,19 @@ function id(prefix) { return `${prefix}_${crypto.randomUUID()}`; }
11
11
  * disk, or a failed write would leave a conversation whose model context is silently empty. The runtime is
12
12
  * therefore handed the id and the store is handed the same one back. See `ForkRequest.forkedConversationId`. */
13
13
  export function newConversationId() { return id("conversation"); }
14
+ /**
15
+ * ONE FOLDER, ONE KEY.
16
+ *
17
+ * The same directory reaches the app spelled several ways - `F:\code\app` and `F:/code/app/` and
18
+ * `f:\code\app` are one folder - and two workspaces for one folder are one project split in half.
19
+ * The key folds the separators and the trailing slash away, and folds case away only where the
20
+ * filesystem folds it too: Windows and macOS treat `F:\Code` and `f:\code` as one directory, Linux
21
+ * does not, and pretending otherwise would merge two real projects into one.
22
+ */
23
+ export function workspacePathKey(path) {
24
+ const folded = path.trim().replace(/\\/g, "/").replace(/\/+$/, "");
25
+ return process.platform === "linux" ? folded : folded.toLowerCase();
26
+ }
14
27
  function now() { return new Date().toISOString(); }
15
28
  const DEFAULT_CONVERSATION_TITLE = "\u65b0\u4f1a\u8bdd";
16
29
  /** Whether a value survives a `new Date()` round-trip. `createdAt` is typed string, but state.json only
@@ -735,6 +748,35 @@ export class Store {
735
748
  console.warn(`[super-pi latency] phase=state.snapshot bytes=${JSON.stringify(value).length} cloneMs=${Math.round(performance.now() - startedAt)}`);
736
749
  return value;
737
750
  }
751
+ /** Cheap scalar/entity reads for hot paths. Never expose the live mutable state object. */
752
+ hasWorkspace(workspaceId) {
753
+ return this.state.workspaces.some((workspace) => workspace.id === workspaceId);
754
+ }
755
+ conversationStatus(conversationId) {
756
+ return this.state.conversations.find((conversation) => conversation.id === conversationId)?.status;
757
+ }
758
+ question(conversationId, messageId, index) {
759
+ const question = this.state.conversations.find((conversation) => conversation.id === conversationId)
760
+ ?.messages.find((message) => message.id === messageId)?.toolCalls?.[index]?.question;
761
+ return question ? structuredClone(question) : undefined;
762
+ }
763
+ pendingQuestion(conversationId) {
764
+ const conversation = this.state.conversations.find((item) => item.id === conversationId);
765
+ if (!conversation)
766
+ return undefined;
767
+ for (let messageIndex = conversation.messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
768
+ const message = conversation.messages[messageIndex];
769
+ if (message.role !== "assistant")
770
+ continue;
771
+ for (let index = (message.toolCalls?.length ?? 0) - 1; index >= 0; index -= 1) {
772
+ const question = message.toolCalls?.[index]?.question;
773
+ if (message.toolCalls?.[index]?.label === "question" && question?.status === "pending") {
774
+ return { messageId: message.id, index, question: structuredClone(question) };
775
+ }
776
+ }
777
+ }
778
+ return undefined;
779
+ }
738
780
  /**
739
781
  * Whether a conversation already holds a message with this id.
740
782
  *
@@ -767,6 +809,17 @@ export class Store {
767
809
  return undefined;
768
810
  return { conversation: structuredClone(conversation), workspace: structuredClone(workspace) };
769
811
  }
812
+ /**
813
+ * THE PHYSICAL DIRECTORY IS THE WORKSPACE'S IDENTITY.
814
+ *
815
+ * A workspace is a folder, not a row in a table: two workspaces pointing at the same directory are
816
+ * the same project, and the app used to mint a fresh UUID for each one. That is what broke
817
+ * re-adding a workspace - the new id matched none of the conversations that belonged to that
818
+ * directory, so the history was unreachable (and, before the soft unmount, actually deleted).
819
+ *
820
+ * So the same folder always means the same workspace id, and the id is what the conversations
821
+ * point at.
822
+ */
770
823
  async createWorkspace(name, path) {
771
824
  const cleanName = name.trim();
772
825
  if (!cleanName || cleanName.length > 80)
@@ -774,6 +827,19 @@ export class Store {
774
827
  const cleanPath = path?.trim() || process.cwd();
775
828
  if (cleanPath.length > 500)
776
829
  throw new Error("工作区路径太长了。");
830
+ // THE SAME FOLDER IS THE SAME WORKSPACE. Adding a directory that is already here re-opens it
831
+ // instead of forking a second one: the id stays, the conversations stay attached to it, and the
832
+ // name is left alone (renaming is an explicit action elsewhere, and silently renaming a
833
+ // workspace because someone typed a name into the add dialog would be its own surprise).
834
+ const existing = this.state.workspaces.find((item) => workspacePathKey(item.path) === workspacePathKey(cleanPath));
835
+ if (existing) {
836
+ if (existing.closedAt) {
837
+ delete existing.closedAt;
838
+ existing.updatedAt = now();
839
+ await this.persist();
840
+ }
841
+ return structuredClone(existing);
842
+ }
777
843
  const workspace = { id: id("workspace"), name: cleanName, path: cleanPath, updatedAt: now() };
778
844
  this.state.workspaces.push(workspace);
779
845
  await this.persist();
@@ -830,23 +896,55 @@ export class Store {
830
896
  await this.persist();
831
897
  return structuredClone(workspace);
832
898
  }
899
+ /**
900
+ * TAKES A WORKSPACE OUT OF THE SIDEBAR. IT DOES NOT DELETE ANYTHING.
901
+ *
902
+ * This used to cascade: the workspace row AND every conversation that pointed at it were erased
903
+ * from the state, in one persist. That made the most natural thing a user could do - remove a
904
+ * workspace to tidy the sidebar, then add the same folder back - silently destroy the history of
905
+ * that project, and there was no way to get it back.
906
+ *
907
+ * So the removal is a SOFT UNMOUNT, the same shape as a session's archive: the workspace row
908
+ * stays (with `closedAt` on it), the conversations stay, the uploads stay, and nothing is
909
+ * broadcast for either (see `clientState`). Adding the same folder again re-opens the very same
910
+ * workspace - same id, same conversations - which is the whole point of the physical path being
911
+ * the workspace's identity.
912
+ *
913
+ * The returned list is the conversations that were STOPPED, not deleted: a turn that was still
914
+ * running under a workspace that just left the sidebar cannot keep burning a model session
915
+ * behind the user's back, and a pending question would otherwise wait for an answer that the
916
+ * sidebar can no longer reach.
917
+ */
833
918
  async deleteWorkspace(workspaceId) {
834
- if (!this.state.workspaces.some((item) => item.id === workspaceId))
919
+ const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
920
+ if (!workspace)
835
921
  throw new Error("找不到这个工作区。");
836
- // Deleting the LAST workspace is allowed: a fresh store starts with zero (see `openRepository`), so
837
- // "at least one" was an artifact of the old first-run seed, and the client's zero-workspace empty
838
- // state is the onboarding path back in.
839
- // Cascade: workspaces and conversations live in one state.json, so this single atomic persist removes
840
- // both together. A waiting message needs no sweep of its own any more - it is a message inside one of
841
- // the conversations being removed, so it goes with its container, which is the whole reason it is a
842
- // better shape than an entry keyed by conversation id in a separate array: that array needed this
843
- // cascade to avoid naming a conversation that no longer exists, and could not have been trusted to
844
- // get it right on its own.
845
- const doomed = new Set(this.state.conversations.filter((item) => item.workspaceId === workspaceId).map((item) => item.id));
846
- this.state.workspaces = this.state.workspaces.filter((item) => item.id !== workspaceId);
847
- this.state.conversations = this.state.conversations.filter((item) => item.workspaceId !== workspaceId);
922
+ if (workspace.closedAt)
923
+ return [];
924
+ workspace.closedAt = now();
925
+ workspace.updatedAt = workspace.closedAt;
848
926
  await this.persist();
849
- return [...doomed];
927
+ return [];
928
+ }
929
+ /**
930
+ * THE STATE THE CLIENT IS ALLOWED TO SEE.
931
+ *
932
+ * `snapshot()` is the truth of what the store HOLDS; this is the truth of what is ON SCREEN. The
933
+ * difference is the closed workspaces: one that was removed from the sidebar is still in the store
934
+ * - that is exactly why re-adding the folder brings its history back - but it is not part of the
935
+ * app any more, so neither it nor its conversations are broadcast.
936
+ *
937
+ * Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
938
+ * sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
939
+ */
940
+ clientState() {
941
+ const state = this.snapshot();
942
+ const closed = new Set(state.workspaces.filter((item) => item.closedAt).map((item) => item.id));
943
+ if (!closed.size)
944
+ return state;
945
+ state.workspaces = state.workspaces.filter((item) => !closed.has(item.id));
946
+ state.conversations = state.conversations.filter((item) => !closed.has(item.workspaceId));
947
+ return state;
850
948
  }
851
949
  async createConversation(workspaceId, configuration) {
852
950
  const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
@@ -586,6 +586,12 @@ export type AgentCatalog = {
586
586
  * the conversation's own field, an existing conversation is therefore immune to later workspace
587
587
  * edits, and deleting the workspace default cannot reach back into sessions already born.
588
588
  */
589
+ /**
590
+ * `closedAt` marks a workspace that was REMOVED FROM THE SIDEBAR but is not gone: its conversations
591
+ * are still on disk and still belong to it. The removal is a soft unmount, exactly like a session's
592
+ * `archivedAt` - a state marker, never a deletion - and the marker is what lets the same physical
593
+ * directory be added again later and come back with its history instead of with an empty list.
594
+ */
589
595
  export type Workspace = {
590
596
  id: string;
591
597
  name: string;
@@ -594,6 +600,7 @@ export type Workspace = {
594
600
  defaultModel?: ModelSelection;
595
601
  defaultThinkingLevel?: string;
596
602
  defaultAgentId?: string;
603
+ closedAt?: string;
597
604
  };
598
605
  /**
599
606
  * Usage accumulated across one sub-agent run. `turns` is the number of finished assistant messages,
@@ -1469,6 +1476,9 @@ export type ChangedFile = {
1469
1476
  at: number;
1470
1477
  };
1471
1478
  export type ServerEvent = {
1479
+ type: "run.stopping";
1480
+ conversationId: string;
1481
+ } | {
1472
1482
  type: "tools.updated";
1473
1483
  tools: MountedToolSummary[];
1474
1484
  }
@@ -13,6 +13,9 @@ export declare class WsBroadcaster implements EventBroadcaster {
13
13
  private readonly sockets;
14
14
  private readonly coalescedSockets;
15
15
  private readonly pendingStreams;
16
+ private cachedState?;
17
+ private cachedStatePayload?;
18
+ private stateFlushScheduled;
16
19
  constructor(getState: () => AppState);
17
20
  register(socket: WebSocket): void;
18
21
  unregister(socket: WebSocket): void;
@@ -1,5 +1,6 @@
1
1
  import { WebSocket, WebSocketServer } from "ws";
2
2
  import { isAuthorized } from "./http-server.js";
3
+ import { describeThrown, describeThrownWithStack } from "./failure-text.js";
3
4
  /**
4
5
  * Concrete implementation of EventBroadcaster delivering server events
5
6
  * across active WebSocket connections.
@@ -9,6 +10,9 @@ export class WsBroadcaster {
9
10
  sockets = new Set();
10
11
  coalescedSockets = new Set();
11
12
  pendingStreams = new Map();
13
+ cachedState;
14
+ cachedStatePayload;
15
+ stateFlushScheduled = false;
12
16
  constructor(getState) {
13
17
  this.getState = getState;
14
18
  }
@@ -90,7 +94,23 @@ export class WsBroadcaster {
90
94
  }
91
95
  }
92
96
  broadcastState() {
93
- this.broadcast({ type: "state", state: this.getState() });
97
+ if (this.stateFlushScheduled)
98
+ return;
99
+ this.stateFlushScheduled = true;
100
+ queueMicrotask(() => {
101
+ this.stateFlushScheduled = false;
102
+ this.cachedState = this.getState();
103
+ this.cachedStatePayload = JSON.stringify({ type: "state", state: this.cachedState });
104
+ const payload = this.cachedStatePayload;
105
+ for (const socket of this.sockets) {
106
+ if (socket.readyState !== WebSocket.OPEN)
107
+ continue;
108
+ this.flushStreaming(socket);
109
+ socket.send(payload);
110
+ }
111
+ this.cachedState = undefined;
112
+ this.cachedStatePayload = undefined;
113
+ });
94
114
  }
95
115
  getStateSnapshot() {
96
116
  return this.getState();
@@ -228,7 +248,8 @@ export class WsServer {
228
248
  parsed = JSON.parse(raw.toString());
229
249
  }
230
250
  catch {
231
- socket.send(JSON.stringify({ type: "error", message: "command failed" }));
251
+ console.error("Super Pi command error: received a frame that is not valid JSON");
252
+ socket.send(JSON.stringify({ type: "error", message: "收到一条无法解析的命令,已忽略。" }));
232
253
  return;
233
254
  }
234
255
  if (parsed.type === "stream.configure") {
@@ -264,13 +285,21 @@ export class WsServer {
264
285
  }
265
286
  }
266
287
  catch (error) {
288
+ // A FAILED COMMAND WRITES ITSELF DOWN BEFORE IT ANSWERS.
289
+ //
290
+ // The server runs detached with its streams thrown away, so `console.error` here is not
291
+ // decoration: the logger tees it into `<dataDir>/logs/server.log`, which is the only place
292
+ // the stack survives. The browser gets the reason (never a stand-in phrase like
293
+ // `command failed`), because the reader is the one who has to act on it.
294
+ console.error("Super Pi command error:", describeThrownWithStack(error));
295
+ const reason = describeThrown(error);
267
296
  if (command?.operationRequestId) {
268
- socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: false, message: error instanceof Error ? error.message : String(error) }));
297
+ socket.send(JSON.stringify({ type: "operation.result", requestId: command.operationRequestId, ok: false, message: reason }));
269
298
  return;
270
299
  }
271
300
  socket.send(JSON.stringify({
272
301
  type: "error",
273
- message: error instanceof Error ? error.message : "command failed",
302
+ message: reason || "服务端没有说明这次失败的原因。",
274
303
  }));
275
304
  }
276
305
  finally {
@@ -284,11 +313,11 @@ export class WsServer {
284
313
  };
285
314
  if (runIndependently)
286
315
  void execute().catch((error) => {
287
- console.warn(`Super Pi: command dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`);
316
+ console.error("Super Pi command dispatch failed:", describeThrownWithStack(error));
288
317
  });
289
318
  else
290
319
  commandQueue = commandQueue.then(execute).catch((error) => {
291
- console.warn(`Super Pi: command dispatch failed: ${error instanceof Error ? error.message : "unknown error"}`);
320
+ console.error("Super Pi command dispatch failed:", describeThrownWithStack(error));
292
321
  });
293
322
  });
294
323
  });