@evandu/super-pi 0.1.133 → 0.1.136

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 (63) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +6 -6
  3. package/dist/client/assets/{CodeMirrorEditor-CZZfGD0U.js → CodeMirrorEditor-CWN8JIx4.js} +2 -2
  4. package/dist/client/assets/{dist-D4x1XTQb.js → dist-0vYGgCC5.js} +1 -1
  5. package/dist/client/assets/{dist-CFPXj4ws.js → dist-B0vHKLg1.js} +1 -1
  6. package/dist/client/assets/{dist-4Pts9xPO.js → dist-BA6BeFaf.js} +1 -1
  7. package/dist/client/assets/{dist-BtTFCNVQ.js → dist-BQjtlfpY.js} +1 -1
  8. package/dist/client/assets/{dist-CUKaDTyO.js → dist-BSOBu3lQ.js} +1 -1
  9. package/dist/client/assets/{dist-CYD2SqmG.js → dist-BtNKV30Z.js} +1 -1
  10. package/dist/client/assets/{dist-DHqqpxo4.js → dist-CTXz1R9C.js} +1 -1
  11. package/dist/client/assets/{dist-BffE00vI.js → dist-CV9QN-qU.js} +1 -1
  12. package/dist/client/assets/{dist-azDN8a65.js → dist-CYLUz40X.js} +1 -1
  13. package/dist/client/assets/{dist-aL2NGji_.js → dist-CwNZuQ4i.js} +1 -1
  14. package/dist/client/assets/{dist-CApAttfj.js → dist-DCuAT0n-.js} +1 -1
  15. package/dist/client/assets/{dist-DOYWHMIq.js → dist-DErmxG4Q.js} +1 -1
  16. package/dist/client/assets/{dist-DQf5nNxa.js → dist-DHTyhm7w.js} +1 -1
  17. package/dist/client/assets/{dist-fYp8Xwrx.js → dist-DMDxxHpj.js} +1 -1
  18. package/dist/client/assets/{dist-B5ruVtUG.js → dist-Dalb5yHq.js} +1 -1
  19. package/dist/client/assets/{dist-0FH-oFco.js → dist-OKarkelj.js} +1 -1
  20. package/dist/client/assets/{dist-DbbBgSwL.js → dist-Qc0xt1y7.js} +1 -1
  21. package/dist/client/assets/{dist-CxsunljU.js → dist-WkcafMsD.js} +1 -1
  22. package/dist/client/assets/{dist-CE5BnwJa.js → dist-ZvUavnTX.js} +1 -1
  23. package/dist/client/assets/{dist-BJinarhP.js → dist-q1T0uV1c.js} +1 -1
  24. package/dist/client/assets/{dist-C2nuBCP4.js → dist-t0oHfWqq.js} +1 -1
  25. package/dist/client/assets/{dist-LFfokYJs.js → dist-yOveRczL.js} +1 -1
  26. package/dist/client/assets/{index-YEN4ysHF.js → index-oDRgSKPX.js} +37 -37
  27. package/dist/client/assets/index-r-L_XCYs.css +1 -0
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/active-memory.d.ts +18 -2
  30. package/dist/server/active-memory.js +73 -8
  31. package/dist/server/application/conversation-service.js +4 -2
  32. package/dist/server/application/run-service.d.ts +4 -0
  33. package/dist/server/application/run-service.js +114 -93
  34. package/dist/server/application/scheduled-task-service.d.ts +7 -0
  35. package/dist/server/application/scheduled-task-service.js +14 -5
  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/files.js +11 -1
  41. package/dist/server/http-server.js +16 -5
  42. package/dist/server/index.js +5 -2
  43. package/dist/server/knowledge-embedding-worker.d.ts +1 -0
  44. package/dist/server/knowledge-embedding-worker.js +27 -0
  45. package/dist/server/knowledge-vectors.d.ts +5 -3
  46. package/dist/server/knowledge-vectors.js +49 -47
  47. package/dist/server/knowledge.d.ts +1 -0
  48. package/dist/server/knowledge.js +6 -2
  49. package/dist/server/model-filter.d.ts +13 -3
  50. package/dist/server/model-filter.js +7 -5
  51. package/dist/server/persistence/sqlite-state-repository.d.ts +0 -1
  52. package/dist/server/persistence/sqlite-state-repository.js +37 -1
  53. package/dist/server/providers.d.ts +13 -0
  54. package/dist/server/providers.js +27 -2
  55. package/dist/server/runtime.js +46 -0
  56. package/dist/server/store.d.ts +83 -0
  57. package/dist/server/store.js +140 -14
  58. package/dist/server/subagents.js +10 -2
  59. package/dist/server/types.d.ts +10 -0
  60. package/dist/server/ws-server.d.ts +3 -0
  61. package/dist/server/ws-server.js +35 -6
  62. package/package.json +21 -2
  63. package/dist/client/assets/index-D3MEG8uH.css +0 -1
