@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.
- package/agents/super-pi-task/core.ts +74 -2
- package/agents/super-pi-task/index.ts +7 -0
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +11 -11
- package/dist/client/assets/{CodeMirrorEditor-A9KvbIkm.js → CodeMirrorEditor-DQCjEnxG.js} +2 -2
- package/dist/client/assets/{dist-DM4Olwfx.js → dist-1Innu17E.js} +1 -1
- package/dist/client/assets/{dist-bIDce5j-.js → dist-1lynFOc5.js} +1 -1
- package/dist/client/assets/{dist-BlxwDnWr.js → dist-B1qLZVMK.js} +1 -1
- package/dist/client/assets/{dist-BTVbZigv.js → dist-B4pz_xdd.js} +1 -1
- package/dist/client/assets/{dist-UJ46aPS3.js → dist-B6KwDpeI.js} +1 -1
- package/dist/client/assets/{dist-CK2KFFtT.js → dist-BJVI1Unv.js} +1 -1
- package/dist/client/assets/{dist-CRnm1rnO.js → dist-BWvzD8Pe.js} +1 -1
- package/dist/client/assets/{dist-DSHM-PAs.js → dist-BayWssd0.js} +1 -1
- package/dist/client/assets/{dist-B9DE7sc5.js → dist-Bk_pkBVH.js} +1 -1
- package/dist/client/assets/{dist-D-o7iQ8c.js → dist-BpeClAv0.js} +1 -1
- package/dist/client/assets/{dist-CQFASxyb.js → dist-BsmJg5PX.js} +1 -1
- package/dist/client/assets/{dist-Ds5gfeAi.js → dist-Bx7s4rni.js} +1 -1
- package/dist/client/assets/{dist-UPJPMjWv.js → dist-CDBKM9tL.js} +1 -1
- package/dist/client/assets/{dist-CIgjifH-.js → dist-CGzXCzCG.js} +1 -1
- package/dist/client/assets/{dist-BAiV8I_O.js → dist-CRxCwn-Z.js} +1 -1
- package/dist/client/assets/{dist-Br-AuQUG.js → dist-CTDgUnBG.js} +1 -1
- package/dist/client/assets/{dist-4JbFTwg2.js → dist-D1VfMWmf.js} +1 -1
- package/dist/client/assets/{dist-BYOpvW02.js → dist-DTZLNEci.js} +1 -1
- package/dist/client/assets/{dist-CQV5kOCE.js → dist-DuUi29km.js} +1 -1
- package/dist/client/assets/{dist-CjszAoXk.js → dist-F3S0t1oM.js} +1 -1
- package/dist/client/assets/{dist-2boAf_pN.js → dist-QZ-FAVJV.js} +1 -1
- package/dist/client/assets/{dist-B1p4YF9C.js → dist-u0nBg0fZ.js} +1 -1
- package/dist/client/assets/index-BMpqviwU.css +1 -0
- package/dist/client/assets/index-Dzyp_7sf.js +102 -0
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +17 -0
- package/dist/server/active-memory.js +39 -0
- package/dist/server/agents.d.ts +1 -1
- package/dist/server/agents.js +3 -1
- package/dist/server/application/conversation-service.d.ts +1 -1
- package/dist/server/application/conversation-service.js +12 -4
- package/dist/server/application/run-service.d.ts +32 -2
- package/dist/server/application/run-service.js +296 -40
- package/dist/server/application/scheduled-task-service.d.ts +14 -1
- package/dist/server/application/scheduled-task-service.js +27 -2
- package/dist/server/application/session-orchestrator-service.d.ts +114 -3
- package/dist/server/application/session-orchestrator-service.js +406 -35
- package/dist/server/application/session-watchdog.d.ts +159 -0
- package/dist/server/application/session-watchdog.js +244 -0
- package/dist/server/command-handler.js +1 -1
- package/dist/server/conversation-usage.d.ts +19 -0
- package/dist/server/conversation-usage.js +40 -0
- package/dist/server/file-diff.d.ts +3 -0
- package/dist/server/file-diff.js +4 -4
- package/dist/server/file-index.d.ts +4 -2
- package/dist/server/file-index.js +14 -9
- package/dist/server/file-name-index.d.ts +5 -2
- package/dist/server/file-name-index.js +22 -5
- package/dist/server/git.d.ts +3 -3
- package/dist/server/git.js +10 -9
- package/dist/server/http-server.js +256 -34
- package/dist/server/index.js +3 -0
- package/dist/server/knowledge-vectors.d.ts +15 -0
- package/dist/server/knowledge-vectors.js +118 -37
- package/dist/server/knowledge.d.ts +7 -8
- package/dist/server/knowledge.js +133 -55
- package/dist/server/length-continuation.d.ts +50 -0
- package/dist/server/length-continuation.js +55 -0
- package/dist/server/model-context-window.d.ts +46 -0
- package/dist/server/model-context-window.js +121 -0
- package/dist/server/pi-session-events.js +5 -1
- package/dist/server/pi-session-pool.d.ts +2 -0
- package/dist/server/pi-session-pool.js +16 -0
- package/dist/server/pi-session.d.ts +6 -0
- package/dist/server/pi-session.js +15 -1
- package/dist/server/runtime.d.ts +3 -0
- package/dist/server/runtime.js +23 -7
- package/dist/server/session-cwd.d.ts +17 -0
- package/dist/server/session-cwd.js +43 -0
- package/dist/server/session-ledger.d.ts +7 -0
- package/dist/server/session-tree-scope.d.ts +125 -0
- package/dist/server/session-tree-scope.js +337 -0
- package/dist/server/store.d.ts +35 -0
- package/dist/server/store.js +87 -2
- package/dist/server/task-tool.d.ts +2 -1
- package/dist/server/task-tool.js +4 -1
- package/dist/server/terminal.d.ts +1 -0
- package/dist/server/terminal.js +5 -3
- package/dist/server/turn-failure.d.ts +26 -0
- package/dist/server/turn-failure.js +67 -1
- package/dist/server/types.d.ts +33 -1
- package/dist/server/version.d.ts +25 -2
- package/dist/server/version.js +98 -26
- package/dist/server/ws-server.d.ts +1 -1
- package/dist/server/ws-server.js +7 -5
- package/package.json +36 -3
- package/dist/client/assets/index-BGsP8pt_.js +0 -100
- 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
|
+
};
|
package/dist/server/store.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/server/store.js
CHANGED
|
@@ -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. */
|
package/dist/server/task-tool.js
CHANGED
|
@@ -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
|
|
package/dist/server/terminal.js
CHANGED
|
@@ -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(
|
|
311
|
-
send({ type: "terminal.error", sessionId: command.sessionId, message:
|
|
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
|
|
324
|
+
cwd,
|
|
323
325
|
env: { ...process.env, TERM: "xterm-256color" },
|
|
324
326
|
});
|
|
325
327
|
const session = { pty: child, closed: false, killed: false, pending: "" };
|