@evandu/super-pi 0.1.140 → 0.1.142

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 (40) 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-DvZeOLny.js → CodeMirrorEditor-iLUdQy9q.js} +2 -2
  4. package/dist/client/assets/{dist-C_VuSpx8.js → dist-0dCNvYEE.js} +1 -1
  5. package/dist/client/assets/{dist-gyzurUDR.js → dist-B4JqBNQI.js} +1 -1
  6. package/dist/client/assets/{dist-BCUfH4nP.js → dist-B8FbKhnf.js} +1 -1
  7. package/dist/client/assets/{dist-CTkzLBdb.js → dist-BA6EsWzo.js} +1 -1
  8. package/dist/client/assets/{dist-Cb1gxS57.js → dist-Bj2C-QqM.js} +1 -1
  9. package/dist/client/assets/{dist-gBAr9yXF.js → dist-Bt8G7e7e.js} +1 -1
  10. package/dist/client/assets/{dist-DAcS4Kj8.js → dist-CWBm3q4a.js} +1 -1
  11. package/dist/client/assets/{dist-qG53hT1b.js → dist-Cfa_e2VZ.js} +1 -1
  12. package/dist/client/assets/{dist-D7yfnKNC.js → dist-CoTJns5x.js} +1 -1
  13. package/dist/client/assets/{dist-CaUsvSz1.js → dist-CtCiGxne.js} +1 -1
  14. package/dist/client/assets/{dist-DdfW8Gjv.js → dist-DBAoCOnn.js} +1 -1
  15. package/dist/client/assets/{dist-DA8yW4lK.js → dist-DEUl3Yqk.js} +1 -1
  16. package/dist/client/assets/{dist-r4Kq1ABA.js → dist-DHyWJ9kA.js} +1 -1
  17. package/dist/client/assets/{dist-DRgWZqFH.js → dist-DbLEngKN.js} +1 -1
  18. package/dist/client/assets/{dist-C-QmRwbd.js → dist-Df9zcCXs.js} +1 -1
  19. package/dist/client/assets/{dist-CqDSeoUk.js → dist-Dq6y_nK3.js} +1 -1
  20. package/dist/client/assets/{dist-DlQ8PMvv.js → dist-Dxq1s4j3.js} +1 -1
  21. package/dist/client/assets/{dist-3BuyVG3H.js → dist-F634gv7V.js} +1 -1
  22. package/dist/client/assets/{dist-BIIMtU9W.js → dist-JuLMNOUt.js} +1 -1
  23. package/dist/client/assets/{dist-DN0M4DAX.js → dist-Nt-BWmBB.js} +1 -1
  24. package/dist/client/assets/{dist-HJdP1yxN.js → dist-ic-MopV2.js} +1 -1
  25. package/dist/client/assets/{dist-Dvca0LeR.js → dist-rIYPNzbY.js} +1 -1
  26. package/dist/client/assets/{index-CNbh1fL2.js → index-CX5gWDve.js} +13 -13
  27. package/dist/client/assets/{index-DCSFD4VV.css → index-DMhigTiY.css} +1 -1
  28. package/dist/client/index.html +2 -2
  29. package/dist/server/agents.d.ts +2 -0
  30. package/dist/server/agents.js +10 -3
  31. package/dist/server/application/conversation-service.d.ts +17 -2
  32. package/dist/server/application/conversation-service.js +42 -8
  33. package/dist/server/application/run-service.d.ts +1 -0
  34. package/dist/server/application/run-service.js +121 -20
  35. package/dist/server/application/session-orchestrator-service.js +8 -1
  36. package/dist/server/command-handler.js +4 -0
  37. package/dist/server/store.d.ts +22 -1
  38. package/dist/server/store.js +108 -3
  39. package/dist/server/types.d.ts +36 -1
  40. package/package.json +325 -315
@@ -8,8 +8,8 @@
8
8
  <link rel="alternate icon" href="/favicon.ico" />
9
9
  <link rel="apple-touch-icon" href="/favicon.svg" />
10
10
  <title>Super Pi</title>
11
- <script type="module" crossorigin src="/assets/index-CNbh1fL2.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-DCSFD4VV.css">
11
+ <script type="module" crossorigin src="/assets/index-CX5gWDve.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-DMhigTiY.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -37,6 +37,8 @@ type Frontmatter = Record<string, unknown>;
37
37
  * `validateToolNames` for what is rejected on that account and why.
38
38
  */
39
39
  export declare const PI_TOOL_NAMES: readonly ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls", "codemode", "tool_search", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"];
