@evandu/super-pi 0.1.145 → 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 (93) hide show
  1. package/agents/super-pi-task/core.ts +74 -2
  2. package/agents/super-pi-task/index.ts +7 -0
  3. package/dist/.super-pi-check-inputs.json +2 -2
  4. package/dist/client/.super-pi-client-sources.json +11 -11
  5. package/dist/client/assets/{CodeMirrorEditor-A9KvbIkm.js → CodeMirrorEditor-DQCjEnxG.js} +2 -2
  6. package/dist/client/assets/{dist-DM4Olwfx.js → dist-1Innu17E.js} +1 -1
  7. package/dist/client/assets/{dist-bIDce5j-.js → dist-1lynFOc5.js} +1 -1
  8. package/dist/client/assets/{dist-BlxwDnWr.js → dist-B1qLZVMK.js} +1 -1
  9. package/dist/client/assets/{dist-BTVbZigv.js → dist-B4pz_xdd.js} +1 -1
  10. package/dist/client/assets/{dist-UJ46aPS3.js → dist-B6KwDpeI.js} +1 -1
  11. package/dist/client/assets/{dist-CK2KFFtT.js → dist-BJVI1Unv.js} +1 -1
  12. package/dist/client/assets/{dist-CRnm1rnO.js → dist-BWvzD8Pe.js} +1 -1
  13. package/dist/client/assets/{dist-DSHM-PAs.js → dist-BayWssd0.js} +1 -1
  14. package/dist/client/assets/{dist-B9DE7sc5.js → dist-Bk_pkBVH.js} +1 -1
  15. package/dist/client/assets/{dist-D-o7iQ8c.js → dist-BpeClAv0.js} +1 -1
  16. package/dist/client/assets/{dist-CQFASxyb.js → dist-BsmJg5PX.js} +1 -1
  17. package/dist/client/assets/{dist-Ds5gfeAi.js → dist-Bx7s4rni.js} +1 -1
  18. package/dist/client/assets/{dist-UPJPMjWv.js → dist-CDBKM9tL.js} +1 -1
  19. package/dist/client/assets/{dist-CIgjifH-.js → dist-CGzXCzCG.js} +1 -1
  20. package/dist/client/assets/{dist-BAiV8I_O.js → dist-CRxCwn-Z.js} +1 -1
  21. package/dist/client/assets/{dist-Br-AuQUG.js → dist-CTDgUnBG.js} +1 -1
  22. package/dist/client/assets/{dist-4JbFTwg2.js → dist-D1VfMWmf.js} +1 -1
  23. package/dist/client/assets/{dist-BYOpvW02.js → dist-DTZLNEci.js} +1 -1
  24. package/dist/client/assets/{dist-CQV5kOCE.js → dist-DuUi29km.js} +1 -1
  25. package/dist/client/assets/{dist-CjszAoXk.js → dist-F3S0t1oM.js} +1 -1
  26. package/dist/client/assets/{dist-2boAf_pN.js → dist-QZ-FAVJV.js} +1 -1
  27. package/dist/client/assets/{dist-B1p4YF9C.js → dist-u0nBg0fZ.js} +1 -1
  28. package/dist/client/assets/index-BMpqviwU.css +1 -0
  29. package/dist/client/assets/index-Dzyp_7sf.js +102 -0
  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 +3 -1
  35. package/dist/server/application/conversation-service.d.ts +1 -1
  36. package/dist/server/application/conversation-service.js +12 -4
  37. package/dist/server/application/run-service.d.ts +32 -2
  38. package/dist/server/application/run-service.js +296 -40
  39. package/dist/server/application/scheduled-task-service.d.ts +14 -1
  40. package/dist/server/application/scheduled-task-service.js +27 -2
  41. package/dist/server/application/session-orchestrator-service.d.ts +114 -3
  42. package/dist/server/application/session-orchestrator-service.js +406 -35
  43. package/dist/server/application/session-watchdog.d.ts +159 -0
  44. package/dist/server/application/session-watchdog.js +244 -0
  45. package/dist/server/command-handler.js +1 -1
  46. package/dist/server/conversation-usage.d.ts +19 -0
  47. package/dist/server/conversation-usage.js +40 -0
  48. package/dist/server/file-diff.d.ts +3 -0
  49. package/dist/server/file-diff.js +4 -4
  50. package/dist/server/file-index.d.ts +4 -2
  51. package/dist/server/file-index.js +14 -9
  52. package/dist/server/file-name-index.d.ts +5 -2
  53. package/dist/server/file-name-index.js +22 -5
  54. package/dist/server/git.d.ts +3 -3
  55. package/dist/server/git.js +10 -9
  56. package/dist/server/http-server.js +256 -34
  57. package/dist/server/index.js +3 -0
  58. package/dist/server/knowledge-vectors.d.ts +15 -0
  59. package/dist/server/knowledge-vectors.js +118 -37
  60. package/dist/server/knowledge.d.ts +7 -8
  61. package/dist/server/knowledge.js +133 -55
  62. package/dist/server/length-continuation.d.ts +50 -0
  63. package/dist/server/length-continuation.js +55 -0
  64. package/dist/server/model-context-window.d.ts +46 -0
  65. package/dist/server/model-context-window.js +121 -0
  66. package/dist/server/pi-session-events.js +5 -1
  67. package/dist/server/pi-session-pool.d.ts +2 -0
  68. package/dist/server/pi-session-pool.js +16 -0
  69. package/dist/server/pi-session.d.ts +6 -0
  70. package/dist/server/pi-session.js +15 -1
  71. package/dist/server/runtime.d.ts +3 -0
  72. package/dist/server/runtime.js +23 -7
  73. package/dist/server/session-cwd.d.ts +17 -0
  74. package/dist/server/session-cwd.js +43 -0
  75. package/dist/server/session-ledger.d.ts +7 -0
  76. package/dist/server/session-tree-scope.d.ts +125 -0
  77. package/dist/server/session-tree-scope.js +337 -0
  78. package/dist/server/store.d.ts +35 -0
  79. package/dist/server/store.js +87 -2
  80. package/dist/server/task-tool.d.ts +2 -1
  81. package/dist/server/task-tool.js +4 -1
  82. package/dist/server/terminal.d.ts +1 -0
  83. package/dist/server/terminal.js +5 -3
  84. package/dist/server/turn-failure.d.ts +26 -0
  85. package/dist/server/turn-failure.js +67 -1
  86. package/dist/server/types.d.ts +33 -1
  87. package/dist/server/version.d.ts +25 -2
  88. package/dist/server/version.js +98 -26
  89. package/dist/server/ws-server.d.ts +1 -1
  90. package/dist/server/ws-server.js +7 -5
  91. package/package.json +36 -3
  92. package/dist/client/assets/index-BGsP8pt_.js +0 -100
  93. package/dist/client/assets/index-CiIc734l.css +0 -1