@@ -741,6 +741,7 @@ export async function fetchRemoteModels(input) {
741
741
  candidates.push(`${cleanBase}/v1/models`, `${cleanBase}/models`);
742
742
  }
743
743
  let lastError = "";
744
+ let lastCause;
744
745
  let payload = null;
745
746
  for (const candidate of candidates) {
746
747
  try {
@@ -758,11 +759,35 @@ export async function fetchRemoteModels(input) {
758
759
  }
759
760
  catch (error) {
760
761
  lastError = error instanceof Error ? error.message : String(error);
762
+ lastCause = error;
761
763
  }
762
764
  }
763
765
  if (!payload) {
764
- throw new Error(`无法从远程服务获取模型列表 (${lastError || "请求超时或无法连接"})`);
765
- }
766
+ // THE CAUSE, NOT JUST "fetch failed". `fetch` reports every transport-level problem - a refused
767
+ // connection, a reset, an aborted timeout - as the same one-word "fetch failed", so a user staring
768
+ // at 「无法从远程服务获取模型列表 (fetch failed)」 learns nothing and neither does the log. The
769
+ // cause is where the truth is: "The operation was aborted due to timeout" and "connect
770
+ // ECONNREFUSED" are two different problems with two different fixes.
771
+ const cause = lastCause instanceof Error
772
+ ? (lastCause.cause instanceof Error ? lastCause.cause.message : lastCause.cause instanceof Object && lastCause.cause !== null ? String(lastCause.cause.code ?? "") : undefined)
773
+ : undefined;
774
+ throw new Error(`无法从远程服务获取模型列表 (${lastError || "请求超时或无法连接"}${cause ? `:${cause}` : ""})`);
775
+ }
776
+ return parseRemoteModels(payload);
777
+ }
778
+ /**
779
+ * THE ANSWER, READ.
780
+ *
781
+ * Split out of `fetchRemoteModels` so that the part worth asserting - what a provider's answer MEANS -
782
+ * can be tested without a network in between. A remote provider answers in three shapes (a bare array,
783
+ * `{data: [...]}` for OpenAI, `{models: [...]}` for Ollama), and each entry names itself with `id`,
784
+ * `name` or `model`; what the panel shows next to it - whether the model reasons, whether it takes
785
+ * images - is inferred from the name, because that is all most providers tell us.
786
+ *
787
+ * `fetchRemoteModels` is the transport; this is the reading. Keeping them apart means the reading can
788
+ * be pinned exactly, and a slow machine can never turn a parsing question into a timeout.
789
+ */
790
+ export function parseRemoteModels(payload) {
766
791
  const rawList = Array.isArray(payload)
767
792
  ? payload
768
793
  : Array.isArray(payload.data)
@@ -941,7 +941,46 @@ export class PiRpcRuntime {
941
941
  const promptOptions = images?.length
942
942
  ? { images: images, preflightResult: () => traceLatency(conversation.id, "session.prompt.accepted", latencyStartedAt, promptSize()) }
943
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
+ }
944
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
+ }
945
984
  // `prompt()` RETURNING IS NOT THE TURN ENDING. Pi's `prompt` resolves when the message has
946
985
  // been ACCEPTED and the run has been started; the agent is still streaming, still executing
947
986
  // tools, still settling. A turn that ended here would release the reader while the model was