40
+ /** Tools shipped by this package. Authors must be able to explicitly deny host extras. */
41
+ export declare const SUPER_PI_TOOL_NAMES: readonly ["task", "todowrite", "workflow", "run_async", "scheduled_task", "question", "tool_register", "memory", "skill_manage", "session_search", "knowledge_search", "knowledge_import", "document_read", "document_write", "web_search", "web_fetch", "feishu_doc_read", "feishu_doc_write"];
40
42
  /**
41
43
  * Whether a name can be a real Pi tool. A name is real if it is one of `PI_TOOL_NAMES`, an MCP
42
44
  * server tool, or a PATTERN.
@@ -36,7 +36,14 @@ export const PI_TOOL_NAMES = [
36
36
  // core/mcp-servers.ts:31-37 - reachable on any server that exposes resources.
37
37
  "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource",
38
38
  ];
39
- const PI_TOOL_NAME_SET = new Set(PI_TOOL_NAMES);
39
+ /** Tools shipped by this package. Authors must be able to explicitly deny host extras. */
40
+ export const SUPER_PI_TOOL_NAMES = [
41
+ "task", "todowrite", "workflow", "run_async", "scheduled_task", "question", "tool_register",
42
+ "memory", "skill_manage", "session_search", "knowledge_search", "knowledge_import",
43
+ "document_read", "document_write", "web_search", "web_fetch", "feishu_doc_read", "feishu_doc_write",
44
+ ];
45
+ const KNOWN_TOOL_NAMES = [...PI_TOOL_NAMES, ...SUPER_PI_TOOL_NAMES];
46
+ const PI_TOOL_NAME_SET = new Set(KNOWN_TOOL_NAMES);
40
47
  /**
41
48
  * Whether a name can be a real Pi tool. A name is real if it is one of `PI_TOOL_NAMES`, an MCP
42
49
  * server tool, or a PATTERN.
@@ -66,7 +73,7 @@ function editDistance(left, right) {
66
73
  return row[right.length];
67
74
  }
68
75
  function closestToolName(name) {
69
- const ranked = PI_TOOL_NAMES.map((candidate) => ({ candidate, distance: editDistance(name, candidate) })).sort((a, b) => a.distance - b.distance);
76
+ const ranked = KNOWN_TOOL_NAMES.map((candidate) => ({ candidate, distance: editDistance(name, candidate) })).sort((a, b) => a.distance - b.distance);
70
77
  return ranked[0]?.distance <= Math.max(2, Math.floor(name.length / 2)) ? ranked[0].candidate : undefined;
71
78
  }
72
79
  /**
@@ -95,7 +102,7 @@ function validateToolNames(tools) {
95
102
  const suggestion = closestToolName(name);
96
103
  return `${JSON.stringify(name)}${suggestion ? ` (did you mean ${JSON.stringify(suggestion)}?)` : ""}`;
97
104
  });
98
- return `tools names ${details.join(", ")}, which Pi does not have. Statically known tools: ${PI_TOOL_NAMES.join(", ")}; MCP tools are named mcp__<server>__<tool>, and a "*" in a name is a Pi glob.`;
105
+ return `tools names ${details.join(", ")}, which Pi does not have. Statically known tools: ${KNOWN_TOOL_NAMES.join(", ")}; MCP tools are named mcp__<server>__<tool>, and a "*" in a name is a Pi glob.`;
99
106
  }
100
107
  /**
101
108
  * Splits a definition file into frontmatter and body. The body is the system prompt, exactly as in
@@ -39,8 +39,16 @@ export declare class ConversationService {
39
39
  setAgentsDir(dir: string | string[]): void;
40
40
  /**
41
41
  * Creates a new conversation in a workspace.
42
+ *
43
+ * `parentage` is WHO OPENED THIS CONVERSATION, recorded at birth exactly once: the session
44
+ * tree's edge (`parentConversationId`) and its origin. Only the orchestrator passes it - the "+"
45
+ * button and every other caller create a top-level conversation by omission, which is the same
46
+ * "absence means top level" rule the pre-tree state.json files read under.
42
47
  */
43
- createConversation(workspaceId: string): Promise<string>;
48
+ createConversation(workspaceId: string, parentage?: {
49
+ parentConversationId?: string;
50
+ origin?: "user" | "orchestrator";
51
+ }): Promise<string>;
44
52
  /**
45
53
  * Name a conversation from what was said in it. Returns the title; the CALLER applies it, because the
46
54
  * rename is a second command with its own optimistic path and its own failure toast, and a service that
@@ -66,9 +74,16 @@ export declare class ConversationService {
66
74
  */
67
75
  archiveConversation(conversationId: string): Promise<void>;
68
76
  /**
69
- * Unarchives a conversation.
77
+ * Unarchives an archived tree root and every archived descendant. Descendants that are
78
+ * already active need no write; they are already in the live tree this restore rejoins.
70
79
  */
71
80
  unarchiveConversation(conversationId: string): Promise<void>;
