@evandu/super-pi 0.1.148 → 0.1.150

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 (37) hide show
  1. package/agents/super-pi-task/core.ts +105 -1
  2. package/agents/super-pi-task/index.ts +46 -0
  3. package/dist/client/.super-pi-client-sources.json +3 -3
  4. package/dist/client/assets/{CodeMirrorEditor-DQCjEnxG.js → CodeMirrorEditor-BhTJvXpU.js} +2 -2
  5. package/dist/client/assets/{dist-QZ-FAVJV.js → dist-0vbzT0fs.js} +1 -1
  6. package/dist/client/assets/{dist-CRxCwn-Z.js → dist-49TFFUQg.js} +1 -1
  7. package/dist/client/assets/{dist-B4pz_xdd.js → dist-80xX2Yd8.js} +1 -1
  8. package/dist/client/assets/{dist-F3S0t1oM.js → dist-B0gI-KRY.js} +1 -1
  9. package/dist/client/assets/{dist-CDBKM9tL.js → dist-B3Dy7iF_.js} +1 -1
  10. package/dist/client/assets/{dist-BpeClAv0.js → dist-B5nvkwVU.js} +1 -1
  11. package/dist/client/assets/{dist-CGzXCzCG.js → dist-BFAjSsad.js} +1 -1
  12. package/dist/client/assets/{dist-CTDgUnBG.js → dist-BOqRI6sA.js} +1 -1
  13. package/dist/client/assets/{dist-u0nBg0fZ.js → dist-BstoGC94.js} +1 -1
  14. package/dist/client/assets/{dist-Bk_pkBVH.js → dist-BuDFattZ.js} +1 -1
  15. package/dist/client/assets/{dist-B1qLZVMK.js → dist-Bz1OET8s.js} +1 -1
  16. package/dist/client/assets/{dist-BWvzD8Pe.js → dist-CMiCGuFr.js} +1 -1
  17. package/dist/client/assets/{dist-DTZLNEci.js → dist-Ce7WvAkm.js} +1 -1
  18. package/dist/client/assets/{dist-DuUi29km.js → dist-CkatEAPx.js} +1 -1
  19. package/dist/client/assets/{dist-BJVI1Unv.js → dist-Cwuref9o.js} +1 -1
  20. package/dist/client/assets/{dist-Bx7s4rni.js → dist-CzCFepaD.js} +1 -1
  21. package/dist/client/assets/{dist-1Innu17E.js → dist-DDdxjRBp.js} +1 -1
  22. package/dist/client/assets/{dist-D1VfMWmf.js → dist-DJdFGvR0.js} +1 -1
  23. package/dist/client/assets/{dist-1lynFOc5.js → dist-Dq7hLZoB.js} +1 -1
  24. package/dist/client/assets/{dist-BsmJg5PX.js → dist-SMKcq1ns.js} +1 -1
  25. package/dist/client/assets/{dist-BayWssd0.js → dist-TwlTH8F1.js} +1 -1
  26. package/dist/client/assets/{dist-B6KwDpeI.js → dist-aOpTOMHI.js} +1 -1
  27. package/dist/client/assets/{index-Dzyp_7sf.js → index-Csr3-JTY.js} +5 -5
  28. package/dist/client/index.html +1 -1
  29. package/dist/server/agents.d.ts +1 -1
  30. package/dist/server/agents.js +1 -1
  31. package/dist/server/application/session-orchestrator-service.d.ts +49 -2
  32. package/dist/server/application/session-orchestrator-service.js +129 -7
  33. package/dist/server/http-server.js +65 -0
  34. package/dist/server/runtime.js +3 -3
  35. package/dist/server/task-tool.d.ts +3 -0
  36. package/dist/server/task-tool.js +19 -0
  37. package/package.json +2 -1
@@ -8,7 +8,7 @@
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-Dzyp_7sf.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-Csr3-JTY.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-BMpqviwU.css">
13
13
  </head>
14
14
  <body>