@@ -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
+ };
@@ -3,6 +3,7 @@ import { defaultJsonStatePath } from "./persistence/json-state-repository.js";
3
3
  import type { ScheduledTask } from "./scheduled-tasks.js";
4
4
  import type { WorkflowRun } from "./workflow.js";
5
5
  import type { AppState, Conversation, Message, MessageDelivery, MessageImage, ModelSelection, SubagentRun, SubagentRunUpdate, TodoItem, ToolCallQuestion, ToolCallArg, ToolCallBlock, ToolResultSummary, Workspace } from "./types.js";
6
+ import type { TurnUsageReadout } from "./conversation-usage.js";
6
7
  /** Mints the id a forked conversation will have, BEFORE its Pi transcript exists.
7
8
  *
8
9
  * Exported because the ordering demands it and no other arrangement provides it. Pi's copied file's header
@@ -119,6 +120,8 @@ export declare class Store {
119
120
  */
120
121
  workspaceForConversation(conversationId: string): Workspace | undefined;
121
122
  workspace(workspaceId: string): Workspace | undefined;
123
+ /** Read every workspace as detached shallow copies. */
124
+ workspaces(): Workspace[];
122
125
  /** Read the app-wide default without cloning conversations or transcripts. */
123
126
  defaultModel(): ModelSelection | undefined;
124
127
  /** Read one detached conversation for lifecycle operations. */
@@ -271,6 +274,7 @@ export declare class Store {
271
274
  thinkingLevel?: string | null;
272
275
  modelNotice?: string;
273
276
  agentNotice?: string;
277
+ cwd?: string;
274
278
  parentConversationId?: string;
275
279
  origin?: "user" | "orchestrator";
276
280
  }): Promise<Conversation>;