@@ -1437,6 +1476,13 @@ export class PiRpcRuntime {
1437
1476
  const imagePayloads = this.options.uploadsDir && images?.length
1438
1477
  ? await loadConversationImages(this.options.uploadsDir, conversation.id, images)
1439
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;
1440
1486
  channel.write({ id: "prompt", type: "prompt", message: prompt, ...(imagePayloads?.length ? { images: imagePayloads } : {}) });
1441
1487
  traceLatency(conversation.id, "prompt.sent", latencyStartedAt);
1442
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
  *
@@ -86,6 +96,28 @@ export declare class Store {
86
96
  static open(filePath: string): Promise<Store>;
87
97
  /** Opens an injected repository, retaining domain reconciliation here. */
88
98
  static openRepository(repository: AppStateRepository): Promise<Store>;
99
+ /**
100
+ * THE MODEL FILTER RULES, WITHOUT CLONING THE STATE TO READ THEM.
101
+ *
102
+ * `snapshot()` deep-clones the WHOLE application - every workspace, every conversation, every
103
+ * message of every conversation - and the model filter check needs ONE small array. Reading that
104
+ * array through a full clone is the difference between a few hundred bytes and the entire
105
+ * transcript of the app, on the path that runs every time a message is sent.
106
+ *
107
+ * So the rules get their own narrow reader. It returns a COPY of the array (the caller may keep
108
+ * it, and the store's own state must not change under it), and the individual rules are treated
109
+ * as read-only - which is what the filter does with them.
110
+ */
111
+ modelFilterRules(): import("./types.js").ModelFilterRule[];
112
+ /**
113
+ * THE WORKSPACE A CONVERSATION BELONGS TO, WITHOUT CLONING THE STATE TO FIND ONE ROW.
114
+ *
115
+ * The same argument as the rules above: the filter needs to know WHICH workspace the conversation
116
+ * is in (the rules are per workspace), and the only way to know that today is to clone everything
117
+ * and look. This returns a shallow copy of the one workspace - cheap, and detached enough that a
118
+ * caller cannot reach into the store's state through it.
119
+ */
120
+ workspaceForConversation(conversationId: string): Workspace | undefined;
89
121
  setModelFilterRules(rules: import("./types.js").ModelFilterRule[]): Promise<void>;
90
122
  /**
91
123
  * Sets (or clears) the app-wide default model.
@@ -101,6 +133,15 @@ export declare class Store {
101
133
  setDefaultModel(model?: ModelSelection): Promise<void>;
102
134
  setTerminalShell(shell?: import("./types.js").TerminalShell): Promise<void>;
103
135
  snapshot(): AppState;
136
+ /** Cheap scalar/entity reads for hot paths. Never expose the live mutable state object. */
137
+ hasWorkspace(workspaceId: string): boolean;
138
+ conversationStatus(conversationId: string): Conversation["status"] | undefined;
139
+ question(conversationId: string, messageId: string, index: number): ToolCallQuestion | undefined;
140
+ pendingQuestion(conversationId: string): {
141
+ messageId: string;
142
+ index: number;
143
+ question: ToolCallQuestion;
144
+ } | undefined;
104
145
  /**
105
146
  * Whether a conversation already holds a message with this id.
106
147
  *
@@ -126,6 +167,17 @@ export declare class Store {
126
167
  conversation: Conversation;
127
168
  workspace: Workspace;
128
169
  } | undefined;
170
+ /**
171
+ * THE PHYSICAL DIRECTORY IS THE WORKSPACE'S IDENTITY.
172
+ *
173
+ * A workspace is a folder, not a row in a table: two workspaces pointing at the same directory are
174
+ * the same project, and the app used to mint a fresh UUID for each one. That is what broke
175
+ * re-adding a workspace - the new id matched none of the conversations that belonged to that
176
+ * directory, so the history was unreachable (and, before the soft unmount, actually deleted).
177
+ *
178
+ * So the same folder always means the same workspace id, and the id is what the conversations
179
+ * point at.
180
+ */
129
181
  createWorkspace(name: string, path?: string): Promise<Workspace>;
130
182
  renameWorkspace(workspaceId: string, name: string): Promise<Workspace>;
131
183
  /**
@@ -151,7 +203,38 @@ export declare class Store {
151
203
  thinkingLevel?: string;
152
204
  agentId?: string | null;
153
205
  }): Promise<Workspace>;
206
+ /**
207
+ * TAKES A WORKSPACE OUT OF THE SIDEBAR. IT DOES NOT DELETE ANYTHING.
208
+ *
209
+ * This used to cascade: the workspace row AND every conversation that pointed at it were erased
210
+ * from the state, in one persist. That made the most natural thing a user could do - remove a
211
+ * workspace to tidy the sidebar, then add the same folder back - silently destroy the history of
212
+ * that project, and there was no way to get it back.
213
+ *
214
+ * So the removal is a SOFT UNMOUNT, the same shape as a session's archive: the workspace row
215
+ * stays (with `closedAt` on it), the conversations stay, the uploads stay, and nothing is
216
+ * broadcast for either (see `clientState`). Adding the same folder again re-opens the very same
217
+ * workspace - same id, same conversations - which is the whole point of the physical path being
218
+ * the workspace's identity.
219
+ *
220
+ * The returned list is the conversations that were STOPPED, not deleted: a turn that was still
221
+ * running under a workspace that just left the sidebar cannot keep burning a model session
222
+ * behind the user's back, and a pending question would otherwise wait for an answer that the
223
+ * sidebar can no longer reach.
224
+ */
154
225
  deleteWorkspace(workspaceId: string): Promise<string[]>;
226
+ /**
227
+ * THE STATE THE CLIENT IS ALLOWED TO SEE.
228
+ *
229
+ * `snapshot()` is the truth of what the store HOLDS; this is the truth of what is ON SCREEN. The
230
+ * difference is the closed workspaces: one that was removed from the sidebar is still in the store
231
+ * - that is exactly why re-adding the folder brings its history back - but it is not part of the
232
+ * app any more, so neither it nor its conversations are broadcast.
233
+ *
234
+ * Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
235
+ * sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
236
+ */
237
+ clientState(): AppState;
155
238
  createConversation(workspaceId: string, configuration?: {
156
239
  agentId?: string;
157
240
  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
@@ -676,6 +689,34 @@ export class Store {
676
689
  return new Store(repository, { workspaces: [], conversations: [], subagentRuns: [], backgroundTasks: [] });
677
690
  }
678
691
  }
692
+ /**
693
+ * THE MODEL FILTER RULES, WITHOUT CLONING THE STATE TO READ THEM.
694
+ *
695
+ * `snapshot()` deep-clones the WHOLE application - every workspace, every conversation, every
696
+ * message of every conversation - and the model filter check needs ONE small array. Reading that
697
+ * array through a full clone is the difference between a few hundred bytes and the entire
698
+ * transcript of the app, on the path that runs every time a message is sent.
699
+ *
700
+ * So the rules get their own narrow reader. It returns a COPY of the array (the caller may keep
701
+ * it, and the store's own state must not change under it), and the individual rules are treated
702
+ * as read-only - which is what the filter does with them.
703
+ */
704
+ modelFilterRules() {
705
+ return this.state.modelFilterRules ? [...this.state.modelFilterRules] : [];
706
+ }
707
+ /**
708
+ * THE WORKSPACE A CONVERSATION BELONGS TO, WITHOUT CLONING THE STATE TO FIND ONE ROW.
709
+ *
710
+ * The same argument as the rules above: the filter needs to know WHICH workspace the conversation
711
+ * is in (the rules are per workspace), and the only way to know that today is to clone everything
712
+ * and look. This returns a shallow copy of the one workspace - cheap, and detached enough that a
713
+ * caller cannot reach into the store's state through it.
714
+ */
715
+ workspaceForConversation(conversationId) {
716
+ const conversation = this.state.conversations.find((item) => item.id === conversationId);
717
+ const workspace = this.state.workspaces.find((item) => item.id === conversation?.workspaceId);
718
+ return workspace ? { ...workspace } : undefined;
719
+ }
679
720
  async setModelFilterRules(rules) {
680
721
  if (!Array.isArray(rules) || rules.some(rule => !rule || typeof rule.id !== "string" || typeof rule.workspacePattern !== "string" || typeof rule.modelPattern !== "string" || (rule.label !== undefined && typeof rule.label !== "string")))
681
722
  throw new Error("模型过滤规则格式不合法");
@@ -735,6 +776,35 @@ export class Store {
735
776
  console.warn(`[super-pi latency] phase=state.snapshot bytes=${JSON.stringify(value).length} cloneMs=${Math.round(performance.now() - startedAt)}`);
736
777
  return value;
737
778
  }
779
+ /** Cheap scalar/entity reads for hot paths. Never expose the live mutable state object. */
780
+ hasWorkspace(workspaceId) {
781
+ return this.state.workspaces.some((workspace) => workspace.id === workspaceId);
782
+ }
783
+ conversationStatus(conversationId) {
784
+ return this.state.conversations.find((conversation) => conversation.id === conversationId)?.status;
785
+ }
786
+ question(conversationId, messageId, index) {
787
+ const question = this.state.conversations.find((conversation) => conversation.id === conversationId)
788
+ ?.messages.find((message) => message.id === messageId)?.toolCalls?.[index]?.question;
789
+ return question ? structuredClone(question) : undefined;
790
+ }
791
+ pendingQuestion(conversationId) {
792
+ const conversation = this.state.conversations.find((item) => item.id === conversationId);
793
+ if (!conversation)
794
+ return undefined;
795
+ for (let messageIndex = conversation.messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
796
+ const message = conversation.messages[messageIndex];
797
+ if (message.role !== "assistant")
798
+ continue;
799
+ for (let index = (message.toolCalls?.length ?? 0) - 1; index >= 0; index -= 1) {
800
+ const question = message.toolCalls?.[index]?.question;
801
+ if (message.toolCalls?.[index]?.label === "question" && question?.status === "pending") {
802
+ return { messageId: message.id, index, question: structuredClone(question) };
803
+ }
804
+ }
805
+ }
806
+ return undefined;
807
+ }
738
808
  /**
739
809
  * Whether a conversation already holds a message with this id.
740
810
  *
@@ -767,6 +837,17 @@ export class Store {
767
837
  return undefined;
768
838
  return { conversation: structuredClone(conversation), workspace: structuredClone(workspace) };
769
839
  }
840
+ /**
841
+ * THE PHYSICAL DIRECTORY IS THE WORKSPACE'S IDENTITY.
842
+ *
843
+ * A workspace is a folder, not a row in a table: two workspaces pointing at the same directory are
844
+ * the same project, and the app used to mint a fresh UUID for each one. That is what broke
845
+ * re-adding a workspace - the new id matched none of the conversations that belonged to that
846
+ * directory, so the history was unreachable (and, before the soft unmount, actually deleted).
847
+ *
848
+ * So the same folder always means the same workspace id, and the id is what the conversations
849
+ * point at.
850
+ */
770
851
  async createWorkspace(name, path) {
771
852
  const cleanName = name.trim();
772
853
  if (!cleanName || cleanName.length > 80)
@@ -774,6 +855,19 @@ export class Store {
774
855
  const cleanPath = path?.trim() || process.cwd();
775
856
  if (cleanPath.length > 500)
776
857
  throw new Error("工作区路径太长了。");
858
+ // THE SAME FOLDER IS THE SAME WORKSPACE. Adding a directory that is already here re-opens it
859
+ // instead of forking a second one: the id stays, the conversations stay attached to it, and the
860
+ // name is left alone (renaming is an explicit action elsewhere, and silently renaming a
861
+ // workspace because someone typed a name into the add dialog would be its own surprise).
862
+ const existing = this.state.workspaces.find((item) => workspacePathKey(item.path) === workspacePathKey(cleanPath));
863
+ if (existing) {
864
+ if (existing.closedAt) {
865
+ delete existing.closedAt;
866
+ existing.updatedAt = now();
867
+ await this.persist();
868
+ }
869
+ return structuredClone(existing);
870
+ }
777
871
  const workspace = { id: id("workspace"), name: cleanName, path: cleanPath, updatedAt: now() };
778
872
  this.state.workspaces.push(workspace);
779
873
  await this.persist();
@@ -830,23 +924,55 @@ export class Store {
830
924
  await this.persist();
831
925
  return structuredClone(workspace);
832
926
  }
927
+ /**
928
+ * TAKES A WORKSPACE OUT OF THE SIDEBAR. IT DOES NOT DELETE ANYTHING.
929
+ *
930
+ * This used to cascade: the workspace row AND every conversation that pointed at it were erased
931
+ * from the state, in one persist. That made the most natural thing a user could do - remove a
932
+ * workspace to tidy the sidebar, then add the same folder back - silently destroy the history of
933
+ * that project, and there was no way to get it back.
934
+ *
935
+ * So the removal is a SOFT UNMOUNT, the same shape as a session's archive: the workspace row
936
+ * stays (with `closedAt` on it), the conversations stay, the uploads stay, and nothing is
937
+ * broadcast for either (see `clientState`). Adding the same folder again re-opens the very same
938
+ * workspace - same id, same conversations - which is the whole point of the physical path being
939
+ * the workspace's identity.
940
+ *
941
+ * The returned list is the conversations that were STOPPED, not deleted: a turn that was still
942
+ * running under a workspace that just left the sidebar cannot keep burning a model session
943
+ * behind the user's back, and a pending question would otherwise wait for an answer that the
944
+ * sidebar can no longer reach.
945
+ */
833
946
  async deleteWorkspace(workspaceId) {
834
- if (!this.state.workspaces.some((item) => item.id === workspaceId))
947
+ const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
948
+ if (!workspace)
835
949
  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);
950
+ if (workspace.closedAt)
951
+ return [];
952
+ workspace.closedAt = now();
953
+ workspace.updatedAt = workspace.closedAt;
848
954
  await this.persist();
849
- return [...doomed];
955
+ return [];
956
+ }
957
+ /**
958
+ * THE STATE THE CLIENT IS ALLOWED TO SEE.
959
+ *
960
+ * `snapshot()` is the truth of what the store HOLDS; this is the truth of what is ON SCREEN. The
961
+ * difference is the closed workspaces: one that was removed from the sidebar is still in the store
962
+ * - that is exactly why re-adding the folder brings its history back - but it is not part of the
963
+ * app any more, so neither it nor its conversations are broadcast.
964
+ *
965
+ * Filtering HERE, in one place, rather than in every reader, is what keeps the client dumb: the
966
+ * sidebar renders `state.workspaces` and never has to know that a closed workspace exists.
967
+ */
968
+ clientState() {
969
+ const state = this.snapshot();
970
+ const closed = new Set(state.workspaces.filter((item) => item.closedAt).map((item) => item.id));
971
+ if (!closed.size)
972
+ return state;
973
+ state.workspaces = state.workspaces.filter((item) => !closed.has(item.id));
974
+ state.conversations = state.conversations.filter((item) => !closed.has(item.workspaceId));
975
+ return state;
850
976
  }
851
977
  async createConversation(workspaceId, configuration) {
852
978
  const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
@@ -421,8 +421,16 @@ export class SubagentSupervisor {
421
421
  }
422
422
  if (!record)
423
423
  break;
424
- if (record.type === "response" && record.id === "prompt" && record.success === false)
425
- throw new Error(`Pi rejected the sub-agent task: ${record.error ?? "no reason given"}`);
424
+ // THE RECEIPT FOR OUR OWN PROMPT IS A KNOWN RECORD, NOT AN UNKNOWN ONE. Pi answers every
425
+ // command with `type:"response"`, so the successful receipt for the prompt we just wrote is
426
+ // part of the protocol - and treating it as unrecognised made every ordinary sub-agent run
427
+ // log a false alarm ("unhandled child record response - consumed without action"). A FAILED
428
+ // receipt is the run's failure and is thrown; a successful one is simply consumed.
429
+ if (record.type === "response" && record.id === "prompt") {
430
+ if (record.success === false)
431
+ throw new Error(`Pi rejected the sub-agent task: ${record.error ?? "no reason given"}`);
432
+ continue;
433
+ }
426
434
  if (record.type === "message_update") {
427
435
  const event = record.assistantMessageEvent;
428
436
  // `text_delta` is the only inner event that is output. `start` and `done` are the boundaries
@@ -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
  });