@@ -38,7 +38,7 @@ type Frontmatter = Record<string, unknown>;
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
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", "session_spawn", "session_status", "session_dispatch", "session_stop", "session_report", "knowledge_search", "knowledge_import", "document_read", "document_write", "web_search", "web_fetch", "feishu_doc_read", "feishu_doc_write"];
41
+ export declare const SUPER_PI_TOOL_NAMES: readonly ["task", "todowrite", "workflow", "run_async", "scheduled_task", "question", "tool_register", "memory", "skill_manage", "session_search", "session_spawn", "session_status", "session_dispatch", "session_stop", "session_report", "session_stop_subtree", "knowledge_search", "knowledge_import", "document_read", "document_write", "web_search", "web_fetch", "feishu_doc_read", "feishu_doc_write"];
42
42
  /**
43
43
  * Whether a name can be a real Pi tool. A name is real if it is one of `PI_TOOL_NAMES`, an MCP
44
44
  * server tool, or a PATTERN.
@@ -40,7 +40,7 @@ export const PI_TOOL_NAMES = [
40
40
  export const SUPER_PI_TOOL_NAMES = [
41
41
  "task", "todowrite", "workflow", "run_async", "scheduled_task", "question", "tool_register",
42
42
  "memory", "skill_manage", "session_search",
43
- "session_spawn", "session_status", "session_dispatch", "session_stop", "session_report",
43
+ "session_spawn", "session_status", "session_dispatch", "session_stop", "session_report", "session_stop_subtree",
44
44
  "knowledge_search", "knowledge_import",
45
45
  "document_read", "document_write", "web_search", "web_fetch", "feishu_doc_read", "feishu_doc_write",
46
46
  ];
@@ -110,16 +110,27 @@ export declare class SessionOrchestratorService {
110
110
  */
111
111
  private readonly lengthContinuations;
112
112
  /**
113
- * THE CHILDREN THAT HAVE ALREADY REPORTED THEIR OWN RESULT TO THEIR PARENT.
113
+ * THE CHILDREN THAT HAVE ALREADY REPORTED THEIR OWN RESULT TO THEIR PARENT, AND WHEN.
114
114
  *
115
115
  * A child can report explicitly - the `report` action - and that report is the child's own
116
116
  * statement of what it did. When the child's turn then ends, the automatic completion report
117
117
  * would deliver the same thing a second time, so the automatic path steps aside for a child that
118
- * is in this set.
118
+ * is in this map.
119
119
  *
120
120
  * The entry is added only by a TERMINAL report (`completed` / `failed`), because an interim
121
121
  * update is not the child's final word: the task is still running, and the parent will want to
122
122
  * hear how it ended.
123
+ *
124
+ * WHY THE MAP HOLDS A TIMESTAMP AND NOT A FLAG. A flag says "this child reported", and that is
125
+ * not the same question as "this child reported ABOUT THE TURN THAT JUST ENDED". A flag stays
126
+ * true for the rest of the child's life, so it silences every automatic report the child ever
127
+ * produces again: the parent gives the same child a second task, the child finishes, and the
128
+ * result is swallowed because of a report the parent received about the FIRST task. The parent
129
+ * waits for a result that was written and never arrives, and nothing in the transcript says why.
130
+ *
131
+ * The timestamp answers the real question: the report was made INSIDE this turn, so it speaks for
132
+ * this turn. A report made before the turn started speaks for an earlier one, and this turn's
133
+ * result is still its own.
123
134
  */
124
135
  private readonly reportedChildren;
125
136
  constructor(store: Store, conversationService: ConversationService, runService: RunService, ledger: SessionLedger, broadcaster?: EventBroadcaster | undefined, watchdog?: SessionWatchdog);
@@ -219,6 +230,42 @@ export declare class SessionOrchestratorService {
219
230
  stopped: boolean;
220
231
  mode: string;
221
232
  }>;
233
+ /**
234
+ * THE SESSIONS UNDER ONE SESSION, DEEPEST LAST.
235
+ *
236
+ * The tree edge lives on the conversation itself (`parentConversationId`), so "who is under this
237
+ * one?" is a walk over the workspace's conversations rather than a separate structure that could
238
+ * disagree with it. The order is breadth-first, which is depth order - and the caller stops them
239
+ * in reverse, so the deepest work dies before the session that owns it.
240
+ */
241
+ private descendantsOf;
242
+ /**
243
+ * STOPS A SESSION AND EVERYTHING UNDER IT.
244
+ *
245
+ * THIS IS NOT THE SAME THING AS STOPPING A SESSION, and it is deliberately a separate operation
246
+ * rather than a flag on the other one. Stopping a session stops THAT session: its turn, its queued
247
+ * messages, its watchdog. Nothing else. That behaviour is right for the ordinary case - a child
248
+ * that went down the wrong path should not take its own children down with it, because those
249
+ * children may be doing work the parent still wants.
250
+ *
251
+ * But there is a case where the ordinary case leaves a mess. A supervisor whose own supervisor has
252
+ * stopped is an ORPHAN, and the sessions it started are orphans too: they keep burning tokens,
253
+ * keep writing files, and report their results to a session that is no longer running - nobody is
254
+ * left to read them. The parent sees a child that has gone wrong, stops it, and the grandchildren
255
+ * keep running with no parent to stop them.
256
+ *
257
+ * So this operation takes the whole tree: the root, its children, its grandchildren, deepest
258
+ * first. The descendants are stopped AS A CONSEQUENCE of the root being stopped - the caller is
259
+ * still only managing its OWN direct child, and the cross-level reach the scope rules forbid is
260
+ * not what happens here: a grandchild cannot be stopped on its own, and it cannot be spared
261
+ * either, because it cannot outlive the session that owns it.
262
+ */
263
+ stopSubtree(conversationId: string, options?: StopSessionOptions): Promise<{
264
+ stopped: boolean;
265
+ mode: string;
266
+ stoppedIds: string[];
267
+ descendantCount: number;
268
+ }>;
222
269
  /**
223
270
  * Resumes execution in a conversation.
224
271
  * If prompt is provided, sends it as a new turn.
@@ -39,18 +39,29 @@ export class SessionOrchestratorService {
39
39
  */