81
+ /**
82
+ * Collapses or expands one conversation's subtree in the sidebar's session tree. The state is
83
+ * the store's, server-side; this method only adds the conversation-exists check and the
84
+ * broadcast every other write here performs.
85
+ */
86
+ setCollapsed(conversationId: string, collapsed: boolean): Promise<void>;
72
87
  /**
73
88
  * Renames a conversation.
74
89
  */
@@ -1,5 +1,11 @@
1
1
  import { assertModelAllowed } from "../model-filter.js";
2
2
  import { newConversationId } from "../store.js";
3
+ function isMissingPrimaryAgent(error, agentId) {
4
+ const message = error instanceof Error ? error.message : String(error);
5
+ const id = JSON.stringify(agentId);
6
+ return message.includes(`Agent ${id} 已失效或不是可用的 primary agent`)
7
+ || message.includes(`Agent ${id} 不存在或不是可用的 primary agent`);
8
+ }
3
9
  /**
4
10
  * Service managing conversation lifecycle: creation, selection, archiving,
5
11
  * model/thinking/agent configuration, message reversion, and session forking.
@@ -38,8 +44,13 @@ export class ConversationService {
38
44
  }
39
45
  /**
40
46
  * Creates a new conversation in a workspace.
47
+ *
48
+ * `parentage` is WHO OPENED THIS CONVERSATION, recorded at birth exactly once: the session
49
+ * tree's edge (`parentConversationId`) and its origin. Only the orchestrator passes it - the "+"
50
+ * button and every other caller create a top-level conversation by omission, which is the same
51
+ * "absence means top level" rule the pre-tree state.json files read under.
41
52
  */
