@evandu/super-pi 0.1.146 → 0.1.147

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 (62) hide show
  1. package/agents/super-pi-task/core.ts +60 -1
  2. package/agents/super-pi-task/index.ts +4 -0
  3. package/dist/.super-pi-check-inputs.json +2 -2
  4. package/dist/client/.super-pi-client-sources.json +8 -8
  5. package/dist/client/assets/{CodeMirrorEditor-CEwBCVM6.js → CodeMirrorEditor-DQCjEnxG.js} +2 -2
  6. package/dist/client/assets/{dist-DC1IocrS.js → dist-1Innu17E.js} +1 -1
  7. package/dist/client/assets/{dist-CSCUT1PQ.js → dist-1lynFOc5.js} +1 -1
  8. package/dist/client/assets/{dist-Do52MRwm.js → dist-B1qLZVMK.js} +1 -1
  9. package/dist/client/assets/{dist-CAOcjKAQ.js → dist-B4pz_xdd.js} +1 -1
  10. package/dist/client/assets/{dist-B0RXZeXQ.js → dist-B6KwDpeI.js} +1 -1
  11. package/dist/client/assets/{dist-DFMtP3wG.js → dist-BJVI1Unv.js} +1 -1
  12. package/dist/client/assets/{dist-oLq6HXLG.js → dist-BWvzD8Pe.js} +1 -1
  13. package/dist/client/assets/{dist-9F1wL-T5.js → dist-BayWssd0.js} +1 -1
  14. package/dist/client/assets/{dist-DBUqwiWG.js → dist-Bk_pkBVH.js} +1 -1
  15. package/dist/client/assets/{dist-CbVf2Cjn.js → dist-BpeClAv0.js} +1 -1
  16. package/dist/client/assets/{dist-DftD4ibj.js → dist-BsmJg5PX.js} +1 -1
  17. package/dist/client/assets/{dist-CUB_Chdh.js → dist-Bx7s4rni.js} +1 -1
  18. package/dist/client/assets/{dist-Bh8Q7Y_n.js → dist-CDBKM9tL.js} +1 -1
  19. package/dist/client/assets/{dist-BoyICDlL.js → dist-CGzXCzCG.js} +1 -1
  20. package/dist/client/assets/{dist-BCNaDgNW.js → dist-CRxCwn-Z.js} +1 -1
  21. package/dist/client/assets/{dist-76ArZ5M4.js → dist-CTDgUnBG.js} +1 -1
  22. package/dist/client/assets/{dist-BXBSIuTD.js → dist-D1VfMWmf.js} +1 -1
  23. package/dist/client/assets/{dist-DN0GlnnZ.js → dist-DTZLNEci.js} +1 -1
  24. package/dist/client/assets/{dist-JvQIIdqB.js → dist-DuUi29km.js} +1 -1
  25. package/dist/client/assets/{dist-CHt8PX8R.js → dist-F3S0t1oM.js} +1 -1
  26. package/dist/client/assets/{dist-BuR5Iagd.js → dist-QZ-FAVJV.js} +1 -1
  27. package/dist/client/assets/{dist-Bx6ZWYUW.js → dist-u0nBg0fZ.js} +1 -1
  28. package/dist/client/assets/index-BMpqviwU.css +1 -0
  29. package/dist/client/assets/{index-E7yaLa2C.js → index-Dzyp_7sf.js} +43 -43
  30. package/dist/client/index.html +2 -2
  31. package/dist/server/active-memory.d.ts +17 -0
  32. package/dist/server/active-memory.js +39 -0
  33. package/dist/server/application/conversation-service.js +6 -0
  34. package/dist/server/application/run-service.d.ts +16 -0
  35. package/dist/server/application/run-service.js +175 -26
  36. package/dist/server/application/session-orchestrator-service.d.ts +103 -1
  37. package/dist/server/application/session-orchestrator-service.js +303 -8
  38. package/dist/server/application/session-watchdog.d.ts +159 -0
  39. package/dist/server/application/session-watchdog.js +244 -0
  40. package/dist/server/http-server.js +136 -76
  41. package/dist/server/index.js +3 -0
  42. package/dist/server/knowledge-vectors.d.ts +15 -0
  43. package/dist/server/knowledge-vectors.js +118 -37
  44. package/dist/server/knowledge.d.ts +7 -8
  45. package/dist/server/knowledge.js +133 -55
  46. package/dist/server/length-continuation.d.ts +50 -0
  47. package/dist/server/length-continuation.js +55 -0
  48. package/dist/server/pi-session-events.js +5 -1
  49. package/dist/server/pi-session-pool.d.ts +2 -0
  50. package/dist/server/pi-session-pool.js +16 -0
  51. package/dist/server/runtime.js +2 -1
  52. package/dist/server/session-tree-scope.d.ts +125 -0
  53. package/dist/server/session-tree-scope.js +337 -0
  54. package/dist/server/store.d.ts +13 -0
  55. package/dist/server/store.js +37 -4
  56. package/dist/server/task-tool.d.ts +1 -1
  57. package/dist/server/task-tool.js +2 -1
  58. package/dist/server/turn-failure.d.ts +26 -0
  59. package/dist/server/turn-failure.js +51 -0
  60. package/dist/server/types.d.ts +15 -1
  61. package/package.json +19 -3
  62. package/dist/client/assets/index-D-OxlUxN.css +0 -1
