@evandu/super-pi 0.1.139 → 0.1.141

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/agents/super-pi-task/core.ts +418 -1
  2. package/agents/super-pi-task/index.ts +115 -0
  3. package/dist/.super-pi-check-inputs.json +2 -2
  4. package/dist/client/.super-pi-client-sources.json +7 -5
  5. package/dist/client/assets/{CodeMirrorEditor-H7SHGhdS.js → CodeMirrorEditor-Dm5IDKy9.js} +2 -2
  6. package/dist/client/assets/{dist-CZ9Krsno.js → dist-4baaMdqn.js} +1 -1
  7. package/dist/client/assets/{dist-Jw-GQ-re.js → dist-B-edBMDa.js} +1 -1
  8. package/dist/client/assets/{dist-zUJqdbfo.js → dist-BGaKp7BG.js} +1 -1
  9. package/dist/client/assets/{dist-2XXLO-IZ.js → dist-BMbwbGH9.js} +1 -1
  10. package/dist/client/assets/{dist-NlopeJ5R.js → dist-BqXU6S8h.js} +1 -1
  11. package/dist/client/assets/{dist-BEM6tttY.js → dist-BvCNiV_z.js} +1 -1
  12. package/dist/client/assets/{dist-CukPKrEn.js → dist-C7UQGfmz.js} +1 -1
  13. package/dist/client/assets/{dist-BfUeRJKr.js → dist-CMDW84Eh.js} +1 -1
  14. package/dist/client/assets/{dist-C8NllalC.js → dist-CN1j5LO2.js} +1 -1
  15. package/dist/client/assets/{dist-CUNctZ5v.js → dist-CNgQgdyS.js} +1 -1
  16. package/dist/client/assets/{dist-C_ftUQZN.js → dist-CSDLiQKt.js} +1 -1
  17. package/dist/client/assets/{dist-cP2vnTP_.js → dist-D0-9TI_k.js} +1 -1
  18. package/dist/client/assets/{dist-POoQ4j29.js → dist-D0eIQjoI.js} +1 -1
  19. package/dist/client/assets/{dist-BoucVzKA.js → dist-D7YZUqfA.js} +1 -1
  20. package/dist/client/assets/{dist-BokzcFLE.js → dist-DQWPdnen.js} +1 -1
  21. package/dist/client/assets/{dist-DJ8SwQl_.js → dist-DWj0wg8M.js} +1 -1
  22. package/dist/client/assets/{dist-BxTGjU84.js → dist-DtO-7WEZ.js} +1 -1
  23. package/dist/client/assets/{dist-D3Ta0y4e.js → dist-F7KOq8Xm.js} +1 -1
  24. package/dist/client/assets/{dist-DLKVFZhO.js → dist-GAPsnCM8.js} +1 -1
  25. package/dist/client/assets/{dist-CuMnZOGE.js → dist-LCDxbTTI.js} +1 -1
  26. package/dist/client/assets/{dist-CSdzvQnf.js → dist-M3BFsjal.js} +1 -1
  27. package/dist/client/assets/{dist-C-MUXK_t.js → dist-PQZBepmy.js} +1 -1
  28. package/dist/client/assets/index-BJIfLtDs.css +1 -0
  29. package/dist/client/assets/{index-B32VZVYx.js → index-DHsjaI7m.js} +13 -13
  30. package/dist/client/index.html +2 -2
  31. package/dist/server/agents.d.ts +2 -0
  32. package/dist/server/agents.js +10 -3
  33. package/dist/server/application/conversation-service.d.ts +15 -1
  34. package/dist/server/application/conversation-service.js +18 -2
  35. package/dist/server/application/run-service.d.ts +1 -0
  36. package/dist/server/application/run-service.js +121 -20
  37. package/dist/server/application/session-orchestrator-service.d.ts +113 -0
  38. package/dist/server/application/session-orchestrator-service.js +241 -0
  39. package/dist/server/command-handler.js +4 -0
  40. package/dist/server/http-server.js +135 -0
  41. package/dist/server/index.js +20 -2
  42. package/dist/server/runtime.d.ts +1 -0
  43. package/dist/server/runtime.js +6 -2
  44. package/dist/server/server-context.d.ts +2 -0
  45. package/dist/server/session-ledger.d.ts +60 -0
  46. package/dist/server/session-ledger.js +76 -0
  47. package/dist/server/store.d.ts +10 -0
  48. package/dist/server/store.js +38 -0
  49. package/dist/server/subagent-runs.d.ts +31 -1
  50. package/dist/server/subagent-runs.js +30 -13
  51. package/dist/server/task-tool.d.ts +18 -0
  52. package/dist/server/task-tool.js +85 -0
  53. package/dist/server/types.d.ts +56 -1
  54. package/dist/server/ws-server.d.ts +52 -1
  55. package/dist/server/ws-server.js +151 -35
  56. package/package.json +4 -2
  57. package/dist/client/assets/index-Bdym8BPo.css +0 -1
