@evandu/super-pi 0.1.146 → 0.1.148

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 (64) hide show
  1. package/agents/super-pi-task/core.ts +223 -2
  2. package/agents/super-pi-task/index.ts +50 -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/agents.d.ts +1 -1
  34. package/dist/server/agents.js +1 -1
  35. package/dist/server/application/conversation-service.js +6 -0
  36. package/dist/server/application/run-service.d.ts +16 -0
  37. package/dist/server/application/run-service.js +175 -26
  38. package/dist/server/application/session-orchestrator-service.d.ts +103 -1
  39. package/dist/server/application/session-orchestrator-service.js +308 -8
  40. package/dist/server/application/session-watchdog.d.ts +159 -0
  41. package/dist/server/application/session-watchdog.js +244 -0
  42. package/dist/server/http-server.js +136 -76
  43. package/dist/server/index.js +3 -0
  44. package/dist/server/knowledge-vectors.d.ts +15 -0
  45. package/dist/server/knowledge-vectors.js +118 -37
  46. package/dist/server/knowledge.d.ts +7 -8
  47. package/dist/server/knowledge.js +133 -55
  48. package/dist/server/length-continuation.d.ts +50 -0
  49. package/dist/server/length-continuation.js +55 -0
  50. package/dist/server/pi-session-events.js +5 -1
  51. package/dist/server/pi-session-pool.d.ts +2 -0
  52. package/dist/server/pi-session-pool.js +16 -0
  53. package/dist/server/runtime.js +5 -4
  54. package/dist/server/session-tree-scope.d.ts +125 -0
  55. package/dist/server/session-tree-scope.js +337 -0
  56. package/dist/server/store.d.ts +13 -0
  57. package/dist/server/store.js +37 -4
  58. package/dist/server/task-tool.d.ts +4 -1
  59. package/dist/server/task-tool.js +25 -1
  60. package/dist/server/turn-failure.d.ts +26 -0
  61. package/dist/server/turn-failure.js +51 -0
  62. package/dist/server/types.d.ts +15 -1
  63. package/package.json +20 -3
  64. 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,12 +18,13 @@ 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.
24
25
  export { describeDeliveredUserMessage, describePiQueue } from "./pi-session-events.js";
25
26
  import { createPiSession, createSdkCatalog, piSdkAvailable, resolveTranscriptFile, superPiExtensionPaths } from "./pi-session.js";
26
- 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, 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, rosterPayload, } from "./task-tool.js";
27
28
  import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
28
29
  import { loadConversationImages } from "./upload.js";
29
30
  import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
@@ -1259,7 +1260,7 @@ export class PiRpcRuntime {
1259
1260
  if (process.env.SUPER_PI_FEISHU_ENDPOINT)
1260
1261
  names.push(FEISHU_READ_TOOL_NAME, FEISHU_WRITE_TOOL_NAME);
1261
1262
  if (this.options.orchestratorEndpoint)
1262
- names.push(SESSION_SPAWN_TOOL_NAME, SESSION_STATUS_TOOL_NAME, SESSION_DISPATCH_TOOL_NAME, SESSION_STOP_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);
1263
1264
  return names;
1264
1265
  }
1265
1266
  /**
@@ -1478,7 +1479,7 @@ export class PiRpcRuntime {
1478
1479
  // allowlist and naming a tool Pi has not registered would change the spawn line for every other
1479
1480
  // mode. The prompt asks for a bare JSON array and nothing else; if the model spends a turn on a
1480
1481
  // tool call anyway the reranker times out and falls back to the lexical order.
1481
- 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] : [])] : []), ...(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] : [])] : []), ...(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
1483
  // Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
1483
1484
  // "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
1484
1485
  if (!this.pi.found)
@@ -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
  /**