@@ -388,6 +392,24 @@ export declare class Store {
388
392
  * whatever was typed into a box that was not listening yet. `openTurnOnMessage` applies them at the
389
393
  * moment a turn genuinely begins.
390
394
  */
395
+ /**
396
+ * Writes ONE INSTRUCTION INTO THE TRANSCRIPT AS SOMETHING THE SYSTEM SAID.
397
+ *
398
+ * `addUserMessage` above is the only way a person's words come into being, and everything written
399
+ * through it wears the reader's own name and the reader's bubble. That is right for a person and
400
+ * wrong for the orchestrator: when a primary agent dispatches a task to a child session, the
401
+ * child receives an ORDER, not something the reader typed - and the child transcript showed it
402
+ * under the reader's avatar, so the reader saw their own name on an instruction they never wrote.
403
+ *
404
+ * The role is `tool` for a reason that is not cosmetic: `tool` is already the product's word for
405
+ * "the system delivered this", it renders as the 系统 side of the transcript rather than as a
406
+ * person, and Pi's queue already knows how to hand a `tool` message to a model (see
407
+ * `composePrompt` in the run service). So the instruction reaches the model the same way, and the
408
+ * reader sees who spoke.
409
+ */
410
+ addAgentMessage(conversationId: string, content: string, options?: {
411
+ delivery?: Message["delivery"];
412
+ }): Promise<Message>;
391
413
  addUserMessage(conversationId: string, content: string, images?: MessageImage[], attachments?: import("./types.js").MessageAttachments, options?: {
392
414
  clientId?: string;
393
415
  delivery?: Message["delivery"];
@@ -578,6 +600,8 @@ export declare class Store {
578
600
  */
579
601
  clearPreparingToolCalls(conversationId: string, messageId: string, rows: readonly number[]): Promise<ToolCallBlock[]>;
580
602
  setStatus(conversationId: string, status: Conversation["status"]): Promise<void>;
603
+ /** Commit once at turn end; metadata transactions work for both JSON and SQLite repositories. */
604
+ recordConversationUsage(conversationId: string, readout: TurnUsageReadout): Promise<void>;
581
605
  /**
582
606
  * Records WHY a turn failed in a way the reader can see. `Conversation.error` is rendered as a red
583
607
  * banner above the transcript (`App.tsx:conversation-error`), and it is the only field that survives
@@ -594,6 +618,17 @@ export declare class Store {
594
618
  * going to act on it without sending another prompt first).
595
619
  */
596
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>;
597
632
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
598
633
  planRevert(conversationId: string, messageId: string, withdraw?: boolean): RevertPlan;
599
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;
@@ -1103,6 +1107,8 @@ export class Store {
1103
1107
  // the sidebar draws after a restart is the tree that was true when each session was opened.
1104
1108
  // Written only when present, so a conversation created without them (the user's own "+") reads
1105
1109
  // as a top-level one on every code path, including every state.json from before this field.
1110
+ if (configuration.cwd)
1111
+ conversation.cwd = configuration.cwd;
1106
1112
  if (configuration.parentConversationId)
1107
1113
  conversation.parentConversationId = configuration.parentConversationId;
1108
1114
  if (configuration.origin)
@@ -1212,6 +1218,8 @@ export class Store {
1212
1218
  conversation.thinkingLevel = source.thinkingLevel;
1213
1219
  if (source.agentId)
1214
1220
  conversation.agentId = source.agentId;
1221
+ if (source.cwd)
1222
+ conversation.cwd = source.cwd;
1215
1223
  this.state.conversations.unshift(conversation);
1216
1224
  await this.persist();
1217
1225
  return structuredClone(conversation);
@@ -1230,7 +1238,7 @@ export class Store {
1230
1238
  const conversation = this.requireConversation(conversationId);
1231
1239
  if (conversation.archivedAt)
1232
1240
  return;
1233
- if (conversation.status === "running")
1241
+ if (conversation.status === "running" || conversation.status === "awaiting_background")
1234
1242
  throw new Error("运行中的会话无法归档,请先停止运行");
1235
1243
  conversation.archivedAt = now();
1236
1244
  await this.persistMetadata(conversationId);
@@ -1350,6 +1358,32 @@ export class Store {
1350
1358
  * whatever was typed into a box that was not listening yet. `openTurnOnMessage` applies them at the
1351
1359
  * moment a turn genuinely begins.
1352
1360
  */
1361
+ /**
1362
+ * Writes ONE INSTRUCTION INTO THE TRANSCRIPT AS SOMETHING THE SYSTEM SAID.
1363
+ *
1364
+ * `addUserMessage` above is the only way a person's words come into being, and everything written
1365
+ * through it wears the reader's own name and the reader's bubble. That is right for a person and
1366
+ * wrong for the orchestrator: when a primary agent dispatches a task to a child session, the
1367
+ * child receives an ORDER, not something the reader typed - and the child transcript showed it
1368
+ * under the reader's avatar, so the reader saw their own name on an instruction they never wrote.
1369
+ *
1370
+ * The role is `tool` for a reason that is not cosmetic: `tool` is already the product's word for
1371
+ * "the system delivered this", it renders as the 系统 side of the transcript rather than as a
1372
+ * person, and Pi's queue already knows how to hand a `tool` message to a model (see
1373
+ * `composePrompt` in the run service). So the instruction reaches the model the same way, and the
1374
+ * reader sees who spoke.
1375
+ */
1376
+ async addAgentMessage(conversationId, content, options) {
1377
+ const conversation = this.requireConversation(conversationId);
1378
+ if (conversation.archivedAt)
1379
+ throw new Error("无法向已归档的会话下发指令,请先恢复该会话");
1380
+ const message = { id: id("message"), role: "tool", content, createdAt: now() };
1381
+ if (options?.delivery)
1382
+ message.delivery = options.delivery;
1383
+ conversation.messages.push(message);
1384
+ await this.persistMessages(conversation);
1385
+ return structuredClone(message);
1386
+ }
1353
1387
  async addUserMessage(conversationId, content, images, attachments, options) {
1354
1388
  const conversation = this.requireConversation(conversationId);
1355
1389
  // The browser cannot reach an archived conversation, so this guard exists for any other caller -
@@ -1391,6 +1425,10 @@ export class Store {
1391
1425
  // the new turn starts: leaving it up would show "was interrupted, marked failed" above a conversation
1392
1426
  // that is visibly working again.
1393
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;
1394
1432
  // AN INCREMENTAL WRITE, NOT A FULL ONE. This line was the last full-state write on the hot path
1395
1433
  // of every message, and it was in front of the broadcast that tells the browser a turn has
1396
1434
  // started - so the size of the WHOLE state was the delay between clicking send and the stream
@@ -1836,6 +1874,26 @@ export class Store {
1836
1874
  return structuredClone(calls);
1837
1875
  }
1838
1876
  async setStatus(conversationId, status) { const conversation = this.requireConversation(conversationId); conversation.status = status; conversation.updatedAt = now(); await this.persistMetadata(conversationId); }
1877
+ /** Commit once at turn end; metadata transactions work for both JSON and SQLite repositories. */
1878
+ async recordConversationUsage(conversationId, readout) {
1879
+ const conversation = this.requireConversation(conversationId);
1880
+ if (readout.usage) {
1881
+ const previous = conversation.usage;
1882
+ const total = { ...readout.usage };
1883
+ if (previous)
1884
+ for (const key of ["input", "output", "cacheRead", "cacheWrite", "cost", "turns"])
1885
+ total[key] += previous[key];
1886
+ conversation.usage = total;
1887
+ conversation.usageCostKnown = (conversation.usageCostKnown ?? true) && readout.costKnown;
1888
+ }
1889
+ if (readout.contextUsage)
1890
+ conversation.contextUsage = { ...readout.contextUsage };
1891
+ else
1892
+ delete conversation.contextUsage;
1893
+ if (readout.usage || readout.contextUsage)
1894
+ conversation.usageUpdatedAt = now();
1895
+ await this.persistMetadata(conversationId);
1896
+ }
1839
1897
  /**
1840
1898
  * Records WHY a turn failed in a way the reader can see. `Conversation.error` is rendered as a red
1841
1899
  * banner above the transcript (`App.tsx:conversation-error`), and it is the only field that survives
@@ -1860,6 +1918,25 @@ export class Store {
1860
1918
  conversation.updatedAt = now();
1861
1919
  await this.persistMetadata(conversationId);
1862
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
+ }
1863
1940
  /** The plan a revert would carry out, without carrying it out. The server reads this to stop children first. */
1864
1941
  planRevert(conversationId, messageId, withdraw = false) { return planRevert(this.state, conversationId, messageId, withdraw); }
1865
1942
  /**
@@ -2340,6 +2417,14 @@ export class Store {
2340
2417
  Object.assign(existing, summary);
2341
2418
  else
2342
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
+ }
2343
2428
  await this.persistCollections("backgroundTasks");
2344
2429
  }
2345
2430
  async removeBackgroundTask(id) {
@@ -251,13 +251,14 @@ export declare function todoSummary(todos: readonly TodoItem[]): string;
251
251
  * `agents/super-pi-task/core.ts` is the other end; `scripts/task-tool.test.mjs` asserts the two copies agree.
252
252
  */
253
253
  export declare const ORCHESTRATOR_ENDPOINT_ENV = "SUPER_PI_ORCHESTRATOR_ENDPOINT";
254
+ export declare const ORCHESTRATOR_WORKSPACE_ENV = "SUPER_PI_ORCHESTRATOR_WORKSPACE";
254
255
  /** Tool names registered with Pi. */
255
256
  export declare const SESSION_SPAWN_TOOL_NAME = "session_spawn";
256
257
  export declare const SESSION_STATUS_TOOL_NAME = "session_status";
257
258
  export declare const SESSION_DISPATCH_TOOL_NAME = "session_dispatch";
258
259
  export declare const SESSION_STOP_TOOL_NAME = "session_stop";
259
260
  /** Model-facing description for session_spawn. */
260
- 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.";
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.";
261
262
  /** Model-facing description for session_status. */
262
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.";
263
264
  /** Model-facing description for session_dispatch. */
@@ -356,6 +356,7 @@ export function todoSummary(todos) {
356
356
  * `agents/super-pi-task/core.ts` is the other end; `scripts/task-tool.test.mjs` asserts the two copies agree.
357
357
  */
358
358
  export const ORCHESTRATOR_ENDPOINT_ENV = "SUPER_PI_ORCHESTRATOR_ENDPOINT";
359
+ export const ORCHESTRATOR_WORKSPACE_ENV = "SUPER_PI_ORCHESTRATOR_WORKSPACE";
359
360
  /** Tool names registered with Pi. */
360
361
  export const SESSION_SPAWN_TOOL_NAME = "session_spawn";
361
362
  export const SESSION_STATUS_TOOL_NAME = "session_status";
@@ -382,7 +383,9 @@ Usage notes:
382
383
  1. 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.
383
384
  2. The initial task should provide clear context and goals since the new session starts with a fresh conversation history.
384
385
  3. Use session_status with the returned conversationId to check progress, or session_dispatch to send further messages.
385
- 4. The new session reports completion to its DIRECT parent automatically. 'parentConversationId' optionally selects that parent; by default it is the calling session.`;
386
+ 4. The new session reports completion to its DIRECT parent automatically. 'parentConversationId' optionally selects that parent; by default it is the calling session.
387
+ 5. 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.
388
+ 6. 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.`;
386
389
  /** Model-facing description for session_status. */
387
390
  export const SESSION_STATUS_DESCRIPTION = `Inspect the progress, run state, and latest activity of sibling conversations created by the orchestrator.
388
391
 
@@ -104,6 +104,7 @@ export type TerminalCommand = {
104
104
  type: "terminal.open";
105
105
  sessionId: string;
106
106
  workspaceId: string;
107
+ cwd?: string;
107
108
  cols?: number;
108
109
  rows?: number;
109
110
  } | {
@@ -52,6 +52,7 @@ import { createRequire } from "node:module";
52
52
  import { existsSync, accessSync, constants } from "node:fs";
53
53
  import { delimiter, join } from "node:path";
54
54
  import { isLocalOrPrivateAddress } from "./http-server.js";
55
+ import { resolveSessionCwd } from "./session-cwd.js";
55
56
  let ptyModule;
56
57
  let ptyLoadFailure;
57
58
  /**
@@ -305,10 +306,11 @@ export class TerminalService {
305
306
  send({ type: "terminal.error", sessionId: command.sessionId, message: "工作区不存在,无法打开终端。" });
306
307
  return;
307
308
  }
309
+ const cwd = await resolveSessionCwd(workspace.path, command.cwd);
308
310
  // A workspace whose directory was deleted or renamed must not spawn a shell in `process.cwd()`,
309
311
  // which would silently run the terminal in the SERVER's directory instead of an error.
310
- if (!existsSync(workspace.path)) {
311
- send({ type: "terminal.error", sessionId: command.sessionId, message: `工作区目录不存在:${workspace.path}` });
312
+ if (!existsSync(cwd)) {
313
+ send({ type: "terminal.error", sessionId: command.sessionId, message: `会话工作目录不存在:${cwd}` });
312
314
  return;
313
315
  }
314
316
  const pty = loadPty();
@@ -319,7 +321,7 @@ export class TerminalService {
319
321
  name: "xterm-256color",
320
322
  cols: clamp(command.cols, MIN_COLS, MAX_COLS, 80),
321
323
  rows: clamp(command.rows, MIN_ROWS, MAX_ROWS, 24),
322
- cwd: workspace.path,
324
+ cwd,
323
325
  env: { ...process.env, TERM: "xterm-256color" },
324
326
  });
325
327
  const session = { pty: child, closed: false, killed: false, pending: "" };