@@ -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-B32VZVYx.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-Bdym8BPo.css">
11
+ <script type="module" crossorigin src="/assets/index-DHsjaI7m.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BJIfLtDs.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
@@ -69,6 +77,12 @@ export declare class ConversationService {
69
77
  * Unarchives a conversation.
70
78
  */
71
79
  unarchiveConversation(conversationId: string): Promise<void>;
80
+ /**
81
+ * Collapses or expands one conversation's subtree in the sidebar's session tree. The state is
82
+ * the store's, server-side; this method only adds the conversation-exists check and the
83
+ * broadcast every other write here performs.
84
+ */
85
+ setCollapsed(conversationId: string, collapsed: boolean): Promise<void>;
72
86
  /**
73
87
  * Renames a conversation.
74
88
  */
@@ -38,8 +38,13 @@ export class ConversationService {
38
38
  }
39
39
  /**
40
40
  * Creates a new conversation in a workspace.
41
+ *
42
+ * `parentage` is WHO OPENED THIS CONVERSATION, recorded at birth exactly once: the session
43
+ * tree's edge (`parentConversationId`) and its origin. Only the orchestrator passes it - the "+"
44
+ * button and every other caller create a top-level conversation by omission, which is the same
45
+ * "absence means top level" rule the pre-tree state.json files read under.
41
46
  */
42
- async createConversation(workspaceId) {
47
+ async createConversation(workspaceId, parentage) {
43
48
  const workspace = this.store.workspace(workspaceId);
44
49
  if (!workspace)
45
50
  throw new Error("找不到这个工作区。");
@@ -104,7 +109,7 @@ export class ConversationService {
104
109
  : { model, thinkingLevel };
105
110
  // `null` is intentional here: Store uses it to remove a workspace value that it
106
111
  // would otherwise have copied before applying the resolved configuration.
107
- const defaults = { ...resolved, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null, ...(modelNotice ? { modelNotice } : {}) };
112
+ const defaults = { ...resolved, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null, ...(modelNotice ? { modelNotice } : {}), ...(parentage ? { parentConversationId: parentage.parentConversationId, origin: parentage.origin } : {}) };
108
113
  const created = await this.store.createConversation(workspaceId, defaults);
109
114
  this.broadcaster.broadcastState();
110
115
  return created.id;
@@ -174,6 +179,17 @@ export class ConversationService {
174
179
  await this.store.unarchiveConversation(conversationId);
175
180
  this.broadcaster.broadcastState();
176
181
  }
182
+ /**
183
+ * Collapses or expands one conversation's subtree in the sidebar's session tree. The state is
184
+ * the store's, server-side; this method only adds the conversation-exists check and the
185
+ * broadcast every other write here performs.
186
+ */
187
+ async setCollapsed(conversationId, collapsed) {
188
+ if (!this.store.conversation(conversationId))
189
+ throw new Error("找不到这个会话。");
190
+ await this.store.setConversationCollapsed(conversationId, collapsed);
191
+ this.broadcaster.broadcastState();
192
+ }
177
193
  /**
178
194
  * Renames a conversation.
179
195
  */
@@ -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));
@@ -0,0 +1,113 @@
1
+ import type { Store } from "../store.js";
2
+ import type { ConversationService } from "./conversation-service.js";
3
+ import type { RunService } from "./run-service.js";
4
+ import type { SessionLedger } from "../session-ledger.js";
5
+ import type { EventBroadcaster } from "../server-context.js";
6
+ import type { RunStatus } from "../types.js";
7
+ export interface SpawnSessionOptions {
8
+ /** The high-level task to be performed by the sibling session. */
9
+ task: string;
10
+ /** Parent conversation initiating the orchestration, if any. */
11
+ parentConversationId?: string;
12
+ /** Workspace ID for the new session. Inherited from parent if omitted. */
13
+ workspaceId?: string;
14
+ /** Optional custom title for the new session. */
15
+ title?: string;
16
+ /** Optional agent override for the new session. */
17
+ agentId?: string;
18
+ /** Optional model override for the new session. */
19
+ model?: {
20
+ provider: string;
21
+ modelId: string;
22
+ };
23
+ /** Optional thinking level override for the new session. */
24
+ thinkingLevel?: string;
25
+ /** Initial prompt to send immediately. Defaults to `task`. */
26
+ initialPrompt?: string;
27
+ /** Whether to await turn completion before returning (default: false, asynchronous dispatch). */
28
+ wait?: boolean;
29
+ }
30
+ export interface StopSessionOptions {
31
+ /** Stop mode: "active" (active turn), "queued" (waiting messages in queue), or "all" (both, default). */
32
+ mode?: "active" | "queued" | "all";
33
+ /** Optional specific waiting messageId to interrupt directly. */
34
+ messageId?: string;
35
+ }
36
+ export interface SessionSummary {
37
+ conversationId: string;
38
+ workspaceId: string;
39
+ title: string;
40
+ status: RunStatus;
41
+ origin: "user" | "orchestrator";
42
+ parentConversationId?: string;
43
+ task?: string;
44
+ messageCount: number;
45
+ turnCount: number;
46
+ lastAssistantSnippet?: string;
47
+ lastUserSnippet?: string;
48
+ hasPendingQuestion: boolean;
49
+ activeTool?: {
50
+ label: string;
51
+ detail?: string;
52
+ };
53
+ error?: string;
54
+ updatedAt: string;
55
+ createdAt?: number;
56
+ lastProgress?: string;
57
+ }
58
+ /**
59
+ * Orchestrator service coordinating sibling and child conversations.
60
+ *
61
+ * Provides lifecycle methods (spawn, dispatch, status, list, stop, resume) without
62
+ * duplicating message history or inflating memory.
63
+ */
64
+ export declare class SessionOrchestratorService {
65
+ private readonly store;
66
+ private readonly conversationService;
67
+ private readonly runService;
68
+ private readonly ledger;
69
+ private readonly broadcaster?;
70
+ constructor(store: Store, conversationService: ConversationService, runService: RunService, ledger: SessionLedger, broadcaster?: EventBroadcaster | undefined);
71
+ /**
72
+ * Spawns a new sibling conversation and immediately dispatches its initial task.
73
+ */
74
+ spawn(options: SpawnSessionOptions): Promise<{
75
+ conversationId: string;
76
+ }>;
77
+ /**
78
+ * Dispatches a message to an arbitrary session (server-to-server).
79
+ * By default dispatches asynchronously so the orchestrator caller is not blocked.
80
+ */
81
+ dispatch(conversationId: string, content: string, options?: {
82
+ wait?: boolean;
83
+ }): Promise<{
84
+ conversationId: string;
85
+ }>;
86
+ /**
87
+ * Returns a high-level summary of a session's progress without returning full transcripts.
88
+ * Uses narrow store reader `store.conversation(id)` to avoid cloning the entire state.
89
+ */
90
+ status(conversationId: string): Promise<SessionSummary>;
91
+ /**
92
+ * Lists summaries of all sessions, optionally filtered by parent conversation.
93
+ */
94
+ list(parentConversationId?: string): Promise<SessionSummary[]>;
95
+ /**
96
+ * Stops a target session.
97
+ * Can distinguish between interrupting an active turn, cancelling queued messages, or both.
98
+ */
99
+ stop(conversationId: string, options?: StopSessionOptions): Promise<{
100
+ stopped: boolean;
101
+ mode: string;
102
+ }>;
103
+ /**
104
+ * Resumes execution in a conversation.
105
+ * If prompt is provided, sends it as a new turn.
106
+ * Otherwise, if there is a waiting message in the queue, kicks off its execution.
107
+ */
108
+ resume(conversationId: string, prompt?: string, options?: {
109
+ wait?: boolean;
110
+ }): Promise<{
111
+ conversationId: string;
112
+ }>;
113
+ }