@@ -25,6 +25,7 @@ import { join } from "node:path";
25
25
  import { createPiSession, sameFingerprint, superPiExtensionPaths, superPiSkillPaths, } from "./pi-session.js";
26
26
  /** How long an idle session survives before it is evicted. */
27
27
  const IDLE_EVICTION_MS = 10 * 60_000;
28
+ const MAX_LIVE_SESSIONS = Math.max(4, Number.parseInt(process.env.SUPER_PI_MAX_LIVE_SESSIONS ?? "32", 10) || 32);
28
29
  /**
29
30
  * The environment variable the session's credential travels in.
30
31
  *
@@ -119,6 +120,7 @@ export class PiSessionPool {
119
120
  * tool allowlist has changed.
120
121
  */
121
122
  async acquire(request) {
123
+ await this.evictOverflow(request.conversationId);
122
124
  const fingerprint = this.fingerprintOf(request);
123
125
  const existing = this.sessions.get(request.conversationId);
124
126
  if (existing) {
@@ -182,6 +184,20 @@ export class PiSessionPool {
182
184
  throw error;
183
185
  }
184
186
  }
187
+ /** Keep an accidental session fan-out from retaining one full transcript context per conversation. */
188
+ async evictOverflow(exceptConversationId) {
189
+ if (this.sessions.size < MAX_LIVE_SESSIONS)
190
+ return;
191
+ const candidates = [...this.sessions.entries()]
192
+ .filter(([id, entry]) => id !== exceptConversationId && !entry.building)
193
+ .sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt);
194
+ while (this.sessions.size >= MAX_LIVE_SESSIONS && candidates.length) {
195
+ const [conversationId] = candidates.shift();
196
+ if (process.env.SUPER_PI_TRACE_LATENCY === "1")
197
+ console.warn(`[super-pi latency] conversation=${conversationId} phase=session.max-live-evict`);
198
+ await this.evict(conversationId);
199
+ }
200
+ }
185
201
  async build(request, fingerprint) {
186
202
  const creation = {
187
203
  cwd: request.cwd,
@@ -18,6 +18,7 @@ import { TASK_CAPABILITY_ENV, mintCapability, releaseCapability } from "./capabi
18
18
  import { resolvePiCommand, shellQuote } from "./pi-command.js";
19
19
  import { PiSessionPool, repositoryRootFrom } from "./pi-session-pool.js";
20
20
  import { describeDeliveredUserMessage, describePiQueue, handleSessionEvent, TurnQueue } from "./pi-session-events.js";
21
+ import { LengthExceededError } from "./turn-failure.js";
21
22
  // The queue/steer recognisers live in pi-session-events.ts, where the SESSION path's bridge can use
22
23
  // them; the one-shot drain loop below reads the same records through the same two functions, so both
23
24
  // paths recognise a delivery identically. Re-exported because they were part of this module's surface.
@@ -1604,7 +1605,7 @@ export class PiRpcRuntime {
1604
1605
  if (reason === "aborted")
1605
1606
  throw new Error(outcome.errorMessage || "Pi 意外中止了本轮回答。");
1606
1607
  if (reason === "length")
1607
- throw new Error(outcome.errorMessage || "模型输出达到本轮长度上限,回答未完成。");
1608
+ throw new LengthExceededError(outcome.errorMessage || "模型输出达到本轮长度上限,回答未完成。");
1608
1609
  };
1609
1610
  let firstPiRecord = true;
1610
1611
  // Nothing may be dropped without a trace. Pi emits a wide, open-ended family of agent
@@ -0,0 +1,125 @@
1
+ import type { Store } from "./store.js";
2
+ /**
3
+ * THE SESSION TREE'S SCOPE RULE.
4
+ *
5
+ * A session may only manage its DIRECT children: the sessions whose `parentConversationId` is
6
+ * exactly its own id. Nothing else. A grandchild is a level away, a peer sits in somebody else's
7
+ * branch, an ancestor is above - none of them is this session's subordinate, so none of them may be
8
+ * dispatched to, read, stopped or resumed by it. A workspace is NOT an organisation chart: two
9
+ * supervisors can sit in the same workspace without being each other's subordinates.
10
+ *
11
+ * WHY THE RULE IS ONE LINE. The tree edge is written on the conversation itself at birth
12
+ * (`parentConversationId`), so "is this session my child?" is a comparison, not a walk. The walk up
13
+ * the chain exists for ONE purpose only: writing a refusal that says WHY, so the reader knows
14
+ * whether it asked for a peer, a grandchild or somebody else's department. The walk NEVER decides
15
+ * anything - a refusal is a refusal because the parent is not the caller, never because the walk
16
+ * came back empty.
17
+ *
18
+ * WHY THIS MATTERS AT ALL. The result callback of a dispatched session goes back to the session that
19
+ * OWNS it - the one whose id is the target's `parentConversationId`. If a session could dispatch to
20
+ * a session it does not own, that session's result would report to somebody else, and the tree the
21
+ * user sees in the sidebar would stop being the tree the work actually flows through. Making the
22
+ * target's parent be the caller is what keeps "who gave the order is who gets the report" true by
23
+ * construction.
24
+ */
25
+ /** How the caller and the target relate in the session tree. Computed for the MESSAGE only. */
26
+ export type SessionRelation = "self" | "direct-child" | "descendant" | "ancestor" | "peer" | "other-root" | "unrelated";
27
+ /** The outcome of a scope check: allowed, or a refusal with the status code and the reason. */
28
+ export type ScopeVerdict = {
29
+ ok: true;
30
+ } | {
31
+ ok: false;
32
+ status: number;
33
+ error: string;
34
+ };
35
+ /** The verb a refusal is about, so the message reads like the action that was refused. */
36
+ export type ScopeVerb = "调度" | "读取" | "停止" | "恢复" | "列出" | "看护";
37
+ /** What the caller was trying to reach: a session to manage, a session's children, or a parent to claim. */
38
+ export type ScopeSubject = "manage" | "list-children" | "declare-parent";
39
+ /** The narrow slice of the store this file needs. */
40
+ type ConversationReader = Pick<Store, "conversation">;
41
+ /**
42
+ * The relation between two sessions, in the tree.
43
+ *
44
+ * READ ONLY. This classifies a pair for the sake of the refusal message; it never grants access.
45
+ */
46
+ export declare const classifySessionRelation: (store: ConversationReader, callerConversationId: string, targetConversationId: string) => SessionRelation;
47
+ /**
48
+ * Writes the refusal for a pair that is not allowed to be reached.
49
+ *
50
+ * The three shapes, because the three failures are different failures:
51
+ * - managing a session -> 「不能调度/读取/停止/恢复会话 X」
52
+ * - listing a session's kids -> 「不能列出会话 X 的下属」
53
+ * - claiming a parent -> 「不能把会话 X 声明为自己的下属」
54
+ */
55
+ export declare const describeScopeRefusal: (options: {
56
+ store: ConversationReader;
57
+ callerConversationId: string;
58
+ targetConversationId: string;
59
+ verb: ScopeVerb;
60
+ subject: ScopeSubject;
61
+ }) => string;
62
+ /**
63
+ * Checks that the caller may manage the target: the target must be the caller's DIRECT child.
64
+ *
65
+ * The workspace check is NOT part of this rule and is NOT removed by it - a conversation has to be
66
+ * in the caller's workspace AND in the caller's own subtree. The workspace refusal comes first
67
+ * because it is the older, coarser boundary, and its message names the two workspaces.
68
+ */
69
+ export declare const verifyDirectChildScope: (options: {
70
+ store: ConversationReader;
71
+ callerConversationId: string;
72
+ targetConversationId: string;
73
+ verb?: ScopeVerb;
74
+ }) => ScopeVerdict;
75
+ /**
76
+ * Checks that the caller may list a parent's children.
77
+ *
78
+ * The rule is EQUALITY, not parenthood: the parent whose children are listed must be the caller
79
+ * itself, so the only sessions on the list are the caller's own direct children. Listing the
80
+ * children of the caller's own child would be listing grandchildren, which is the cross-level reach
81
+ * this whole rule exists to refuse.
82
+ */
83
+ export declare const verifyListParentScope: (options: {
84
+ store: ConversationReader;
85
+ callerConversationId: string;
86
+ requestedParentId?: string;
87
+ }) => ScopeVerdict;
88
+ /**
89
+ * Checks that the caller may REPORT to the target: the target must be the caller's DIRECT PARENT.
90
+ *
91
+ * THE REPORTING LINE IS THE TREE EDGE, READ BACKWARDS. Every other rule in this file asks "may this
92
+ * session manage that one?" - and the answer is always about a session's subordinates. A report is the
93
+ * one message that travels UP, so the rule is inverted: a child reports to the session that owns it,
94
+ * the one whose id is the child's `parentConversationId`, and to nobody else.
95
+ *
96
+ * WHY THE INVERSION IS NOT A LOOPHOLE. A peer is somebody else's child, an ancestor is above the
97
+ * caller's own parent, a grandchild is below the caller, and a session in another workspace is another
98
+ * department. Every one of them would put one branch's news into another branch's inbox, and the inbox
99
+ * is where a parent's NEXT ACTION is decided - so a report that crossed a branch would be a stranger's
100
+ * news steering the wrong supervisor.
101
+ *
102
+ * THE RECIPIENT IS DERIVED, NOT CHOSEN. This check exists for the case where a caller NAMES a
103
+ * recipient at all: the server derives the parent from the tree and this refuses anything else, so a
104
+ * model cannot redirect its own report up or sideways.
105
+ */
106
+ export declare const verifyReportTargetScope: (options: {
107
+ store: ConversationReader;
108
+ callerConversationId: string;
109
+ targetConversationId: string;
110
+ }) => ScopeVerdict;
111
+ /**
112
+ * Checks the parent a new session is created under.
113
+ *
114
+ * The parent edge is written onto the new conversation at birth, and the result callback follows
115
+ * that edge home. A caller that names somebody ELSE as the parent would therefore rewrite that
116
+ * session's reporting line - putting the victim's result into the caller's inbox - so the parent
117
+ * must be the caller. An empty parent means a top-level session, which is what the user's own "+"
118
+ * creates and what a session with no owner legitimately is.
119
+ */
120
+ export declare const verifySpawnParentScope: (options: {
121
+ store: ConversationReader;
122
+ callerConversationId: string;
123
+ requestedParentId?: string;
124
+ }) => ScopeVerdict;
125
+ export {};
@@ -0,0 +1,337 @@
1
+ /** The depth cap on an ancestor walk. A tree deeper than this is a bug, not a reason to hang. */
2
+ const MAX_ANCESTOR_DEPTH = 64;
3
+ /**
4
+ * The chain of ancestors of a conversation, starting with the conversation itself.
5
+ *
6
+ * A dangling parent (one whose conversation is gone) simply ends the chain, and a cycle is cut by
7
+ * the depth cap: the walk is for the message, so it must terminate whatever the data says.
8
+ */
9
+ const ancestorChain = (store, conversationId) => {
10
+ const chain = [];
11
+ const seen = new Set();
12
+ let current = conversationId;
13
+ while (current && !seen.has(current) && chain.length < MAX_ANCESTOR_DEPTH) {
14
+ seen.add(current);
15
+ chain.push(current);
16
+ current = store.conversation(current)?.parentConversationId;
17
+ }
18
+ return chain;
19
+ };
20
+ /**
21
+ * The relation between two sessions, in the tree.
22
+ *
23
+ * READ ONLY. This classifies a pair for the sake of the refusal message; it never grants access.
24
+ */
25
+ export const classifySessionRelation = (store, callerConversationId, targetConversationId) => {
26
+ if (callerConversationId === targetConversationId)
27
+ return "self";
28
+ const callerChain = ancestorChain(store, callerConversationId);
29
+ const targetChain = ancestorChain(store, targetConversationId);
30
+ if (targetChain[1] === callerConversationId)
31
+ return "direct-child";
32
+ // The caller sits somewhere above the target: the target is a descendant, a level down or more.
33
+ if (targetChain.indexOf(callerConversationId) > 0)
34
+ return "descendant";
35
+ // The target sits above the caller.
36
+ if (callerChain.indexOf(targetConversationId) > 0)
37
+ return "ancestor";
38
+ // The target has no parent at all: it is the root of another tree.
39
+ if (targetChain.length === 1)
40
+ return "other-root";
41
+ // A shared ancestor means the same chart; without one, another department.
42
+ const shared = targetChain.slice(1).find((id) => callerChain.slice(1).includes(id));
43
+ return shared ? "peer" : "unrelated";
44
+ };
45
+ /** The session between the caller and the target, when the target is a descendant of it. */
46
+ const intermediateSession = (store, callerConversationId, targetConversationId) => {
47
+ const chain = ancestorChain(store, targetConversationId);
48
+ const index = chain.indexOf(callerConversationId);
49
+ return index > 1 ? chain[index - 1] : undefined;
50
+ };
51
+ /** The nearest session both the caller and the target descend from, when there is one. */
52
+ const commonAncestorSession = (store, callerConversationId, targetConversationId) => {
53
+ const callerChain = ancestorChain(store, callerConversationId);
54
+ const targetChain = ancestorChain(store, targetConversationId);
55
+ return targetChain.slice(1).find((id) => callerChain.slice(1).includes(id));
56
+ };
57
+ /**
58
+ * The reason a pair was refused, in the words of the relation that was found.
59
+ *
60
+ * The reader is a model about to retry, or a person reading a log: the message says which relation
61
+ * was found (peer, grandchild, ancestor, another tree, another department) and what the rule is, so
62
+ * a retry with a different target is an informed one.
63
+ *
64
+ * The three subjects read differently on purpose. Refusing to DISPATCH a peer and refusing to
65
+ * LIST a peer's subordinates are the same boundary, but they are not the same mistake, and a
66
+ * reader who is told which one they made can fix the right thing.
67
+ */
68
+ const relationReason = (relation, subject, callerConversationId, targetConversationId, verb, intermediateId, ancestorId) => {
69
+ if (subject === "list-children") {
70
+ switch (relation) {
71
+ case "direct-child":
72
+ return `列出 ${targetConversationId} 的下属属于跨级查询,跨级不被允许。`;
73
+ case "descendant":
74
+ return intermediateId
75
+ ? `${targetConversationId} 是 ${callerConversationId} 的隔级下属(中间隔着 ${intermediateId}),跨级列出下属不被允许。`
76
+ : `${targetConversationId} 是 ${callerConversationId} 的隔级下属,跨级列出下属不被允许。`;
77
+ case "ancestor":
78
+ return `${callerConversationId} 不能列出自己上级的下属。`;
79
+ case "peer":
80
+ return ancestorId
81
+ ? `${targetConversationId} 是 ${callerConversationId} 的平级会话(两者的共同上级是 ${ancestorId}),列出别人的下属属于越权。`
82
+ : `${targetConversationId} 是 ${callerConversationId} 的平级会话,列出别人的下属属于越权。`;
83
+ case "other-root":
84
+ return `${targetConversationId} 是一个独立的顶层会话,它的下属不属于 ${callerConversationId}。`;
85
+ case "unrelated":
86
+ return `${targetConversationId} 与 ${callerConversationId} 不在同一条会话树上(${targetConversationId} 不是 ${callerConversationId} 的直接下属),跨部门不被允许。`;
87
+ case "self":
88
+ return `会话不是自己的下属。`;
89
+ }
90
+ }
91
+ if (subject === "declare-parent") {
92
+ switch (relation) {
93
+ case "descendant":
94
+ return `${targetConversationId} 是 ${callerConversationId} 的隔级下属,不能被重新声明为 ${callerConversationId} 的直接下属。`;
95
+ case "ancestor":
96
+ return `${callerConversationId} 不能把会话 ${targetConversationId} 声明为自己的下属。`;
97
+ case "peer":
98
+ return `${targetConversationId} 是 ${callerConversationId} 的平级会话,不能被 ${callerConversationId} 收编为自己的下属。`;
99
+ case "other-root":
100
+ return `${targetConversationId} 是一个独立的顶层会话,不能被 ${callerConversationId} 声明为下属。`;
101
+ case "unrelated":
102
+ return `${targetConversationId} 与 ${callerConversationId} 不在同一条会话树上,不能被 ${callerConversationId} 声明为下属。`;
103
+ case "direct-child":
104
+ case "self":
105
+ return `会话不是自己的下属。`;
106
+ }
107
+ }
108
+ switch (relation) {
109
+ case "self":
110
+ return `会话不是自己的下属。`;
111
+ case "direct-child":
112
+ return `${targetConversationId} 是 ${callerConversationId} 的直接子会话。`;
113
+ case "descendant":
114
+ return intermediateId
115
+ ? `${targetConversationId} 是 ${callerConversationId} 的隔级下属(中间隔着 ${intermediateId}),跨级${verb}不被允许。`
116
+ : `${targetConversationId} 是 ${callerConversationId} 的隔级下属,跨级${verb}不被允许。`;
117
+ case "ancestor":
118
+ return `${callerConversationId} 不能${verb}自己的上级。`;
119
+ case "peer":
120
+ return ancestorId
121
+ ? `${targetConversationId} 是 ${callerConversationId} 的平级会话(两者的共同上级是 ${ancestorId}),不在 ${callerConversationId} 的直接下属中。`
122
+ : `${targetConversationId} 是 ${callerConversationId} 的平级会话,不在 ${callerConversationId} 的直接下属中。`;
123
+ case "other-root":
124
+ return `${targetConversationId} 是一个独立的顶层会话,不属于 ${callerConversationId}。`;
125
+ case "unrelated":
126
+ return `${targetConversationId} 与 ${callerConversationId} 不在同一条会话树上(${targetConversationId} 不是 ${callerConversationId} 的直接下属),跨部门${verb}不被允许。`;
127
+ }
128
+ };
129
+ /**
130
+ * Writes the refusal for a pair that is not allowed to be reached.
131
+ *
132
+ * The three shapes, because the three failures are different failures:
133
+ * - managing a session -> 「不能调度/读取/停止/恢复会话 X」
134
+ * - listing a session's kids -> 「不能列出会话 X 的下属」
135
+ * - claiming a parent -> 「不能把会话 X 声明为自己的下属」
136
+ */
137
+ export const describeScopeRefusal = (options) => {
138
+ const { store, callerConversationId, targetConversationId, verb, subject } = options;
139
+ const relation = classifySessionRelation(store, callerConversationId, targetConversationId);
140
+ const intermediateId = intermediateSession(store, callerConversationId, targetConversationId);
141
+ const ancestorId = commonAncestorSession(store, callerConversationId, targetConversationId);
142
+ const reason = relationReason(relation, subject, callerConversationId, targetConversationId, verb, intermediateId, ancestorId);
143
+ if (subject === "list-children") {
144
+ return `调用方会话 ${callerConversationId} 不能列出会话 ${targetConversationId} 的下属:${reason}只能列出自己直接创建的子会话。`;
145
+ }
146
+ if (subject === "declare-parent") {
147
+ return `调用方会话 ${callerConversationId} 不能把会话 ${targetConversationId} 声明为自己的下属:${reason}创建会话的父会话必须是调用方自己。`;
148
+ }
149
+ return `调用方会话 ${callerConversationId} 不能${verb}会话 ${targetConversationId}:${reason}只能${verb}自己直接创建的子会话。`;
150
+ };
151
+ /** The refusal for a caller that cannot be identified at all. */
152
+ const missingCallerRefusal = (callerConversationId) => ({
153
+ ok: false,
154
+ status: 403,
155
+ error: callerConversationId
156
+ ? `调用方会话 ${callerConversationId} 不存在或未关联有效工作区,拒绝编排操作。`
157
+ : "调用方会话为空,拒绝编排操作。",
158
+ });
159
+ /**
160
+ * Checks that the caller may manage the target: the target must be the caller's DIRECT child.
161
+ *
162
+ * The workspace check is NOT part of this rule and is NOT removed by it - a conversation has to be
163
+ * in the caller's workspace AND in the caller's own subtree. The workspace refusal comes first
164
+ * because it is the older, coarser boundary, and its message names the two workspaces.
165
+ */
166
+ export const verifyDirectChildScope = (options) => {
167
+ const { store, callerConversationId, targetConversationId } = options;
168
+ const verb = options.verb ?? "调度";
169
+ const caller = callerConversationId?.trim();
170
+ if (!caller)
171
+ return missingCallerRefusal(callerConversationId);
172
+ const callerConversation = store.conversation(caller);
173
+ if (!callerConversation?.workspaceId)
174
+ return missingCallerRefusal(caller);
175
+ const target = store.conversation(targetConversationId);
176
+ if (!target) {
177
+ return { ok: false, status: 400, error: `找不到会话:${targetConversationId}` };
178
+ }
179
+ if (target.workspaceId !== callerConversation.workspaceId) {
180
+ return {
181
+ ok: false,
182
+ status: 403,
183
+ error: `调用方会话 ${caller} 所在工作区为 ${callerConversation.workspaceId},目标会话 ${targetConversationId} 所在工作区为 ${target.workspaceId},拒绝跨工作区操作。目标会话必须属于调用方工作区。`,
184
+ };
185
+ }
186
+ // THE RULE, IN ONE LINE. The target's tree edge must point at the caller, exactly.
187
+ if (target.parentConversationId !== caller) {
188
+ return {
189
+ ok: false,
190
+ status: 403,
191
+ error: describeScopeRefusal({
192
+ store,
193
+ callerConversationId: caller,
194
+ targetConversationId,
195
+ verb,
196
+ subject: "manage",
197
+ }),
198
+ };
199
+ }
200
+ return { ok: true };
201
+ };
202
+ /**
203
+ * Checks that the caller may list a parent's children.
204
+ *
205
+ * The rule is EQUALITY, not parenthood: the parent whose children are listed must be the caller
206
+ * itself, so the only sessions on the list are the caller's own direct children. Listing the
207
+ * children of the caller's own child would be listing grandchildren, which is the cross-level reach
208
+ * this whole rule exists to refuse.
209
+ */
210
+ export const verifyListParentScope = (options) => {
211
+ const { store, callerConversationId, requestedParentId } = options;
212
+ const caller = callerConversationId?.trim();
213
+ if (!caller)
214
+ return missingCallerRefusal(callerConversationId);
215
+ const callerConversation = store.conversation(caller);
216
+ if (!callerConversation?.workspaceId)
217
+ return missingCallerRefusal(caller);
218
+ const requested = requestedParentId?.trim();
219
+ // No parent named: the caller's own children are what gets listed.
220
+ if (!requested)
221
+ return { ok: true };
222
+ const parentConversation = store.conversation(requested);
223
+ if (parentConversation && parentConversation.workspaceId !== callerConversation.workspaceId) {
224
+ return {
225
+ ok: false,
226
+ status: 403,
227
+ error: `调用方会话 ${caller} 所在工作区为 ${callerConversation.workspaceId},请求的父会话 ${requested} 所在工作区为 ${parentConversation.workspaceId},拒绝跨工作区查询。父会话必须属于调用方工作区。`,
228
+ };
229
+ }
230
+ if (requested === caller)
231
+ return { ok: true };
232
+ return {
233
+ ok: false,
234
+ status: 403,
235
+ error: describeScopeRefusal({
236
+ store,
237
+ callerConversationId: caller,
238
+ targetConversationId: requested,
239
+ verb: "列出",
240
+ subject: "list-children",
241
+ }),
242
+ };
243
+ };
244
+ /**
245
+ * Checks that the caller may REPORT to the target: the target must be the caller's DIRECT PARENT.
246
+ *
247
+ * THE REPORTING LINE IS THE TREE EDGE, READ BACKWARDS. Every other rule in this file asks "may this
248
+ * session manage that one?" - and the answer is always about a session's subordinates. A report is the
249
+ * one message that travels UP, so the rule is inverted: a child reports to the session that owns it,
250
+ * the one whose id is the child's `parentConversationId`, and to nobody else.
251
+ *
252
+ * WHY THE INVERSION IS NOT A LOOPHOLE. A peer is somebody else's child, an ancestor is above the
253
+ * caller's own parent, a grandchild is below the caller, and a session in another workspace is another
254
+ * department. Every one of them would put one branch's news into another branch's inbox, and the inbox
255
+ * is where a parent's NEXT ACTION is decided - so a report that crossed a branch would be a stranger's
256
+ * news steering the wrong supervisor.
257
+ *
258
+ * THE RECIPIENT IS DERIVED, NOT CHOSEN. This check exists for the case where a caller NAMES a
259
+ * recipient at all: the server derives the parent from the tree and this refuses anything else, so a
260
+ * model cannot redirect its own report up or sideways.
261
+ */
262
+ export const verifyReportTargetScope = (options) => {
263
+ const { store, callerConversationId, targetConversationId } = options;
264
+ const caller = callerConversationId?.trim();
265
+ if (!caller)
266
+ return missingCallerRefusal(callerConversationId);
267
+ const callerConversation = store.conversation(caller);
268
+ if (!callerConversation?.workspaceId)
269
+ return missingCallerRefusal(caller);
270
+ const target = store.conversation(targetConversationId);
271
+ if (!target) {
272
+ return { ok: false, status: 400, error: `找不到会话:${targetConversationId}` };
273
+ }
274
+ if (target.workspaceId !== callerConversation.workspaceId) {
275
+ return {
276
+ ok: false,
277
+ status: 403,
278
+ error: `调用方会话 ${caller} 所在工作区为 ${callerConversation.workspaceId},目标会话 ${targetConversationId} 所在工作区为 ${target.workspaceId},拒绝跨工作区汇报。汇报对象必须与调用方在同一工作区。`,
279
+ };
280
+ }
281
+ // THE RULE, INVERTED. The caller's own parent edge must point at the target.
282
+ if (callerConversation.parentConversationId === targetConversationId) {
283
+ return { ok: true };
284
+ }
285
+ const relation = classifySessionRelation(store, caller, targetConversationId);
286
+ const reason = relation === "self"
287
+ ? `会话 ${caller} 不能向自己汇报`
288
+ : relation === "ancestor"
289
+ ? `${targetConversationId} 是 ${caller} 的上级会话,但不是它的直属父会话`
290
+ : relation === "descendant"
291
+ ? `${targetConversationId} 是 ${caller} 的下属会话`
292
+ : relation === "peer"
293
+ ? `${targetConversationId} 与 ${caller} 是平级会话`
294
+ : relation === "other-root"
295
+ ? `${targetConversationId} 是另一个树的顶层会话`
296
+ : `${targetConversationId} 与 ${caller} 不在同一条会话树上`;
297
+ return {
298
+ ok: false,
299
+ status: 403,
300
+ error: `子会话 ${caller} 只能向自己的直属父会话汇报:${reason}。汇报对象由服务端按会话树决定,调用方不能指定。`,
301
+ };
302
+ };
303
+ /**
304
+ * Checks the parent a new session is created under.
305
+ *
306
+ * The parent edge is written onto the new conversation at birth, and the result callback follows
307
+ * that edge home. A caller that names somebody ELSE as the parent would therefore rewrite that
308
+ * session's reporting line - putting the victim's result into the caller's inbox - so the parent
309
+ * must be the caller. An empty parent means a top-level session, which is what the user's own "+"
310
+ * creates and what a session with no owner legitimately is.
311
+ */
312
+ export const verifySpawnParentScope = (options) => {
313
+ const { store, callerConversationId, requestedParentId } = options;
314
+ const caller = callerConversationId?.trim();
315
+ if (!caller)
316
+ return missingCallerRefusal(callerConversationId);
317
+ const callerConversation = store.conversation(caller);
318
+ if (!callerConversation?.workspaceId)
319
+ return missingCallerRefusal(caller);
320
+ const requested = requestedParentId?.trim();
321
+ // No parent named: a top-level session, owned by nobody and reportable to nobody.
322
+ if (!requested)
323
+ return { ok: true };
324
+ if (requested === caller)
325
+ return { ok: true };
326
+ return {
327
+ ok: false,
328
+ status: 403,
329
+ error: describeScopeRefusal({
330
+ store,
331
+ callerConversationId: caller,
332
+ targetConversationId: requested,
333
+ verb: "调度",
334
+ subject: "declare-parent",
335
+ }),
336
+ };
337
+ };
@@ -120,6 +120,8 @@ export declare class Store {
120
120
  */
121
121
  workspaceForConversation(conversationId: string): Workspace | undefined;
122
122
  workspace(workspaceId: string): Workspace | undefined;
123
+ /** Read every workspace as detached shallow copies. */
124
+ workspaces(): Workspace[];
123
125
  /** Read the app-wide default without cloning conversations or transcripts. */
124
126
  defaultModel(): ModelSelection | undefined;
125
127
  /** Read one detached conversation for lifecycle operations. */
@@ -616,6 +618,17 @@ export declare class Store {
616
618
  * going to act on it without sending another prompt first).
617
619
  */
618
620
  setFailureReason(conversationId: string, reason: string | undefined): Promise<void>;
621
+ /**
622
+ * Records WHY THIS CONVERSATION'S LAST ANSWER IS INCOMPLETE, in a way the reader can see.
623
+ *
624
+ * `Conversation.warning` is a different fact from `Conversation.error`: the run did not break, the
625
+ * answer was cut short by the model's output budget. The reader's next move is different too - a
626
+ * failed turn asks them to retry, a truncated one asks them to let the model continue - so the two
627
+ * are kept apart rather than sharing one field and one colour.
628
+ *
629
+ * Set to `undefined` to clear, which the next `addUserMessage` does anyway.
630
+ */
631
+ setWarningReason(conversationId: string, reason: string | undefined): Promise<void>;
619
632
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
620
633
  planRevert(conversationId: string, messageId: string, withdraw?: boolean): RevertPlan;
621
634
  /**
@@ -153,7 +153,7 @@ function reconcileConversations(state) {
153
153
  // was sent) and the server died before any settled state was recorded. A conversation that was in
154
154
  // either of those two states when the process went away has a half-open turn whose outcome nobody
155
155
  // can determine from the file alone - exactly the situation this sweep exists to remove.
156
- if (!conversation || (conversation.status !== "running" && conversation.status !== "waiting"))
156
+ if (!conversation || (conversation.status !== "running" && conversation.status !== "waiting" && conversation.status !== "awaiting_background"))
157
157
  continue;
158
158
  conversation.status = "failed";
159
159
  conversation.error = ORPHANED_CONVERSATION_ERROR;
@@ -722,6 +722,10 @@ export class Store {
722
722
  const workspace = this.state.workspaces.find((item) => item.id === workspaceId);
723
723
  return workspace ? { ...workspace } : undefined;
724
724
  }
725
+ /** Read every workspace as detached shallow copies. */
726
+ workspaces() {
727
+ return this.state.workspaces.map((item) => ({ ...item }));
728
+ }
725
729
  /** Read the app-wide default without cloning conversations or transcripts. */
726
730
  defaultModel() {
727
731
  const model = this.state.defaultModel;
@@ -1234,7 +1238,7 @@ export class Store {
1234
1238
  const conversation = this.requireConversation(conversationId);
1235
1239
  if (conversation.archivedAt)
1236
1240
  return;
1237
- if (conversation.status === "running")
1241
+ if (conversation.status === "running" || conversation.status === "awaiting_background")
1238
1242
  throw new Error("运行中的会话无法归档,请先停止运行");
1239
1243
  conversation.archivedAt = now();
1240
1244
  await this.persistMetadata(conversationId);
@@ -1370,7 +1374,6 @@ export class Store {
1370
1374
  * reader sees who spoke.
1371
1375
  */
1372
1376
  async addAgentMessage(conversationId, content, options) {
1373
- console.error("STORE addAgentMessage conv=" + conversationId + " content=" + content.slice(0, 60));
1374
1377
  const conversation = this.requireConversation(conversationId);
1375
1378
  if (conversation.archivedAt)
1376
1379
  throw new Error("无法向已归档的会话下发指令,请先恢复该会话");
@@ -1422,6 +1425,10 @@ export class Store {
1422
1425
  // the new turn starts: leaving it up would show "was interrupted, marked failed" above a conversation
1423
1426
  // that is visibly working again.
1424
1427
  delete conversation.error;
1428
+ // THE SAME IS TRUE OF THE TRUNCATION WARNING. It describes the answer that was cut short, and a
1429
+ // new turn is the reader answering that situation - so the notice belongs to the turn that just
1430
+ // ended, not to the conversation from now on.
1431
+ delete conversation.warning;
1425
1432
  // AN INCREMENTAL WRITE, NOT A FULL ONE. This line was the last full-state write on the hot path
1426
1433
  // of every message, and it was in front of the broadcast that tells the browser a turn has
1427
1434
  // started - so the size of the WHOLE state was the delay between clicking send and the stream
@@ -1460,7 +1467,6 @@ export class Store {
1460
1467
  * state it also enters `waitingMessages` and `runConversationTurns` will pick it up next.
1461
1468
  */
1462
1469
  async addToolMessage(conversationId, content, options) {
1463
- console.error("STORE addToolMessage conv=" + conversationId + " content=" + content.slice(0, 60));
1464
1470
  const conversation = this.requireConversation(conversationId);
1465
1471
  if (conversation.archivedAt)
1466
1472
  throw new Error("无法向已归档的会话追加后台回传,请先恢复该会话");
@@ -1912,6 +1918,25 @@ export class Store {
1912
1918
  conversation.updatedAt = now();
1913
1919
  await this.persistMetadata(conversationId);
1914
1920
  }
1921
+ /**
1922
+ * Records WHY THIS CONVERSATION'S LAST ANSWER IS INCOMPLETE, in a way the reader can see.
1923
+ *
1924
+ * `Conversation.warning` is a different fact from `Conversation.error`: the run did not break, the
1925
+ * answer was cut short by the model's output budget. The reader's next move is different too - a
1926
+ * failed turn asks them to retry, a truncated one asks them to let the model continue - so the two
1927
+ * are kept apart rather than sharing one field and one colour.
1928
+ *
1929
+ * Set to `undefined` to clear, which the next `addUserMessage` does anyway.
1930
+ */
1931
+ async setWarningReason(conversationId, reason) {
1932
+ const conversation = this.requireConversation(conversationId);
1933
+ if (reason === undefined)
1934
+ delete conversation.warning;
1935
+ else
1936
+ conversation.warning = reason;
1937
+ conversation.updatedAt = now();
1938
+ await this.persistMetadata(conversationId);
1939
+ }
1915
1940
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
1916
1941
  planRevert(conversationId, messageId, withdraw = false) { return planRevert(this.state, conversationId, messageId, withdraw); }
1917
1942
  /**
@@ -2392,6 +2417,14 @@ export class Store {
2392
2417
  Object.assign(existing, summary);
2393
2418
  else
2394
2419
  this.state.backgroundTasks.unshift(summary);
2420
+ if (summary.conversationId && summary.status === "running") {
2421
+ const conv = this.state.conversations.find((c) => c.id === summary.conversationId);
2422
+ if (conv && conv.status !== "running" && conv.status !== "waiting") {
2423
+ conv.status = "awaiting_background";
2424
+ conv.updatedAt = now();
2425
+ await this.persistMetadata(conv.id);
2426
+ }
2427
+ }
2395
2428
  await this.persistCollections("backgroundTasks");
2396
2429
  }
2397
2430
  async removeBackgroundTask(id) {
@@ -258,7 +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
  /** Model-facing description for session_spawn. */
261
- 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.";
261
+ 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.";
262
262
  /** Model-facing description for session_status. */
263
263
  export declare const SESSION_STATUS_DESCRIPTION = "Inspect the progress, run state, and latest activity of sibling conversations created by the orchestrator.\n\nWHEN TO USE THIS:\n- To check whether a spawned sibling conversation is still running, has finished (idle), is stopped, or encountered an error.\n- To read the latest assistant progress snippet without bloating your context with the session's entire raw message transcript.\n- To list all sibling conversations under your coordination when conversationId is omitted.\n\nUsage notes:\n1. When 'conversationId' is provided: returns a compact summary for that specific session, including its run status ('idle', 'running', 'waiting', 'stopped', 'failed'), messageCount, turnCount, lastAssistantSnippet, lastUserSnippet, activeTool, and error (if any).\n2. When 'conversationId' is omitted: returns a summary list of sibling conversations whose direct parent is 'parentConversationId' (the calling session by default).\n3. By default it never returns full transcripts; concise summaries keep your context window lean. Set 'includeOutput' with a conversationId to read the final output from its result file, capped to the last 64Ki characters with an explicit truncation notice.";
264
264
  /** Model-facing description for session_dispatch. */