40
40
  lengthContinuations = new Map();
41
41
  /**
42
- * THE CHILDREN THAT HAVE ALREADY REPORTED THEIR OWN RESULT TO THEIR PARENT.
42
+ * THE CHILDREN THAT HAVE ALREADY REPORTED THEIR OWN RESULT TO THEIR PARENT, AND WHEN.
43
43
  *
44
44
  * A child can report explicitly - the `report` action - and that report is the child's own
45
45
  * statement of what it did. When the child's turn then ends, the automatic completion report
46
46
  * would deliver the same thing a second time, so the automatic path steps aside for a child that
47
- * is in this set.
47
+ * is in this map.
48
48
  *
49
49
  * The entry is added only by a TERMINAL report (`completed` / `failed`), because an interim
50
50
  * update is not the child's final word: the task is still running, and the parent will want to
51
51
  * hear how it ended.
52
+ *
53
+ * WHY THE MAP HOLDS A TIMESTAMP AND NOT A FLAG. A flag says "this child reported", and that is
54
+ * not the same question as "this child reported ABOUT THE TURN THAT JUST ENDED". A flag stays
55
+ * true for the rest of the child's life, so it silences every automatic report the child ever
56
+ * produces again: the parent gives the same child a second task, the child finishes, and the
57
+ * result is swallowed because of a report the parent received about the FIRST task. The parent
58
+ * waits for a result that was written and never arrives, and nothing in the transcript says why.
59
+ *
60
+ * The timestamp answers the real question: the report was made INSIDE this turn, so it speaks for
61
+ * this turn. A report made before the turn started speaks for an earlier one, and this turn's
62
+ * result is still its own.
52
63
  */