42
- async createConversation(workspaceId) {
53
+ async createConversation(workspaceId, parentage) {
43
54
  const workspace = this.store.workspace(workspaceId);
44
55
  if (!workspace)
45
56
  throw new Error("找不到这个工作区。");
@@ -99,12 +110,23 @@ export class ConversationService {
99
110
  thinkingLevel = undefined;
100
111
  }
101
112
  }
102
- const resolved = workspace.defaultAgentId
103
- ? await this.resolveAgentConfiguration({ id: workspaceId, model, thinkingLevel }, workspace.defaultAgentId, workspaceId)
104
- : { model, thinkingLevel };
113
+ let agentNotice;
114
+ let resolved = { model, thinkingLevel };
115
+ if (workspace.defaultAgentId) {
116
+ try {
117
+ resolved = await this.resolveAgentConfiguration({ id: workspaceId, model, thinkingLevel }, workspace.defaultAgentId, workspaceId);
118
+ }
119
+ catch (error) {
120
+ // Only an absent definition is downgraded. A present agent whose model, thinking level or
121
+ // workspace rules are invalid must still refuse rather than silently dropping its configuration.
122
+ if (!isMissingPrimaryAgent(error, workspace.defaultAgentId))
123
+ throw error;
124
+ agentNotice = `你设的默认 Agent「${workspace.defaultAgentId}」已经不存在了,这个会话改用 Pi 默认 Agent。请在工作区设置里重新选择默认 Agent。`;
125
+ }
126
+ }
105
127
  // `null` is intentional here: Store uses it to remove a workspace value that it
106
128
  // would otherwise have copied before applying the resolved configuration.
107
- const defaults = { ...resolved, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null, ...(modelNotice ? { modelNotice } : {}) };
129
+ const defaults = { ...resolved, agentId: resolved.agentId ?? null, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null, ...(modelNotice ? { modelNotice } : {}), ...(agentNotice ? { agentNotice } : {}), ...(parentage ? { parentConversationId: parentage.parentConversationId, origin: parentage.origin } : {}) };
108
130
  const created = await this.store.createConversation(workspaceId, defaults);
109
131
  this.broadcaster.broadcastState();
110
132
  return created.id;
@@ -164,14 +186,26 @@ export class ConversationService {
164
186
  */
165
187
  async archiveConversation(conversationId) {
166
188
  this.runService.reviewMemoryBoundary(conversationId);
167
- await this.store.archiveConversation(conversationId);
189
+ await this.store.archiveConversationTree(conversationId);
168
190
  this.broadcaster.broadcastState();
169
191
  }
170
192
  /**
171
- * Unarchives a conversation.
193
+ * Unarchives an archived tree root and every archived descendant. Descendants that are
194
+ * already active need no write; they are already in the live tree this restore rejoins.
172
195
  */
173
196
  async unarchiveConversation(conversationId) {
174
- await this.store.unarchiveConversation(conversationId);
197
+ await this.store.unarchiveConversationTree(conversationId);
198
+ this.broadcaster.broadcastState();
199
+ }
200
+ /**
201
+ * Collapses or expands one conversation's subtree in the sidebar's session tree. The state is
202
+ * the store's, server-side; this method only adds the conversation-exists check and the
203
+ * broadcast every other write here performs.
204
+ */
205
+ async setCollapsed(conversationId, collapsed) {
206
+ if (!this.store.conversation(conversationId))
207
+ throw new Error("找不到这个会话。");
208
+ await this.store.setConversationCollapsed(conversationId, collapsed);
175
209
  this.broadcaster.broadcastState();
176
210
  }
177
211
  /**
@@ -85,6 +85,7 @@ export declare class RunService {
85
85
  setNameIndexes(indexes: Map<string, import("../file-name-index.js").FileNameIndex>): void;
86
86
  reviewMemoryBoundary(conversationId: string): void;
87
87
  private readonly activeRuns;
88
+ private readonly turnEventScopes;
88
89
  private readonly historyCutClaims;
89
90
  holdHistoryCut(conversationId: string): void;
90
91
  releaseHistoryCut(conversationId: string): void;
@@ -11,6 +11,7 @@ import { toWorkspaceRelative } from "../files.js";
11
11
  import { refreshWorkspaceIndex } from "../file-index.js";
12
12
  import { execFile } from "node:child_process";
13
13
  import { promisify } from "node:util";
14
+ import { randomUUID } from "node:crypto";
14
15
  const execFileAsync = promisify(execFile);
15
16
  /** Expands composer references into explicit context for the model. */
16
17
  async function expandAtReferences(store, workspace, content) {
@@ -115,6 +116,7 @@ export class RunService {
115
116
  this.memory?.review(conversation);
116
117
  }
117
118
  activeRuns = new Map();
119
+ turnEventScopes = new Map();
118
120
  historyCutClaims = new Map();
119
121
  holdHistoryCut(conversationId) {
120
122
  this.historyCutClaims.set(conversationId, (this.historyCutClaims.get(conversationId) ?? 0) + 1);
@@ -847,8 +849,31 @@ export class RunService {
847
849
  * nothing above it - the user could not find what they had typed, while the agent quoted it back.
848
850
  */
849
851
  async streamTurn(conversation, workspace, prompt, assistantMessage, controller, userMessageId, images, agentIdentity) {
852
+ const scope = { id: randomUUID(), controller, accepting: true };
853
+ this.turnEventScopes.set(conversation.id, scope);
854
+ const ownsScope = () => this.turnEventScopes.get(conversation.id) === scope
855
+ && this.activeRuns.get(conversation.id) === scope.controller;
856
+ // Abort closes new work, while an existing tool outcome and transcript locator may still settle.
857
+ // Iterator completion closes ALL runtime callbacks before the accepted write lane is drained.
858
+ const rejectedEvents = new Set();
859
+ const acceptsEvent = (event, settlement = false) => {
860
+ const current = ownsScope() && scope.accepting;
861
+ if (!current && !rejectedEvents.has(event)) {
862
+ rejectedEvents.add(event);
863
+ console.warn(`Super Pi: ignored stale turn event conversation=${conversation.id} turn=${scope.id} event=${event}`);
864
+ }
865
+ return current && (settlement || !controller.signal.aborted);
866
+ };
867
+ const traceScope = (phase) => {
868
+ if (process.env.SUPER_PI_TRACE_LATENCY === "1") {
869
+ console.info(`[super-pi turn] conversation=${conversation.id} turn=${scope.id} phase=${phase} aborted=${controller.signal.aborted}`);
870
+ }
871
+ };
872
+ traceScope("opened");
850
873
  this.extensionUiService.cancelPendingUi(conversation.id, "expired");
874
+ const uiHost = this.extensionUiService.createHost(conversation.id);
851
875
  let assistant = assistantMessage;
876
+ let closedOwnScope = false;
852
877
  let toolCalls = [];
853
878
  let pendingTools = Promise.resolve();
854
879
  // A TURN MOVES FROM `waiting` TO `running` ONCE THE MODEL HAS PRODUCED ANY SIGNAL: a text delta
@@ -872,11 +897,14 @@ export class RunService {
872
897
  * a snapshot only when it actually flips the status, and the caller asks.
873
898
  */
874
899
  const promoteToRunning = async () => {
875
- if (!firstSignal)
900
+ // Accepted tool writes may drain after the producer closed; that is normal cleanup, not a stale event.
901
+ if (!ownsScope() || !scope.accepting || controller.signal.aborted || !firstSignal)
876
902
  return false;
877
903
  firstSignal = false;
878
- if (this.store.snapshot().conversations.find((item) => item.id === conversation.id)?.status === "waiting") {
904
+ if (this.store.conversationStatus(conversation.id) === "waiting") {
879
905
  await this.store.setStatus(conversation.id, "running");
906
+ if (!ownsScope() || !scope.accepting || controller.signal.aborted)
907
+ return false;
880
908
  this.broadcaster.broadcastState();
881
909
  return true;
882
910
  }
@@ -896,13 +924,21 @@ export class RunService {
896
924
  if (!checkpointPending)
897
925
  return;
898
926
  checkpointPending = false;
927
+ if (!ownsScope())
928
+ return;
899
929
  // Tool updates and message checkpoints both replace the persisted message. They share this lane
900
930
  // so an earlier text snapshot cannot commit after a newer tool state and erase it on disk.
901
- pendingTools = pendingTools.then(() => this.store.checkpointMessage(conversation.id, assistant.id)).catch((error) => {
931
+ pendingTools = pendingTools.then(async () => {
932
+ if (ownsScope())
933
+ await this.store.checkpointMessage(conversation.id, assistant.id);
934
+ }).catch((error) => {
902
935
  console.warn(`Super Pi: streaming checkpoint failed: ${error instanceof Error ? error.message : "unknown error"}`);
903
936
  });
904
937
  };
905
938
  const scheduleCheckpoint = () => {
939
+ // The final checkpoint owns all accepted live text after the producer closes.
940
+ if (!scope.accepting || !ownsScope())
941
+ return;
906
942
  checkpointPending = true;
907
943
  if (checkpointTimer !== undefined)
908
944
  return;
@@ -910,14 +946,35 @@ export class RunService {
910
946
  };
911
947
  const enqueueTools = (what, work) => {
912
948
  pendingTools = pendingTools.then(async () => {
949
+ if (!ownsScope())
950
+ return;
913
951
  // A TOOL CALL IS A SIGNAL THE MODEL IS ALIVE. Promote `waiting` → `running` on the same
914
952
  // path as text deltas: a tool row appearing on screen is the same "we have data" event
915
953
  // the status needs to reflect. Doing it BEFORE the tool write keeps the order
916
954
  // honest - a client that saw the new tool row in the same frame also saw the running
917
955
  // status, instead of flashing back to a still-spinning waiting mark for the next paint.
918
956
  await promoteToRunning();
957
+ if (!ownsScope())
958
+ return;
919
959
  const hadPendingQuestion = toolCalls.some(call => call.question?.status === "pending");
920
960
  toolCalls = await work();
961
+ if (!ownsScope())
962
+ return;
963
+ // Stop may have scanned pending questions before this accepted write reached the Store.
964
+ // Keep the tool record, but do not resurrect a human wait after cancellation.
965
+ if (controller.signal.aborted) {
966
+ for (let index = 0; index < toolCalls.length; index++) {
967
+ const question = toolCalls[index].question;
968
+ if (question?.status !== "pending")
969
+ continue;
970
+ toolCalls = await this.store.updateToolCall(conversation.id, assistant.id, index, {
971
+ question: { items: question.items, status: "cancelled" },
972
+ });
973
+ if (!ownsScope())
974
+ return;
975
+ this.settleQuestion(conversation.id, assistant.id, index, toolCalls[index]?.question);
976
+ }
977
+ }
921
978
  this.broadcaster.broadcast({ type: "message.tools", conversationId: conversation.id, messageId: assistant.id, toolCalls });
922
979
  if (hadPendingQuestion !== toolCalls.some(call => call.question?.status === "pending"))
923
980
  this.broadcaster.broadcastState();
@@ -980,6 +1037,8 @@ export class RunService {
980
1037
  */
981
1038
  const preparingRows = new Map();
982
1039
  const onToolPreparing = (call) => {
1040
+ if (!acceptsEvent("tool.preparing"))
1041
+ return;
983
1042
  const existing = preparingRows.get(call.contentIndex);
984
1043
  if (existing === undefined && call.final) {
985
1044
  // `toolcall_end` FOR A CALL THAT IS ALREADY ON SCREEN. Pi closes an argument document whenever it
@@ -1069,6 +1128,8 @@ export class RunService {
1069
1128
  */
1070
1129
  const fileWritePaths = new Map();
1071
1130
  const onFileWrite = (path, _toolName, toolCallId) => {
1131
+ if (!acceptsEvent("file.write"))
1132
+ return;
1072
1133
  const tracker = this.fileChanges;
1073
1134
  if (!tracker)
1074
1135
  return;
@@ -1098,6 +1159,8 @@ export class RunService {
1098
1159
  this.broadcastFileChanges(workspace.id);
1099
1160
  };
1100
1161
  const onToolCall = (call) => {
1162
+ if (!acceptsEvent("tool.start"))
1163
+ return;
1101
1164
  const at = spoken;
1102
1165
  // THE PLACEHOLDER THAT BELONGS TO THIS CALL, IF ONE IS ON SCREEN.
1103
1166
  //
@@ -1156,12 +1219,16 @@ export class RunService {
1156
1219
  enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, thinkingAt, thinkingBlock: callThinkingBlock, ...(call.question ? { question: call.question } : {}) }));
1157
1220
  };
1158
1221
  const onToolUpdate = (call) => {
1222
+ if (!acceptsEvent("tool.update"))
1223
+ return;
1159
1224
  const index = call.id ? rowByToolCallId.get(call.id) : undefined;
1160
1225
  if (index === undefined)
1161
1226
  return;
1162
1227
  enqueueTools("a tool call update", () => this.store.updateToolCall(conversation.id, assistant.id, index, { ...(call.args ? { args: call.args } : {}), ...(call.live !== undefined ? { live: call.live } : {}) }));
1163
1228
  };
1164
1229
  const onToolEnd = (call) => {
1230
+ if (!acceptsEvent("tool.end", true))
1231
+ return;
1165
1232
  // THE WRITE IS FINISHED, so the baseline can be CHECKED. The tracker recorded the file's etag when
1166
1233
  // the write was announced; if the file's etag now differs, the baseline really is the content
1167
1234
  // from before the write. If it does not, the baseline was taken too late to be shown, and the
@@ -1199,14 +1266,18 @@ export class RunService {
1199
1266
  * it is a global fold sequence, and restarting it would make the browser render two folds numbered 0.
1200
1267
  */
1201
1268
  const onSteerDelivered = (text) => {
1269
+ if (!acceptsEvent("steer.delivered"))
1270
+ return Promise.resolve();
1202
1271
  enqueueTools("a queued message being delivered", async () => {
1203
1272
  // A delayed SDK delivery during abort still belongs to the stopped producer.
1204
- if (controller.signal.aborted)
1273
+ if (!acceptsEvent("steer.delivered"))
1205
1274
  return toolCalls;
1206
1275
  const previous = assistant;
1207
1276
  // Checkpoint first: the message being left behind is complete - Pi drains its queues at a turn
1208
1277
  // boundary - and this is the last chance to write it durably with the lane it belongs to.
1209
1278
  await this.store.checkpointMessage(conversation.id, previous.id);
1279
+ if (!acceptsEvent("steer.delivered"))
1280
+ return toolCalls;
1210
1281
  // WHICH MESSAGE THIS DELIVERY IS ABOUT. Pi announces the text it picked up, and Pi itself matches
1211
1282
  // its own queue by that text (`agent-session.ts:1123`), so the same text resolves to the same
1212
1283
  // message on both sides - including when two messages carry identical words, where both sides
@@ -1225,6 +1296,8 @@ export class RunService {
1225
1296
  // assistant message for one of those would open an empty bubble before every single turn.
1226
1297
  if (!delivered)
1227
1298
  return toolCalls;
1299
+ if (!ownsScope())
1300
+ return toolCalls;
1228
1301
  const next = delivered.assistant;
1229
1302
  this.broadcaster.broadcastState();
1230
1303
  assistant = next;
@@ -1247,11 +1320,22 @@ export class RunService {
1247
1320
  prompt,
1248
1321
  signal: controller.signal,
1249
1322
  cwd: workspace.path,
1250
- host: this.extensionUiService.createHost(conversation.id),
1251
- onPiQueue: (snapshot) => this.onPiQueue(conversation.id, snapshot),
1323
+ host: {
1324
+ notify: (request) => { if (acceptsEvent("ui.notify"))
1325
+ uiHost.notify(request); },
1326
+ ask: (request) => acceptsEvent("ui.ask") ? uiHost.ask(request) : Promise.resolve({ cancelled: true }),
1327
+ },
1328
+ onPiQueue: (snapshot) => { if (acceptsEvent("pi.queue"))
1329
+ this.onPiQueue(conversation.id, snapshot); },
1252
1330
  onSteerDelivered,
1253
- onUserEntry: (entryId) => this.store.setMessagePiEntryId(conversation.id, userMessageId, entryId),
1254
- onSessionFile: (path) => this.store.setConversationPiSessionFile(conversation.id, path),
1331
+ onUserEntry: async (entryId) => {
1332
+ if (acceptsEvent("transcript.entry", true))
1333
+ await this.store.setMessagePiEntryId(conversation.id, userMessageId, entryId);
1334
+ },
1335
+ onSessionFile: async (path) => {
1336
+ if (acceptsEvent("transcript.file", true))
1337
+ await this.store.setConversationPiSessionFile(conversation.id, path);
1338
+ },
1255
1339
  onToolCall,
1256
1340
  onFileWrite,
1257
1341
  onToolPreparing,
@@ -1265,7 +1349,7 @@ export class RunService {
1265
1349
  // having received one. Pi announces the END of reasoning with `thinking_end` and puts nothing on
1266
1350
  // the delta, so these two halves together are the whole lifecycle.
1267
1351
  onThinking: async (delta) => {
1268
- if (!delta)
1352
+ if (!delta || !acceptsEvent("thinking.delta"))
1269
1353
  return;
1270
1354
  // THINKING IS THE MODEL PRODUCING SOMETHING, SO THE TURN IS RUNNING. The contract above
1271
1355
  // says a turn moves from `waiting` to `running` once the model has produced ANY signal -
@@ -1303,7 +1387,9 @@ export class RunService {
1303
1387
  // published live. `promoteToRunning` answers whether it published one.
1304
1388
  this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken, newBlock);
1305
1389
  const promoted = await promoteToRunning();
1306
- if (!promoted) {
1390
+ if (!ownsScope())
1391
+ return;
1392
+ if (!promoted && acceptsEvent("thinking.delta")) {
1307
1393
  this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
1308
1394
  }
1309
1395
  scheduleCheckpoint();
@@ -1314,6 +1400,8 @@ export class RunService {
1314
1400
  // block to every turn whose second reasoning run starts after the answer has already begun,
1315
1401
  // because that block's `at` then differs from the previous one's.
1316
1402
  onThinkingEnd: () => {
1403
+ if (!acceptsEvent("thinking.end"))
1404
+ return;
1317
1405
  thinkingActive = false;
1318
1406
  this.store.setThinkingActiveLive(conversation.id, assistant.id, false);
1319
1407
  this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta: "", active: false });
@@ -1328,12 +1416,16 @@ export class RunService {
1328
1416
  // publish a new message underneath it. One `await` at the top covers both, and costs nothing:
1329
1417
  // the lane is empty most of the time.
1330
1418
  await pendingTools;
1419
+ if (!acceptsEvent("text.delta"))
1420
+ continue;
1331
1421
  // `waiting` → `running` ON THE FIRST DELTA, before the live write. The conversation was
1332
1422
  // opened in `waiting` (see `openTurn` / `openTurnOnSpecificMessage` / `openTurnOnMessage`),
1333
1423
  // and the first response token is the observable fact that says the LLM is actually
1334
1424
  // answering rather than hanging on auth / quota / network. `promoteToRunning` is a no-op
1335
1425
  // after the first signal so this stays a one-shot status write, not a per-token one.
1336
1426
  await promoteToRunning();
1427
+ if (!acceptsEvent("text.delta"))
1428
+ continue;
1337
1429
  if (!thinkingActive && thinkingBlock >= 0)
1338
1430
  answerAfterThinking = true;
1339
1431
  this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
@@ -1346,10 +1438,14 @@ export class RunService {
1346
1438
  }
1347
1439
  }
1348
1440
  finally {
1441
+ scope.accepting = false;
1442
+ traceScope("draining");
1349
1443
  // A failed or cancelled producer still owns live text and a checkpoint timer.
1350
1444
  // Drain the lane before the caller changes status or permits another turn.
1351
- this.store.setThinkingActiveLive(conversation.id, assistant.id, false);
1352
- checkpointPending = true;
1445
+ if (ownsScope()) {
1446
+ this.store.setThinkingActiveLive(conversation.id, assistant.id, false);
1447
+ checkpointPending = true;
1448
+ }
1353
1449
  checkpointNow();
1354
1450
  // A CALL THE MODEL STARTED AND NEVER RAN. Pi announces a call with `toolcall_start` and only tells
1355
1451
  // the host it is running with `tool_execution_start`, so an abort, a cancelled turn or an upstream
@@ -1357,7 +1453,7 @@ export class RunService {
1357
1453
  // rows, so each one kept 「生成参数中…」 for the rest of the conversation. Queued HERE, before the
1358
1454
  // await below, so it drains behind the last real tool write rather than racing it; `preparingRows`
1359
1455
  // is read here because by this line no further call can be announced.
1360
- if (preparingRows.size) {
1456
+ if (ownsScope() && preparingRows.size) {
1361
1457
  const stranded = [...preparingRows.values()].map((entry) => entry.row);
1362
1458
  preparingRows.clear();
1363
1459
  enqueueTools("unfinished tool calls", () => this.store.clearPreparingToolCalls(conversation.id, assistant.id, stranded));
@@ -1366,15 +1462,20 @@ export class RunService {
1366
1462
  // The positions are facts ABOUT THIS TURN and stop being true when it ends, so they go with it.
1367
1463
  // A dispatch whose POST arrives after this line gets no anchor and renders where it always did -
1368
1464
  // which is the honest outcome, because by then the call that asked for it may no longer be on screen.
1369
- this.toolCallSites.delete(conversation.id);
1370
- this.toolCallLanes.delete(conversation.id);
1371
- for (const [key, waiters] of this.toolCallWaiters) {
1372
- if (key.startsWith(`${conversation.id}:`))
1373
- for (const wake of waiters)
1374
- wake();
1465
+ if (this.turnEventScopes.get(conversation.id) === scope) {
1466
+ closedOwnScope = ownsScope();
1467
+ this.turnEventScopes.delete(conversation.id);
1468
+ this.toolCallSites.delete(conversation.id);
1469
+ this.toolCallLanes.delete(conversation.id);
1470
+ for (const [key, waiters] of this.toolCallWaiters) {
1471
+ if (key.startsWith(`${conversation.id}:`))
1472
+ for (const wake of waiters)
1473
+ wake();
1474
+ }
1375
1475
  }
1476
+ traceScope("closed");
1376
1477
  }
1377
- if (!controller.signal.aborted) {
1478
+ if (closedOwnScope && this.activeRuns.get(conversation.id) === controller && !controller.signal.aborted) {
1378
1479
  const completed = this.store.snapshot().conversations.find(c => c.id === conversation.id);
1379
1480
  if (completed)
1380
1481
  await this.memory?.completed(completed).catch(error => console.warn("Super Pi memory scheduling failed:", error));
@@ -29,7 +29,14 @@ export class SessionOrchestratorService {
29
29
  if (!workspaceId) {
30
30
  throw new Error("创建兄弟会话需要指定工作区(或提供有效的父会话 ID)。");
31
31
  }
32
- const conversationId = await this.conversationService.createConversation(workspaceId);
32
+ // THE PARENTAGE IS WRITTEN AT CREATION TIME, onto the conversation itself - not only into the
33
+ // in-memory ledger below. The ledger is task/status/progress metadata and dies with the process;
34
+ // the conversation's parentConversationId/origin is what the sidebar's session tree redraws
35
+ // from after a restart, so it has to be the persistent record.
36
+ const conversationId = await this.conversationService.createConversation(workspaceId, {
37
+ parentConversationId: options.parentConversationId,
38
+ origin: options.parentConversationId ? "orchestrator" : "user",
39
+ });
33
40
  // Set title if requested or derive from task
34
41
  if (options.title) {
35
42
  await this.conversationService.renameConversation(conversationId, options.title);
@@ -200,6 +200,10 @@ export class CommandHandler {
200
200
  await this.conversationService.selectConversation(command.conversationId);
201
201
  return;
202
202
  }
203
+ if (command.type === "conversation.collapse") {
204
+ await this.conversationService.setCollapsed(command.conversationId, command.collapsed);
205
+ return;
206
+ }
203
207
  if (command.type === "conversation.archive") {
204
208
  await this.conversationService.archiveConversation(command.conversationId);
205
209
  return;
@@ -266,11 +266,22 @@ export declare class Store {
266
266
  conversationId?: string;
267
267
  }): AppState;
268
268
  createConversation(workspaceId: string, configuration?: {
269
- agentId?: string;
269
+ agentId?: string | null;
270
270
  model?: ModelSelection | null;
271
271
  thinkingLevel?: string | null;
272
272
  modelNotice?: string;
273
+ agentNotice?: string;
274
+ parentConversationId?: string;
275
+ origin?: "user" | "orchestrator";
273
276
  }): Promise<Conversation>;
277
+ /**
278
+ * Records (or clears) one conversation as collapsed in the sidebar's session tree. The list is a
279
+ * SET in array clothing - order means nothing, duplicates must not accumulate - and it follows the
280
+ * assign -> persist -> rollback pattern `setTerminalShell` established: the in-memory value is the
281
+ * previous value again the moment persistence fails, so a rejected write can never leave the
282
+ * screen and the disk disagreeing.
283
+ */
284
+ setConversationCollapsed(conversationId: string, collapsed: boolean): Promise<void>;
274
285
  /**
275
286
  * Copies a conversation into a NEW one, under an id the caller has already minted.
276
287
  *
@@ -331,6 +342,16 @@ export declare class Store {
331
342
  /** Restoring deletes the key rather than setting it to null, so a fully restored conversation serializes
332
343
  * byte-identically to one that was never archived. `delete` is legal here because the field is optional. */
333
344
  unarchiveConversation(conversationId: string): Promise<void>;
345
+ /**
346
+ * Archives one whole tree as one state write. Only a conversation with no existing parent may
347
+ * start it; an archived parent is still a parent, so its active descendants cannot be archived
348
+ * independently. All running checks happen before the first mutation, making rejection all-or-none.
349
+ */
350
+ archiveConversationTree(conversationId: string): Promise<void>;
351
+ /** Restores one archived tree in one write; active descendants are already restored by definition. */
352
+ unarchiveConversationTree(conversationId: string): Promise<void>;
353
+ /** Pre-order traversal with cycle protection; archived nodes are traversed, not treated as leaves. */
354
+ private conversationSubtree;
334
355
  /**
335
356
  * Rename edits the title and NOTHING else. In particular `updatedAt` is deliberately not touched,
336
357
  * for exactly the reason archive is: `updatedAt` is the sidebar's recency key, so bumping it would