53
- reportedChildren = new Set();
64
+ reportedChildren = new Map();
54
65
  constructor(store, conversationService, runService, ledger, broadcaster, watchdog) {
55
66
  this.store = store;
56
67
  this.conversationService = conversationService;
@@ -180,6 +191,11 @@ export class SessionOrchestratorService {
180
191
  // writes the words under the reader's own name - so the child's transcript showed an
181
192
  // instruction the reader never typed, wearing the reader's avatar, and a reader who looked at
182
193
  // the child session saw their own name on somebody else's order.
194
+ //
195
+ // THE TURN'S START IS RECORDED HERE, because the question "did this child already report THIS
196
+ // turn?" is answered by comparing the report's time against it. A report made inside the turn
197
+ // speaks for the turn; a report made before it speaks for an earlier one.
198
+ const turnStartedAt = Date.now();
183
199
  const runPromise = this.runService.enqueueAndRunAsAgent(conversationId, content);
184
200
  const completion = (async () => {
185
201
  let failure;
@@ -238,9 +254,13 @@ export class SessionOrchestratorService {
238
254
  // is what it produced"; the explicit report says "the child is telling its parent something
239
255
  // now", and can be called at any point in the task's life.
240
256
  const turnEnded = current?.status === "completed" || current?.status === "failed";
241
- // A CHILD THAT REPORTED ITSELF IS NOT REPORTED TWICE. The explicit terminal report is the
242
- // child's own statement of what it did, so the automatic completion steps aside for it.
243
- const alreadyReported = this.reportedChildren.has(conversationId);
257
+ // A CHILD THAT REPORTED ITSELF IS NOT REPORTED TWICE - BUT ONLY FOR THE TURN IT REPORTED.
258
+ // The explicit terminal report is the child's own statement of what it did, so the
259
+ // automatic completion steps aside for it - when the report was made inside THIS turn.
260
+ // A report from an earlier turn says nothing about this one's result, and silencing this
261
+ // one because of that would swallow a result the parent is still waiting for.
262
+ const reportedAt = this.reportedChildren.get(conversationId);
263
+ const alreadyReported = reportedAt !== undefined && reportedAt >= turnStartedAt;
244
264
  if (turnEnded && !alreadyReported) {
245
265
  await this.runService.enqueueSessionResult({
246
266
  conversationId,
@@ -260,6 +280,16 @@ export class SessionOrchestratorService {
260
280
  // turn an already-finished child turn into a second failure.
261
281
  console.warn(`Super Pi: session result callback failed for ${conversationId}:`, error instanceof Error ? error.message : String(error));
262
282
  }
283
+ finally {
284
+ // THE RECORD IS SPENT BY THE TURN IT SPOKE FOR. Whatever it was about, that turn is over.
285
+ //
286
+ // A record left behind would be harmless - the timestamp comparison already keeps it from
287
+ // silencing a later turn - but it would keep saying "this child reported" to every future
288
+ // reader of this code, and the next thing that consults it would have to re-derive the
289
+ // timestamp rule. Dropping it here keeps one rule in one place: a report belongs to the
290
+ // turn that was running when it was made.
291
+ this.reportedChildren.delete(conversationId);
292
+ }
263
293
  if (failure !== undefined) {
264
294
  console.warn(`Super Pi: dispatch failed for conversation ${conversationId}:`, failure instanceof Error ? failure.message : String(failure));
265
295
  this.ledger.update(conversationId, { status: "failed" });
@@ -391,7 +421,10 @@ export class SessionOrchestratorService {
391
421
  // does not silence the automatic report: the child is still working, and the parent will want
392
422
  // to hear how the task ended.
393
423
  if (status === "completed" || status === "failed") {
394
- this.reportedChildren.add(childConversationId);
424
+ // THE TIME OF THE REPORT IS THE PART THAT MATTERS. It is what tells the completion path
425
+ // whether this report spoke for the turn that is ending or for an earlier one, and it is
426
+ // stamped here - where the report is made - rather than where it is looked up later.
427
+ this.reportedChildren.set(childConversationId, Date.now());
395
428
  await this.watchdog.cancel(childConversationId);
396
429
  }
397
430
  else {
@@ -662,6 +695,95 @@ export class SessionOrchestratorService {
662
695
  await this.watchdog.cancel(conversationId);
663
696
  return { stopped: true, mode };
664
697
  }
698
+ /**
699
+ * THE SESSIONS UNDER ONE SESSION, DEEPEST LAST.
700
+ *
701
+ * The tree edge lives on the conversation itself (`parentConversationId`), so "who is under this
702
+ * one?" is a walk over the workspace's conversations rather than a separate structure that could
703
+ * disagree with it. The order is breadth-first, which is depth order - and the caller stops them
704
+ * in reverse, so the deepest work dies before the session that owns it.
705
+ */
706
+ descendantsOf(rootConversationId) {
707
+ const root = this.store.conversation(rootConversationId);
708
+ if (!root)
709
+ return [];
710
+ const childrenByParent = new Map();
711
+ for (const conversation of this.store.conversationsForWorkspace(root.workspaceId)) {
712
+ const parentId = conversation.parentConversationId;
713
+ if (!parentId)
714
+ continue;
715
+ const siblings = childrenByParent.get(parentId);
716
+ if (siblings)
717
+ siblings.push(conversation.id);
718
+ else
719
+ childrenByParent.set(parentId, [conversation.id]);
720
+ }
721
+ const ordered = [];
722
+ const seen = new Set([rootConversationId]);
723
+ const queue = [rootConversationId];
724
+ while (queue.length) {
725
+ const current = queue.shift();
726
+ for (const child of childrenByParent.get(current) ?? []) {
727
+ // A cycle in the stored edges must not turn this into an endless walk.
728
+ if (seen.has(child))
729
+ continue;
730
+ seen.add(child);
731
+ ordered.push(child);
732
+ queue.push(child);
733
+ }
734
+ }
735
+ return ordered;
736
+ }
737
+ /**
738
+ * STOPS A SESSION AND EVERYTHING UNDER IT.
739
+ *
740
+ * THIS IS NOT THE SAME THING AS STOPPING A SESSION, and it is deliberately a separate operation
741
+ * rather than a flag on the other one. Stopping a session stops THAT session: its turn, its queued
742
+ * messages, its watchdog. Nothing else. That behaviour is right for the ordinary case - a child
743
+ * that went down the wrong path should not take its own children down with it, because those
744
+ * children may be doing work the parent still wants.
745
+ *
746
+ * But there is a case where the ordinary case leaves a mess. A supervisor whose own supervisor has
747
+ * stopped is an ORPHAN, and the sessions it started are orphans too: they keep burning tokens,
748
+ * keep writing files, and report their results to a session that is no longer running - nobody is
749
+ * left to read them. The parent sees a child that has gone wrong, stops it, and the grandchildren
750
+ * keep running with no parent to stop them.
751
+ *
752
+ * So this operation takes the whole tree: the root, its children, its grandchildren, deepest
753
+ * first. The descendants are stopped AS A CONSEQUENCE of the root being stopped - the caller is
754
+ * still only managing its OWN direct child, and the cross-level reach the scope rules forbid is
755
+ * not what happens here: a grandchild cannot be stopped on its own, and it cannot be spared
756
+ * either, because it cannot outlive the session that owns it.
757
+ */
758
+ async stopSubtree(conversationId, options) {
759
+ const root = this.store.conversation(conversationId);
760
+ if (!root) {
761
+ throw new Error(`找不到会话:${conversationId}`);
762
+ }
763
+ const descendants = this.descendantsOf(conversationId);
764
+ const stoppedIds = [];
765
+ // DEEPEST FIRST. A session that is stopped while its children still run is exactly the zombie
766
+ // this operation exists to prevent, so the leaves go before their parents.
767
+ for (const descendantId of [...descendants].reverse()) {
768
+ try {
769
+ await this.stop(descendantId, options);
770
+ stoppedIds.push(descendantId);
771
+ }
772
+ catch (error) {
773
+ // ONE SESSION THAT WILL NOT STOP MUST NOT LEAVE THE REST RUNNING. The point of the cascade
774
+ // is that nothing survives, so a failure on one session is recorded and the sweep goes on.
775
+ console.warn(`Super Pi: stopping descendant ${descendantId} failed:`, error instanceof Error ? error.message : String(error));
776
+ }
777
+ }
778
+ const result = await this.stop(conversationId, options);
779
+ stoppedIds.push(conversationId);
780
+ return {
781
+ stopped: result.stopped,
782
+ mode: result.mode,
783
+ stoppedIds,
784
+ descendantCount: descendants.length,
785
+ };
786
+ }
665
787
  /**
666
788
  * Resumes execution in a conversation.
667
789
  * If prompt is provided, sends it as a new turn.
@@ -1614,6 +1614,21 @@ export function createHttpServer(options) {
1614
1614
  json(200, { ok: true, ...result });
1615
1615
  return;
1616
1616
  }
1617
+ if (action === "stop_subtree") {
1618
+ if (!body.conversationId)
1619
+ throw new Error("stop_subtree requires conversationId");
1620
+ // THE ROOT OF THE TREE IS STILL THE CALLER'S DIRECT CHILD. The scope rule does not move:
1621
+ // the descendants go down with their owner, they are not reachable on their own, and a
1622
+ // caller still cannot stop somebody else's branch.
1623
+ const scope = verifyTargetScope(body.conversationId, "停止");
1624
+ if (!scope.ok) {
1625
+ json(scope.status, { error: scope.error });
1626
+ return;
1627
+ }
1628
+ const result = await service.stopSubtree(body.conversationId, { mode: body.mode, messageId: body.messageId });
1629
+ json(200, { ok: true, ...result });
1630
+ return;
1631
+ }
1617
1632
  if (action === "resume") {
1618
1633
  if (!body.conversationId)
1619
1634
  throw new Error("resume requires conversationId");
@@ -1691,6 +1706,56 @@ export function createHttpServer(options) {
1691
1706
  }
1692
1707
  return;
1693
1708
  }
1709
+ // THE PERSON'S OWN STOP.
1710
+ //
1711
+ // The endpoint above is the AGENT's path, and it authenticates with the session's capability,
1712
+ // because a dispatch spends model quota on somebody's behalf and the capability is what says
1713
+ // "this really is that session". A person clicking "stop" in their own browser is not that:
1714
+ // stopping REDUCES work, it grants nothing, and a browser has no capability to present - so
1715
+ // this path is refused by the address guard instead, the same guard every other user-facing
1716
+ // write in this server carries.
1717
+ //
1718
+ // WHY IT IS A SEPARATE PATH AND NOT A FLAG ON THE AGENT'S ENDPOINT. The two have different
1719
+ // authorities: one proves a session is speaking, the other proves a person is. Mixing them
1720
+ // would mean either the browser gets a dispatch credential it must not have, or the agent
1721
+ // loses the proof that it is the agent.
1722
+ if (url.pathname === "/api/orchestrator/stop") {
1723
+ if (request.method !== "POST" || !isLocalOrPrivateAddress(remote)) {
1724
+ response.writeHead(403, { "content-type": "text/plain; charset=utf-8" }).end("Stopping a session requires a loopback or private network POST");
1725
+ return;
1726
+ }
1727
+ const asked = await readRequestText(request, 64 * 1024);
1728
+ if (asked.tooLarge) {
1729
+ response.writeHead(413, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify({ error: "请求体过大", code: "too_large" }));
1730
+ return;
1731
+ }
1732
+ const json = (status, body) => {
1733
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
1734
+ };
1735
+ try {
1736
+ const service = context.sessionOrchestratorService;
1737
+ if (!service) {
1738
+ json(503, { error: "编排服务未就绪" });
1739
+ return;
1740
+ }
1741
+ const body = JSON.parse(asked.text);
1742
+ const conversationId = body.conversationId?.trim();
1743
+ if (!conversationId) {
1744
+ json(400, { error: "缺少 conversationId" });
1745
+ return;
1746
+ }
1747
+ // THE TREE GOES WITH THE ROOT WHEN THE PERSON ASKS FOR THE TREE. Stopping one session and
1748
+ // stopping its whole branch are two different intentions, and the person says which.
1749
+ const result = body.subtree
1750
+ ? await service.stopSubtree(conversationId, { mode: body.mode, messageId: body.messageId })
1751
+ : await service.stop(conversationId, { mode: body.mode, messageId: body.messageId });
1752
+ json(200, { ok: true, ...result });
1753
+ }
1754
+ catch (error) {
1755
+ json(400, { error: error instanceof Error ? error.message : "stop failed" });
1756
+ }
1757
+ return;
1758
+ }
1694
1759
  if (url.pathname === "/api/conversation/usage") {
1695
1760
  const json = (status, payload) => {
1696
1761
  response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }).end(JSON.stringify(payload));
@@ -24,7 +24,7 @@ import { LengthExceededError } from "./turn-failure.js";
24
24
  // paths recognise a delivery identically. Re-exported because they were part of this module's surface.
25
25
  export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
26
26
  import { createPiSession, createSdkCatalog, piSdkAvailable, resolveTranscriptFile, superPiExtensionPaths } from "./pi-session.js";
27
- import { TASK_CONVERSATION_ENV, PARENT_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, SCHEDULED_TASK_ENDPOINT_ENV, SCHEDULED_TASK_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, ORCHESTRATOR_ENDPOINT_ENV, ORCHESTRATOR_WORKSPACE_ENV, SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, SESSION_REPORT_TOOL_NAME, rosterPayload, } from "./task-tool.js";
27
+ import { TASK_CONVERSATION_ENV, PARENT_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_ENDPOINT_ENV, TASK_ROSTER_ENV, TASK_TOOL_NAME, TODO_ENDPOINT_ENV, TODO_TOOL_NAME, SCHEDULED_TASK_ENDPOINT_ENV, SCHEDULED_TASK_TOOL_NAME, WORKFLOW_ENDPOINT_ENV, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, ORCHESTRATOR_ENDPOINT_ENV, ORCHESTRATOR_WORKSPACE_ENV, SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, SESSION_REPORT_TOOL_NAME, SESSION_STOP_SUBTREE_TOOL_NAME, rosterPayload, } from "./task-tool.js";
28
28
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
29
29
  import { loadConversationImages } from "./upload.js";
30
30
  import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
@@ -1260,7 +1260,7 @@ export class PiRpcRuntime {
1260
1260
  if (process.env.SUPER_PI_FEISHU_ENDPOINT)
1261
1261
  names.push(FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME);
1262
1262
  if (this.options.orchestratorEndpoint)
1263
- names.push(SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, SESSION_REPORT_TOOL_NAME);
1263
+ names.push(SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, SESSION_REPORT_TOOL_NAME, SESSION_STOP_SUBTREE_TOOL_NAME);
1264
1264
  return names;
1265
1265
  }
1266
1266
  /**
@@ -1479,7 +1479,7 @@ export class PiRpcRuntime {
1479
1479
  // allowlist and naming a tool Pi has not registered would change the spawn line for every other
1480
1480
  // mode. The prompt asks for a bare JSON array and nothing else; if the model spends a turn on a
1481
1481
  // tool call anyway the reranker times out and falls back to the lexical order.
1482
- const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, SCHEDULED_TASK_TOOL_NAME, "question", "tool_register", ...(this.options.orchestratorEndpoint ? [SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, SESSION_REPORT_TOOL_NAME] : [])] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME] : []), ...(documentsEnabled ? [DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME] : []), ...(webEnabled ? [WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME] : []), ...(feishuEnabled ? [FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME] : [])] });
1482
+ const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, SCHEDULED_TASK_TOOL_NAME, "question", "tool_register", ...(this.options.orchestratorEndpoint ? [SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_TOOL_NAME, SESSION_REPORT_TOOL_NAME, SESSION_STOP_SUBTREE_TOOL_NAME] : [])] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME] : []), ...(documentsEnabled ? [DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME] : []), ...(webEnabled ? [WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME] : []), ...(feishuEnabled ? [FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME] : [])] });
1483
1483
  // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
1484
1484
  // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
1485
1485
  if (!this.pi.found)
@@ -258,6 +258,7 @@ export declare const SESSION_STATUS_TOOL_NAME = "session_status";
258
258
  export declare const SESSION_DISPATCH_TOOL_NAME = "session_dispatch";
259
259
  export declare const SESSION_STOP_TOOL_NAME = "session_stop";
260
260
  export declare const SESSION_REPORT_TOOL_NAME = "session_report";
261
+ export declare const SESSION_STOP_SUBTREE_TOOL_NAME = "session_stop_subtree";
261
262
  /** Model-facing description for session_spawn. */
262
263
  export declare const SESSION_SPAWN_DESCRIPTION = "Create a new sibling conversation in the current workspace and immediately dispatch its initial task.\n\nDIFFERENCE FROM THE 'task' TOOL:\n- The 'task' tool delegates to a transient, one-shot subagent process (scout, critic, patcher) that runs once, returns its final text, and terminates. It has no conversation history and does not appear in the workspace sidebar.\n- 'session_spawn' creates a real, persistent sibling CONVERSATION in Super Pi. It appears in the sidebar, retains multi-turn message history, can receive follow-up messages via session_dispatch, can be inspected via session_status, and can be stopped via session_stop. The user can also switch to it in the Web UI.\n\nWHEN TO USE THIS:\n- Long-running, multi-phase tracks of work that require ongoing context, multiple turns of dialogue, or independent user visibility.\n- Parallel work streams (e.g., Track A: backend refactor, Track B: frontend component update) where both need full conversational capabilities.\n- When the user asks you to act as an orchestrator/manager who delegates to multiple sessions.\n\nWHEN NOT TO USE THIS:\n- For one-off, focused delegation with a single answer: use 'task' (with scout/critic/patcher). It is faster, cheaper, and returns directly to your transcript.\n- For running shell commands or build watchers: use 'run_async'.\n- For a linear multi-step subagent pipeline: use 'workflow'.\n\nUsage notes:\n1. Spawning is ASYNCHRONOUS by default: it creates the conversation, enqueues the initial task, and returns the new conversationId immediately. The sibling session runs in the background.\n2. The initial task should provide clear context and goals since the new session starts with a fresh conversation history.\n3. Use session_status with the returned conversationId to check progress, or session_dispatch to send further messages.\n4. The new session reports completion to its DIRECT parent automatically. 'parentConversationId' optionally selects that parent; by default it is the calling session.\n5. The new session always belongs to the calling session's workspace. Pass 'cwd' as a workspace-relative subdirectory (for example, 'packages/a') when it should work below the workspace root; absolute paths and traversal are rejected.\n6. Optionally pass 'model' to choose which model the NEW session runs on: {\"provider\":\"...\",\"modelId\":\"...\"} - the same \"provider/modelId\" pair the model picker shows, and BOTH halves are required. Leave 'model' out and the new session starts on the workspace default model.\n7. Optionally pass 'watchdogTimeoutSeconds' to declare how long YOU are willing to wait for a report before you want to be reminded: a positive number of seconds. If the new session stays silent that long, you receive a '[Watchdog \u63D0\u9192]' message naming it. A report from the child (session_report) restarts the clock; the watchdog never stops the child. Leave it out to run without a watchdog.";
263
264
  /** Model-facing description for session_status. */
@@ -268,3 +269,5 @@ export declare const SESSION_DISPATCH_DESCRIPTION = "Send a message, follow-up i
268
269
  export declare const SESSION_STOP_DESCRIPTION = "Stop execution in a sibling conversation.\n\nWHEN TO USE THIS:\n- When a sibling session has gone down the wrong path, is stuck in an unwanted loop, or when the overall goal has changed and remaining work should be aborted.\n- To clear pending queued messages before they start running.\n\nUsage notes:\n1. 'mode' controls what to stop:\n - 'all' (default): stops the currently running turn and cancels all pending queued messages in the session.\n - 'active': only interrupts the active running turn; leaves any pending queued messages untouched.\n - 'queued': only cancels pending queued messages; lets the currently running turn complete naturally.\n2. Use 'messageId' if you want to cancel or interrupt one specific queued message instead of all queued messages.";
269
270
  /** Model-facing description for session_report. */
270
271
  export declare const SESSION_REPORT_DESCRIPTION = "Report progress from THIS session to the parent session that spawned it.\n\nWHEN TO USE THIS:\n- You are a spawned sibling session, and the parent is waiting to hear from you: a milestone is done, a roadblock appeared, or the task is finished.\n- The parent set a watchdog (session_spawn's 'watchdogTimeoutSeconds'): a report is what restarts that clock, and silence is what triggers the reminder.\n- The task is over and the parent needs the outcome NOW rather than after the automatic end-of-turn result.\n\nWHEN NOT TO USE THIS:\n- This session has no parent (the user opened it directly): there is nobody to report to, and the server refuses the call.\n- You want to ASK the parent something or send it new instructions: that is session_dispatch, and only the parent can send it. A report is one-way - the parent receives it as a message.\n\nUsage notes:\n1. 'content' is required: it is the text the parent receives. Write it for the parent - what is done, what is left, what you need.\n2. 'status' says what the report MEANS:\n - 'in_progress' (default): an interim update. The task continues, and the parent's watchdog clock restarts.\n - 'completed': the task is finished and this is your final word.\n - 'failed': the task could not be finished, and this says why.\n3. A 'completed' or 'failed' report tells the server the work is over, so the parent is NOT also sent the automatic end-of-turn result. Send it once, at the end, with the final answer in 'content'.\n4. Reporting does NOT end your turn. If the task is not done, keep working afterwards - the parent is waiting on the work, not on the report.\n5. The report reaches your DIRECT parent only. A sibling, a cousin, and the user's own session are all out of reach, and the server refuses those.";
272
+ /** Model-facing description for session_stop_subtree. */
273
+ export declare const SESSION_STOP_SUBTREE_DESCRIPTION = "Stop a sibling session AND every session underneath it.\n\nWHEN TO USE THIS:\n- The session has children of its own and you are stopping it: those children would otherwise keep running with a dead parent - zombies that burn tokens, keep writing files, and report results nobody is left to read.\n- The whole branch should go: stopping only the top leaves the work underneath it alive.\n- A child went wrong and everything it started has to go down with it.\n\nWHEN NOT TO USE THIS:\n- The session has no children: session_stop does the same thing to one session and says less.\n- The session is only misbehaving at the top and its children are doing work you still want: stop it alone with session_stop.\n- You want to cancel one queued message: session_stop's 'messageId'.\n\nUsage notes:\n1. 'conversationId' must be your own DIRECT child, exactly as for session_stop. You are not reaching across levels to manage anybody.\n2. Everything under that session goes down with it - children, grandchildren, the whole tree - deepest first, so nothing is left running under a session that is already gone.\n3. The descendants are stopped AS A CONSEQUENCE of stopping the root. They cannot be spared, because a session cannot outlive the one that owns it.\n4. The answer says how many sessions were stopped in total, including the one you named.";
@@ -363,6 +363,7 @@ export const SESSION_STATUS_TOOL_NAME = "session_status";
363
363
  export const SESSION_DISPATCH_TOOL_NAME = "session_dispatch";
364
364
  export const SESSION_STOP_TOOL_NAME = "session_stop";
365
365
  export const SESSION_REPORT_TOOL_NAME = "session_report";
366
+ export const SESSION_STOP_SUBTREE_TOOL_NAME = "session_stop_subtree";
366
367
  /** Model-facing description for session_spawn. */
367
368
  export const SESSION_SPAWN_DESCRIPTION = `Create a new sibling conversation in the current workspace and immediately dispatch its initial task.
368
369
 
@@ -447,3 +448,21 @@ Usage notes:
447
448
  3. A 'completed' or 'failed' report tells the server the work is over, so the parent is NOT also sent the automatic end-of-turn result. Send it once, at the end, with the final answer in 'content'.
448
449
  4. Reporting does NOT end your turn. If the task is not done, keep working afterwards - the parent is waiting on the work, not on the report.
449
450
  5. The report reaches your DIRECT parent only. A sibling, a cousin, and the user's own session are all out of reach, and the server refuses those.`;
451
+ /** Model-facing description for session_stop_subtree. */
452
+ export const SESSION_STOP_SUBTREE_DESCRIPTION = `Stop a sibling session AND every session underneath it.
453
+
454
+ WHEN TO USE THIS:
455
+ - The session has children of its own and you are stopping it: those children would otherwise keep running with a dead parent - zombies that burn tokens, keep writing files, and report results nobody is left to read.
456
+ - The whole branch should go: stopping only the top leaves the work underneath it alive.
457
+ - A child went wrong and everything it started has to go down with it.
458
+
459
+ WHEN NOT TO USE THIS:
460
+ - The session has no children: session_stop does the same thing to one session and says less.
461
+ - The session is only misbehaving at the top and its children are doing work you still want: stop it alone with session_stop.
462
+ - You want to cancel one queued message: session_stop's 'messageId'.
463
+
464
+ Usage notes:
465
+ 1. 'conversationId' must be your own DIRECT child, exactly as for session_stop. You are not reaching across levels to manage anybody.
466
+ 2. Everything under that session goes down with it - children, grandchildren, the whole tree - deepest first, so nothing is left running under a session that is already gone.
467
+ 3. The descendants are stopped AS A CONSEQUENCE of stopping the root. They cannot be spared, because a session cannot outlive the one that owns it.
468
+ 4. The answer says how many sessions were stopped in total, including the one you named.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evandu/super-pi",
3
- "version": "0.1.148",
3
+ "version": "0.1.150",
4
4
  "description": "Web control plane and companion for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -217,6 +217,7 @@
217
217
  "scripts/background-result-steer.test.mjs",
218
218
  "scripts/session-report-watchdog.test.mjs",
219
219
  "scripts/session-report-tool.test.mjs",
220
+ "scripts/session-stop-subtree.test.mjs",
220
221
  "scripts/awaiting-background-status.test.mjs"
221
222
  ],
222
223
  "check:browser": [