@evandu/super-pi 0.1.139 → 0.1.141
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 +418 -1
- package/agents/super-pi-task/index.ts +115 -0
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +7 -5
- package/dist/client/assets/{CodeMirrorEditor-H7SHGhdS.js → CodeMirrorEditor-Dm5IDKy9.js} +2 -2
- package/dist/client/assets/{dist-CZ9Krsno.js → dist-4baaMdqn.js} +1 -1
- package/dist/client/assets/{dist-Jw-GQ-re.js → dist-B-edBMDa.js} +1 -1
- package/dist/client/assets/{dist-zUJqdbfo.js → dist-BGaKp7BG.js} +1 -1
- package/dist/client/assets/{dist-2XXLO-IZ.js → dist-BMbwbGH9.js} +1 -1
- package/dist/client/assets/{dist-NlopeJ5R.js → dist-BqXU6S8h.js} +1 -1
- package/dist/client/assets/{dist-BEM6tttY.js → dist-BvCNiV_z.js} +1 -1
- package/dist/client/assets/{dist-CukPKrEn.js → dist-C7UQGfmz.js} +1 -1
- package/dist/client/assets/{dist-BfUeRJKr.js → dist-CMDW84Eh.js} +1 -1
- package/dist/client/assets/{dist-C8NllalC.js → dist-CN1j5LO2.js} +1 -1
- package/dist/client/assets/{dist-CUNctZ5v.js → dist-CNgQgdyS.js} +1 -1
- package/dist/client/assets/{dist-C_ftUQZN.js → dist-CSDLiQKt.js} +1 -1
- package/dist/client/assets/{dist-cP2vnTP_.js → dist-D0-9TI_k.js} +1 -1
- package/dist/client/assets/{dist-POoQ4j29.js → dist-D0eIQjoI.js} +1 -1
- package/dist/client/assets/{dist-BoucVzKA.js → dist-D7YZUqfA.js} +1 -1
- package/dist/client/assets/{dist-BokzcFLE.js → dist-DQWPdnen.js} +1 -1
- package/dist/client/assets/{dist-DJ8SwQl_.js → dist-DWj0wg8M.js} +1 -1
- package/dist/client/assets/{dist-BxTGjU84.js → dist-DtO-7WEZ.js} +1 -1
- package/dist/client/assets/{dist-D3Ta0y4e.js → dist-F7KOq8Xm.js} +1 -1
- package/dist/client/assets/{dist-DLKVFZhO.js → dist-GAPsnCM8.js} +1 -1
- package/dist/client/assets/{dist-CuMnZOGE.js → dist-LCDxbTTI.js} +1 -1
- package/dist/client/assets/{dist-CSdzvQnf.js → dist-M3BFsjal.js} +1 -1
- package/dist/client/assets/{dist-C-MUXK_t.js → dist-PQZBepmy.js} +1 -1
- package/dist/client/assets/index-BJIfLtDs.css +1 -0
- package/dist/client/assets/{index-B32VZVYx.js → index-DHsjaI7m.js} +13 -13
- package/dist/client/index.html +2 -2
- package/dist/server/agents.d.ts +2 -0
- package/dist/server/agents.js +10 -3
- package/dist/server/application/conversation-service.d.ts +15 -1
- package/dist/server/application/conversation-service.js +18 -2
- package/dist/server/application/run-service.d.ts +1 -0
- package/dist/server/application/run-service.js +121 -20
- package/dist/server/application/session-orchestrator-service.d.ts +113 -0
- package/dist/server/application/session-orchestrator-service.js +241 -0
- package/dist/server/command-handler.js +4 -0
- package/dist/server/http-server.js +135 -0
- package/dist/server/index.js +20 -2
- package/dist/server/runtime.d.ts +1 -0
- package/dist/server/runtime.js +6 -2
- package/dist/server/server-context.d.ts +2 -0
- package/dist/server/session-ledger.d.ts +60 -0
- package/dist/server/session-ledger.js +76 -0
- package/dist/server/store.d.ts +10 -0
- package/dist/server/store.js +38 -0
- package/dist/server/subagent-runs.d.ts +31 -1
- package/dist/server/subagent-runs.js +30 -13
- package/dist/server/task-tool.d.ts +18 -0
- package/dist/server/task-tool.js +85 -0
- package/dist/server/types.d.ts +56 -1
- package/dist/server/ws-server.d.ts +52 -1
- package/dist/server/ws-server.js +151 -35
- package/package.json +4 -2
- package/dist/client/assets/index-Bdym8BPo.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-DHsjaI7m.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BJIfLtDs.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
package/dist/server/agents.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ type Frontmatter = Record<string, unknown>;
|
|
|
37
37
|
* `validateToolNames` for what is rejected on that account and why.
|
|
38
38
|
*/
|
|
39
39
|
export declare const PI_TOOL_NAMES: readonly ["read", "bash", "powershell", "edit", "write", "grep", "find", "ls", "codemode", "tool_search", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"];
|
|
40
|
+
/** Tools shipped by this package. Authors must be able to explicitly deny host extras. */
|
|
41
|
+
export declare const SUPER_PI_TOOL_NAMES: readonly ["task", "todowrite", "workflow", "run_async", "scheduled_task", "question", "tool_register", "memory", "skill_manage", "session_search", "knowledge_search", "knowledge_import", "document_read", "document_write", "web_search", "web_fetch", "feishu_doc_read", "feishu_doc_write"];
|
|
40
42
|
/**
|
|
41
43
|
* Whether a name can be a real Pi tool. A name is real if it is one of `PI_TOOL_NAMES`, an MCP
|
|
42
44
|
* server tool, or a PATTERN.
|
package/dist/server/agents.js
CHANGED
|
@@ -36,7 +36,14 @@ export const PI_TOOL_NAMES = [
|
|
|
36
36
|
// core/mcp-servers.ts:31-37 - reachable on any server that exposes resources.
|
|
37
37
|
"list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource",
|
|
38
38
|
];
|
|
39
|
-
|
|
39
|
+
/** Tools shipped by this package. Authors must be able to explicitly deny host extras. */
|
|
40
|
+
export const SUPER_PI_TOOL_NAMES = [
|
|
41
|
+
"task", "todowrite", "workflow", "run_async", "scheduled_task", "question", "tool_register",
|
|
42
|
+
"memory", "skill_manage", "session_search", "knowledge_search", "knowledge_import",
|
|
43
|
+
"document_read", "document_write", "web_search", "web_fetch", "feishu_doc_read", "feishu_doc_write",
|
|
44
|
+
];
|
|
45
|
+
const KNOWN_TOOL_NAMES = [...PI_TOOL_NAMES, ...SUPER_PI_TOOL_NAMES];
|
|
46
|
+
const PI_TOOL_NAME_SET = new Set(KNOWN_TOOL_NAMES);
|
|
40
47
|
/**
|
|
41
48
|
* Whether a name can be a real Pi tool. A name is real if it is one of `PI_TOOL_NAMES`, an MCP
|
|
42
49
|
* server tool, or a PATTERN.
|
|
@@ -66,7 +73,7 @@ function editDistance(left, right) {
|
|
|
66
73
|
return row[right.length];
|
|
67
74
|
}
|
|
68
75
|
function closestToolName(name) {
|
|
69
|
-
const ranked =
|
|
76
|
+
const ranked = KNOWN_TOOL_NAMES.map((candidate) => ({ candidate, distance: editDistance(name, candidate) })).sort((a, b) => a.distance - b.distance);
|
|
70
77
|
return ranked[0]?.distance <= Math.max(2, Math.floor(name.length / 2)) ? ranked[0].candidate : undefined;
|
|
71
78
|
}
|
|
72
79
|
/**
|
|
@@ -95,7 +102,7 @@ function validateToolNames(tools) {
|
|
|
95
102
|
const suggestion = closestToolName(name);
|
|
96
103
|
return `${JSON.stringify(name)}${suggestion ? ` (did you mean ${JSON.stringify(suggestion)}?)` : ""}`;
|
|
97
104
|
});
|
|
98
|
-
return `tools names ${details.join(", ")}, which Pi does not have. Statically known tools: ${
|
|
105
|
+
return `tools names ${details.join(", ")}, which Pi does not have. Statically known tools: ${KNOWN_TOOL_NAMES.join(", ")}; MCP tools are named mcp__<server>__<tool>, and a "*" in a name is a Pi glob.`;
|
|
99
106
|
}
|
|
100
107
|
/**
|
|
101
108
|
* Splits a definition file into frontmatter and body. The body is the system prompt, exactly as in
|
|
@@ -39,8 +39,16 @@ export declare class ConversationService {
|
|
|
39
39
|
setAgentsDir(dir: string | string[]): void;
|
|
40
40
|
/**
|
|
41
41
|
* Creates a new conversation in a workspace.
|
|
42
|
+
*
|
|
43
|
+
* `parentage` is WHO OPENED THIS CONVERSATION, recorded at birth exactly once: the session
|
|
44
|
+
* tree's edge (`parentConversationId`) and its origin. Only the orchestrator passes it - the "+"
|
|
45
|
+
* button and every other caller create a top-level conversation by omission, which is the same
|
|
46
|
+
* "absence means top level" rule the pre-tree state.json files read under.
|
|
42
47
|
*/
|
|
43
|
-
createConversation(workspaceId: string
|
|
48
|
+
createConversation(workspaceId: string, parentage?: {
|
|
49
|
+
parentConversationId?: string;
|
|
50
|
+
origin?: "user" | "orchestrator";
|
|
51
|
+
}): Promise<string>;
|
|
44
52
|
/**
|
|
45
53
|
* Name a conversation from what was said in it. Returns the title; the CALLER applies it, because the
|
|
46
54
|
* rename is a second command with its own optimistic path and its own failure toast, and a service that
|
|
@@ -69,6 +77,12 @@ export declare class ConversationService {
|
|
|
69
77
|
* Unarchives a conversation.
|
|
70
78
|
*/
|
|
71
79
|
unarchiveConversation(conversationId: string): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* Collapses or expands one conversation's subtree in the sidebar's session tree. The state is
|
|
82
|
+
* the store's, server-side; this method only adds the conversation-exists check and the
|
|
83
|
+
* broadcast every other write here performs.
|
|
84
|
+
*/
|
|
85
|
+
setCollapsed(conversationId: string, collapsed: boolean): Promise<void>;
|
|
72
86
|
/**
|
|
73
87
|
* Renames a conversation.
|
|
74
88
|
*/
|
|
@@ -38,8 +38,13 @@ export class ConversationService {
|
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
40
|
* Creates a new conversation in a workspace.
|
|
41
|
+
*
|
|
42
|
+
* `parentage` is WHO OPENED THIS CONVERSATION, recorded at birth exactly once: the session
|
|
43
|
+
* tree's edge (`parentConversationId`) and its origin. Only the orchestrator passes it - the "+"
|
|
44
|
+
* button and every other caller create a top-level conversation by omission, which is the same
|
|
45
|
+
* "absence means top level" rule the pre-tree state.json files read under.
|
|
41
46
|
*/
|
|
42
|
-
async createConversation(workspaceId) {
|
|
47
|
+
async createConversation(workspaceId, parentage) {
|
|
43
48
|
const workspace = this.store.workspace(workspaceId);
|
|
44
49
|
if (!workspace)
|
|
45
50
|
throw new Error("找不到这个工作区。");
|
|
@@ -104,7 +109,7 @@ export class ConversationService {
|
|
|
104
109
|
: { model, thinkingLevel };
|
|
105
110
|
// `null` is intentional here: Store uses it to remove a workspace value that it
|
|
106
111
|
// would otherwise have copied before applying the resolved configuration.
|
|
107
|
-
const defaults = { ...resolved, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null, ...(modelNotice ? { modelNotice } : {}) };
|
|
112
|
+
const defaults = { ...resolved, model: resolved.model ?? null, thinkingLevel: resolved.thinkingLevel ?? null, ...(modelNotice ? { modelNotice } : {}), ...(parentage ? { parentConversationId: parentage.parentConversationId, origin: parentage.origin } : {}) };
|
|
108
113
|
const created = await this.store.createConversation(workspaceId, defaults);
|
|
109
114
|
this.broadcaster.broadcastState();
|
|
110
115
|
return created.id;
|
|
@@ -174,6 +179,17 @@ export class ConversationService {
|
|
|
174
179
|
await this.store.unarchiveConversation(conversationId);
|
|
175
180
|
this.broadcaster.broadcastState();
|
|
176
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Collapses or expands one conversation's subtree in the sidebar's session tree. The state is
|
|
184
|
+
* the store's, server-side; this method only adds the conversation-exists check and the
|
|
185
|
+
* broadcast every other write here performs.
|
|
186
|
+
*/
|
|
187
|
+
async setCollapsed(conversationId, collapsed) {
|
|
188
|
+
if (!this.store.conversation(conversationId))
|
|
189
|
+
throw new Error("找不到这个会话。");
|
|
190
|
+
await this.store.setConversationCollapsed(conversationId, collapsed);
|
|
191
|
+
this.broadcaster.broadcastState();
|
|
192
|
+
}
|
|
177
193
|
/**
|
|
178
194
|
* Renames a conversation.
|
|
179
195
|
*/
|
|
@@ -85,6 +85,7 @@ export declare class RunService {
|
|
|
85
85
|
setNameIndexes(indexes: Map<string, import("../file-name-index.js").FileNameIndex>): void;
|
|
86
86
|
reviewMemoryBoundary(conversationId: string): void;
|
|
87
87
|
private readonly activeRuns;
|
|
88
|
+
private readonly turnEventScopes;
|
|
88
89
|
private readonly historyCutClaims;
|
|
89
90
|
holdHistoryCut(conversationId: string): void;
|
|
90
91
|
releaseHistoryCut(conversationId: string): void;
|
|
@@ -11,6 +11,7 @@ import { toWorkspaceRelative } from "../files.js";
|
|
|
11
11
|
import { refreshWorkspaceIndex } from "../file-index.js";
|
|
12
12
|
import { execFile } from "node:child_process";
|
|
13
13
|
import { promisify } from "node:util";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
14
15
|
const execFileAsync = promisify(execFile);
|
|
15
16
|
/** Expands composer references into explicit context for the model. */
|
|
16
17
|
async function expandAtReferences(store, workspace, content) {
|
|
@@ -115,6 +116,7 @@ export class RunService {
|
|
|
115
116
|
this.memory?.review(conversation);
|
|
116
117
|
}
|
|
117
118
|
activeRuns = new Map();
|
|
119
|
+
turnEventScopes = new Map();
|
|
118
120
|
historyCutClaims = new Map();
|
|
119
121
|
holdHistoryCut(conversationId) {
|
|
120
122
|
this.historyCutClaims.set(conversationId, (this.historyCutClaims.get(conversationId) ?? 0) + 1);
|
|
@@ -847,8 +849,31 @@ export class RunService {
|
|
|
847
849
|
* nothing above it - the user could not find what they had typed, while the agent quoted it back.
|
|
848
850
|
*/
|
|
849
851
|
async streamTurn(conversation, workspace, prompt, assistantMessage, controller, userMessageId, images, agentIdentity) {
|
|
852
|
+
const scope = { id: randomUUID(), controller, accepting: true };
|
|
853
|
+
this.turnEventScopes.set(conversation.id, scope);
|
|
854
|
+
const ownsScope = () => this.turnEventScopes.get(conversation.id) === scope
|
|
855
|
+
&& this.activeRuns.get(conversation.id) === scope.controller;
|
|
856
|
+
// Abort closes new work, while an existing tool outcome and transcript locator may still settle.
|
|
857
|
+
// Iterator completion closes ALL runtime callbacks before the accepted write lane is drained.
|
|
858
|
+
const rejectedEvents = new Set();
|
|
859
|
+
const acceptsEvent = (event, settlement = false) => {
|
|
860
|
+
const current = ownsScope() && scope.accepting;
|
|
861
|
+
if (!current && !rejectedEvents.has(event)) {
|
|
862
|
+
rejectedEvents.add(event);
|
|
863
|
+
console.warn(`Super Pi: ignored stale turn event conversation=${conversation.id} turn=${scope.id} event=${event}`);
|
|
864
|
+
}
|
|
865
|
+
return current && (settlement || !controller.signal.aborted);
|
|
866
|
+
};
|
|
867
|
+
const traceScope = (phase) => {
|
|
868
|
+
if (process.env.SUPER_PI_TRACE_LATENCY === "1") {
|
|
869
|
+
console.info(`[super-pi turn] conversation=${conversation.id} turn=${scope.id} phase=${phase} aborted=${controller.signal.aborted}`);
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
traceScope("opened");
|
|
850
873
|
this.extensionUiService.cancelPendingUi(conversation.id, "expired");
|
|
874
|
+
const uiHost = this.extensionUiService.createHost(conversation.id);
|
|
851
875
|
let assistant = assistantMessage;
|
|
876
|
+
let closedOwnScope = false;
|
|
852
877
|
let toolCalls = [];
|
|
853
878
|
let pendingTools = Promise.resolve();
|
|
854
879
|
// A TURN MOVES FROM `waiting` TO `running` ONCE THE MODEL HAS PRODUCED ANY SIGNAL: a text delta
|
|
@@ -872,11 +897,14 @@ export class RunService {
|
|
|
872
897
|
* a snapshot only when it actually flips the status, and the caller asks.
|
|
873
898
|
*/
|
|
874
899
|
const promoteToRunning = async () => {
|
|
875
|
-
|
|
900
|
+
// Accepted tool writes may drain after the producer closed; that is normal cleanup, not a stale event.
|
|
901
|
+
if (!ownsScope() || !scope.accepting || controller.signal.aborted || !firstSignal)
|
|
876
902
|
return false;
|
|
877
903
|
firstSignal = false;
|
|
878
|
-
if (this.store.
|
|
904
|
+
if (this.store.conversationStatus(conversation.id) === "waiting") {
|
|
879
905
|
await this.store.setStatus(conversation.id, "running");
|
|
906
|
+
if (!ownsScope() || !scope.accepting || controller.signal.aborted)
|
|
907
|
+
return false;
|
|
880
908
|
this.broadcaster.broadcastState();
|
|
881
909
|
return true;
|
|
882
910
|
}
|
|
@@ -896,13 +924,21 @@ export class RunService {
|
|
|
896
924
|
if (!checkpointPending)
|
|
897
925
|
return;
|
|
898
926
|
checkpointPending = false;
|
|
927
|
+
if (!ownsScope())
|
|
928
|
+
return;
|
|
899
929
|
// Tool updates and message checkpoints both replace the persisted message. They share this lane
|
|
900
930
|
// so an earlier text snapshot cannot commit after a newer tool state and erase it on disk.
|
|
901
|
-
pendingTools = pendingTools.then(
|
|
931
|
+
pendingTools = pendingTools.then(async () => {
|
|
932
|
+
if (ownsScope())
|
|
933
|
+
await this.store.checkpointMessage(conversation.id, assistant.id);
|
|
934
|
+
}).catch((error) => {
|
|
902
935
|
console.warn(`Super Pi: streaming checkpoint failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
903
936
|
});
|
|
904
937
|
};
|
|
905
938
|
const scheduleCheckpoint = () => {
|
|
939
|
+
// The final checkpoint owns all accepted live text after the producer closes.
|
|
940
|
+
if (!scope.accepting || !ownsScope())
|
|
941
|
+
return;
|
|
906
942
|
checkpointPending = true;
|
|
907
943
|
if (checkpointTimer !== undefined)
|
|
908
944
|
return;
|
|
@@ -910,14 +946,35 @@ export class RunService {
|
|
|
910
946
|
};
|
|
911
947
|
const enqueueTools = (what, work) => {
|
|
912
948
|
pendingTools = pendingTools.then(async () => {
|
|
949
|
+
if (!ownsScope())
|
|
950
|
+
return;
|
|
913
951
|
// A TOOL CALL IS A SIGNAL THE MODEL IS ALIVE. Promote `waiting` → `running` on the same
|
|
914
952
|
// path as text deltas: a tool row appearing on screen is the same "we have data" event
|
|
915
953
|
// the status needs to reflect. Doing it BEFORE the tool write keeps the order
|
|
916
954
|
// honest - a client that saw the new tool row in the same frame also saw the running
|
|
917
955
|
// status, instead of flashing back to a still-spinning waiting mark for the next paint.
|
|
918
956
|
await promoteToRunning();
|
|
957
|
+
if (!ownsScope())
|
|
958
|
+
return;
|
|
919
959
|
const hadPendingQuestion = toolCalls.some(call => call.question?.status === "pending");
|
|
920
960
|
toolCalls = await work();
|
|
961
|
+
if (!ownsScope())
|
|
962
|
+
return;
|
|
963
|
+
// Stop may have scanned pending questions before this accepted write reached the Store.
|
|
964
|
+
// Keep the tool record, but do not resurrect a human wait after cancellation.
|
|
965
|
+
if (controller.signal.aborted) {
|
|
966
|
+
for (let index = 0; index < toolCalls.length; index++) {
|
|
967
|
+
const question = toolCalls[index].question;
|
|
968
|
+
if (question?.status !== "pending")
|
|
969
|
+
continue;
|
|
970
|
+
toolCalls = await this.store.updateToolCall(conversation.id, assistant.id, index, {
|
|
971
|
+
question: { items: question.items, status: "cancelled" },
|
|
972
|
+
});
|
|
973
|
+
if (!ownsScope())
|
|
974
|
+
return;
|
|
975
|
+
this.settleQuestion(conversation.id, assistant.id, index, toolCalls[index]?.question);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
921
978
|
this.broadcaster.broadcast({ type: "message.tools", conversationId: conversation.id, messageId: assistant.id, toolCalls });
|
|
922
979
|
if (hadPendingQuestion !== toolCalls.some(call => call.question?.status === "pending"))
|
|
923
980
|
this.broadcaster.broadcastState();
|
|
@@ -980,6 +1037,8 @@ export class RunService {
|
|
|
980
1037
|
*/
|
|
981
1038
|
const preparingRows = new Map();
|
|
982
1039
|
const onToolPreparing = (call) => {
|
|
1040
|
+
if (!acceptsEvent("tool.preparing"))
|
|
1041
|
+
return;
|
|
983
1042
|
const existing = preparingRows.get(call.contentIndex);
|
|
984
1043
|
if (existing === undefined && call.final) {
|
|
985
1044
|
// `toolcall_end` FOR A CALL THAT IS ALREADY ON SCREEN. Pi closes an argument document whenever it
|
|
@@ -1069,6 +1128,8 @@ export class RunService {
|
|
|
1069
1128
|
*/
|
|
1070
1129
|
const fileWritePaths = new Map();
|
|
1071
1130
|
const onFileWrite = (path, _toolName, toolCallId) => {
|
|
1131
|
+
if (!acceptsEvent("file.write"))
|
|
1132
|
+
return;
|
|
1072
1133
|
const tracker = this.fileChanges;
|
|
1073
1134
|
if (!tracker)
|
|
1074
1135
|
return;
|
|
@@ -1098,6 +1159,8 @@ export class RunService {
|
|
|
1098
1159
|
this.broadcastFileChanges(workspace.id);
|
|
1099
1160
|
};
|
|
1100
1161
|
const onToolCall = (call) => {
|
|
1162
|
+
if (!acceptsEvent("tool.start"))
|
|
1163
|
+
return;
|
|
1101
1164
|
const at = spoken;
|
|
1102
1165
|
// THE PLACEHOLDER THAT BELONGS TO THIS CALL, IF ONE IS ON SCREEN.
|
|
1103
1166
|
//
|
|
@@ -1156,12 +1219,16 @@ export class RunService {
|
|
|
1156
1219
|
enqueueTools("a tool call", () => this.store.appendToolCall(conversation.id, assistant.id, { label: call.name, detail: call.detail, args: call.args, at, thinkingAt, thinkingBlock: callThinkingBlock, ...(call.question ? { question: call.question } : {}) }));
|
|
1157
1220
|
};
|
|
1158
1221
|
const onToolUpdate = (call) => {
|
|
1222
|
+
if (!acceptsEvent("tool.update"))
|
|
1223
|
+
return;
|
|
1159
1224
|
const index = call.id ? rowByToolCallId.get(call.id) : undefined;
|
|
1160
1225
|
if (index === undefined)
|
|
1161
1226
|
return;
|
|
1162
1227
|
enqueueTools("a tool call update", () => this.store.updateToolCall(conversation.id, assistant.id, index, { ...(call.args ? { args: call.args } : {}), ...(call.live !== undefined ? { live: call.live } : {}) }));
|
|
1163
1228
|
};
|
|
1164
1229
|
const onToolEnd = (call) => {
|
|
1230
|
+
if (!acceptsEvent("tool.end", true))
|
|
1231
|
+
return;
|
|
1165
1232
|
// THE WRITE IS FINISHED, so the baseline can be CHECKED. The tracker recorded the file's etag when
|
|
1166
1233
|
// the write was announced; if the file's etag now differs, the baseline really is the content
|
|
1167
1234
|
// from before the write. If it does not, the baseline was taken too late to be shown, and the
|
|
@@ -1199,14 +1266,18 @@ export class RunService {
|
|
|
1199
1266
|
* it is a global fold sequence, and restarting it would make the browser render two folds numbered 0.
|
|
1200
1267
|
*/
|
|
1201
1268
|
const onSteerDelivered = (text) => {
|
|
1269
|
+
if (!acceptsEvent("steer.delivered"))
|
|
1270
|
+
return Promise.resolve();
|
|
1202
1271
|
enqueueTools("a queued message being delivered", async () => {
|
|
1203
1272
|
// A delayed SDK delivery during abort still belongs to the stopped producer.
|
|
1204
|
-
if (
|
|
1273
|
+
if (!acceptsEvent("steer.delivered"))
|
|
1205
1274
|
return toolCalls;
|
|
1206
1275
|
const previous = assistant;
|
|
1207
1276
|
// Checkpoint first: the message being left behind is complete - Pi drains its queues at a turn
|
|
1208
1277
|
// boundary - and this is the last chance to write it durably with the lane it belongs to.
|
|
1209
1278
|
await this.store.checkpointMessage(conversation.id, previous.id);
|
|
1279
|
+
if (!acceptsEvent("steer.delivered"))
|
|
1280
|
+
return toolCalls;
|
|
1210
1281
|
// WHICH MESSAGE THIS DELIVERY IS ABOUT. Pi announces the text it picked up, and Pi itself matches
|
|
1211
1282
|
// its own queue by that text (`agent-session.ts:1123`), so the same text resolves to the same
|
|
1212
1283
|
// message on both sides - including when two messages carry identical words, where both sides
|
|
@@ -1225,6 +1296,8 @@ export class RunService {
|
|
|
1225
1296
|
// assistant message for one of those would open an empty bubble before every single turn.
|
|
1226
1297
|
if (!delivered)
|
|
1227
1298
|
return toolCalls;
|
|
1299
|
+
if (!ownsScope())
|
|
1300
|
+
return toolCalls;
|
|
1228
1301
|
const next = delivered.assistant;
|
|
1229
1302
|
this.broadcaster.broadcastState();
|
|
1230
1303
|
assistant = next;
|
|
@@ -1247,11 +1320,22 @@ export class RunService {
|
|
|
1247
1320
|
prompt,
|
|
1248
1321
|
signal: controller.signal,
|
|
1249
1322
|
cwd: workspace.path,
|
|
1250
|
-
host:
|
|
1251
|
-
|
|
1323
|
+
host: {
|
|
1324
|
+
notify: (request) => { if (acceptsEvent("ui.notify"))
|
|
1325
|
+
uiHost.notify(request); },
|
|
1326
|
+
ask: (request) => acceptsEvent("ui.ask") ? uiHost.ask(request) : Promise.resolve({ cancelled: true }),
|
|
1327
|
+
},
|
|
1328
|
+
onPiQueue: (snapshot) => { if (acceptsEvent("pi.queue"))
|
|
1329
|
+
this.onPiQueue(conversation.id, snapshot); },
|
|
1252
1330
|
onSteerDelivered,
|
|
1253
|
-
onUserEntry: (entryId) =>
|
|
1254
|
-
|
|
1331
|
+
onUserEntry: async (entryId) => {
|
|
1332
|
+
if (acceptsEvent("transcript.entry", true))
|
|
1333
|
+
await this.store.setMessagePiEntryId(conversation.id, userMessageId, entryId);
|
|
1334
|
+
},
|
|
1335
|
+
onSessionFile: async (path) => {
|
|
1336
|
+
if (acceptsEvent("transcript.file", true))
|
|
1337
|
+
await this.store.setConversationPiSessionFile(conversation.id, path);
|
|
1338
|
+
},
|
|
1255
1339
|
onToolCall,
|
|
1256
1340
|
onFileWrite,
|
|
1257
1341
|
onToolPreparing,
|
|
@@ -1265,7 +1349,7 @@ export class RunService {
|
|
|
1265
1349
|
// having received one. Pi announces the END of reasoning with `thinking_end` and puts nothing on
|
|
1266
1350
|
// the delta, so these two halves together are the whole lifecycle.
|
|
1267
1351
|
onThinking: async (delta) => {
|
|
1268
|
-
if (!delta)
|
|
1352
|
+
if (!delta || !acceptsEvent("thinking.delta"))
|
|
1269
1353
|
return;
|
|
1270
1354
|
// THINKING IS THE MODEL PRODUCING SOMETHING, SO THE TURN IS RUNNING. The contract above
|
|
1271
1355
|
// says a turn moves from `waiting` to `running` once the model has produced ANY signal -
|
|
@@ -1303,7 +1387,9 @@ export class RunService {
|
|
|
1303
1387
|
// published live. `promoteToRunning` answers whether it published one.
|
|
1304
1388
|
this.store.appendThinkingDeltaLive(conversation.id, assistant.id, delta, spoken, newBlock);
|
|
1305
1389
|
const promoted = await promoteToRunning();
|
|
1306
|
-
if (!
|
|
1390
|
+
if (!ownsScope())
|
|
1391
|
+
return;
|
|
1392
|
+
if (!promoted && acceptsEvent("thinking.delta")) {
|
|
1307
1393
|
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta, active: true, at: spoken, blockIndex: thinkingBlock });
|
|
1308
1394
|
}
|
|
1309
1395
|
scheduleCheckpoint();
|
|
@@ -1314,6 +1400,8 @@ export class RunService {
|
|
|
1314
1400
|
// block to every turn whose second reasoning run starts after the answer has already begun,
|
|
1315
1401
|
// because that block's `at` then differs from the previous one's.
|
|
1316
1402
|
onThinkingEnd: () => {
|
|
1403
|
+
if (!acceptsEvent("thinking.end"))
|
|
1404
|
+
return;
|
|
1317
1405
|
thinkingActive = false;
|
|
1318
1406
|
this.store.setThinkingActiveLive(conversation.id, assistant.id, false);
|
|
1319
1407
|
this.broadcaster.broadcast({ type: "message.thinking", conversationId: conversation.id, messageId: assistant.id, delta: "", active: false });
|
|
@@ -1328,12 +1416,16 @@ export class RunService {
|
|
|
1328
1416
|
// publish a new message underneath it. One `await` at the top covers both, and costs nothing:
|
|
1329
1417
|
// the lane is empty most of the time.
|
|
1330
1418
|
await pendingTools;
|
|
1419
|
+
if (!acceptsEvent("text.delta"))
|
|
1420
|
+
continue;
|
|
1331
1421
|
// `waiting` → `running` ON THE FIRST DELTA, before the live write. The conversation was
|
|
1332
1422
|
// opened in `waiting` (see `openTurn` / `openTurnOnSpecificMessage` / `openTurnOnMessage`),
|
|
1333
1423
|
// and the first response token is the observable fact that says the LLM is actually
|
|
1334
1424
|
// answering rather than hanging on auth / quota / network. `promoteToRunning` is a no-op
|
|
1335
1425
|
// after the first signal so this stays a one-shot status write, not a per-token one.
|
|
1336
1426
|
await promoteToRunning();
|
|
1427
|
+
if (!acceptsEvent("text.delta"))
|
|
1428
|
+
continue;
|
|
1337
1429
|
if (!thinkingActive && thinkingBlock >= 0)
|
|
1338
1430
|
answerAfterThinking = true;
|
|
1339
1431
|
this.store.appendAssistantDeltaLive(conversation.id, assistant.id, delta);
|
|
@@ -1346,10 +1438,14 @@ export class RunService {
|
|
|
1346
1438
|
}
|
|
1347
1439
|
}
|
|
1348
1440
|
finally {
|
|
1441
|
+
scope.accepting = false;
|
|
1442
|
+
traceScope("draining");
|
|
1349
1443
|
// A failed or cancelled producer still owns live text and a checkpoint timer.
|
|
1350
1444
|
// Drain the lane before the caller changes status or permits another turn.
|
|
1351
|
-
|
|
1352
|
-
|
|
1445
|
+
if (ownsScope()) {
|
|
1446
|
+
this.store.setThinkingActiveLive(conversation.id, assistant.id, false);
|
|
1447
|
+
checkpointPending = true;
|
|
1448
|
+
}
|
|
1353
1449
|
checkpointNow();
|
|
1354
1450
|
// A CALL THE MODEL STARTED AND NEVER RAN. Pi announces a call with `toolcall_start` and only tells
|
|
1355
1451
|
// the host it is running with `tool_execution_start`, so an abort, a cancelled turn or an upstream
|
|
@@ -1357,7 +1453,7 @@ export class RunService {
|
|
|
1357
1453
|
// rows, so each one kept 「生成参数中…」 for the rest of the conversation. Queued HERE, before the
|
|
1358
1454
|
// await below, so it drains behind the last real tool write rather than racing it; `preparingRows`
|
|
1359
1455
|
// is read here because by this line no further call can be announced.
|
|
1360
|
-
if (preparingRows.size) {
|
|
1456
|
+
if (ownsScope() && preparingRows.size) {
|
|
1361
1457
|
const stranded = [...preparingRows.values()].map((entry) => entry.row);
|
|
1362
1458
|
preparingRows.clear();
|
|
1363
1459
|
enqueueTools("unfinished tool calls", () => this.store.clearPreparingToolCalls(conversation.id, assistant.id, stranded));
|
|
@@ -1366,15 +1462,20 @@ export class RunService {
|
|
|
1366
1462
|
// The positions are facts ABOUT THIS TURN and stop being true when it ends, so they go with it.
|
|
1367
1463
|
// A dispatch whose POST arrives after this line gets no anchor and renders where it always did -
|
|
1368
1464
|
// which is the honest outcome, because by then the call that asked for it may no longer be on screen.
|
|
1369
|
-
this.
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1465
|
+
if (this.turnEventScopes.get(conversation.id) === scope) {
|
|
1466
|
+
closedOwnScope = ownsScope();
|
|
1467
|
+
this.turnEventScopes.delete(conversation.id);
|
|
1468
|
+
this.toolCallSites.delete(conversation.id);
|
|
1469
|
+
this.toolCallLanes.delete(conversation.id);
|
|
1470
|
+
for (const [key, waiters] of this.toolCallWaiters) {
|
|
1471
|
+
if (key.startsWith(`${conversation.id}:`))
|
|
1472
|
+
for (const wake of waiters)
|
|
1473
|
+
wake();
|
|
1474
|
+
}
|
|
1375
1475
|
}
|
|
1476
|
+
traceScope("closed");
|
|
1376
1477
|
}
|
|
1377
|
-
if (!controller.signal.aborted) {
|
|
1478
|
+
if (closedOwnScope && this.activeRuns.get(conversation.id) === controller && !controller.signal.aborted) {
|
|
1378
1479
|
const completed = this.store.snapshot().conversations.find(c => c.id === conversation.id);
|
|
1379
1480
|
if (completed)
|
|
1380
1481
|
await this.memory?.completed(completed).catch(error => console.warn("Super Pi memory scheduling failed:", error));
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Store } from "../store.js";
|
|
2
|
+
import type { ConversationService } from "./conversation-service.js";
|
|
3
|
+
import type { RunService } from "./run-service.js";
|
|
4
|
+
import type { SessionLedger } from "../session-ledger.js";
|
|
5
|
+
import type { EventBroadcaster } from "../server-context.js";
|
|
6
|
+
import type { RunStatus } from "../types.js";
|
|
7
|
+
export interface SpawnSessionOptions {
|
|
8
|
+
/** The high-level task to be performed by the sibling session. */
|
|
9
|
+
task: string;
|
|
10
|
+
/** Parent conversation initiating the orchestration, if any. */
|
|
11
|
+
parentConversationId?: string;
|
|
12
|
+
/** Workspace ID for the new session. Inherited from parent if omitted. */
|
|
13
|
+
workspaceId?: string;
|
|
14
|
+
/** Optional custom title for the new session. */
|
|
15
|
+
title?: string;
|
|
16
|
+
/** Optional agent override for the new session. */
|
|
17
|
+
agentId?: string;
|
|
18
|
+
/** Optional model override for the new session. */
|
|
19
|
+
model?: {
|
|
20
|
+
provider: string;
|
|
21
|
+
modelId: string;
|
|
22
|
+
};
|
|
23
|
+
/** Optional thinking level override for the new session. */
|
|
24
|
+
thinkingLevel?: string;
|
|
25
|
+
/** Initial prompt to send immediately. Defaults to `task`. */
|
|
26
|
+
initialPrompt?: string;
|
|
27
|
+
/** Whether to await turn completion before returning (default: false, asynchronous dispatch). */
|
|
28
|
+
wait?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface StopSessionOptions {
|
|
31
|
+
/** Stop mode: "active" (active turn), "queued" (waiting messages in queue), or "all" (both, default). */
|
|
32
|
+
mode?: "active" | "queued" | "all";
|
|
33
|
+
/** Optional specific waiting messageId to interrupt directly. */
|
|
34
|
+
messageId?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface SessionSummary {
|
|
37
|
+
conversationId: string;
|
|
38
|
+
workspaceId: string;
|
|
39
|
+
title: string;
|
|
40
|
+
status: RunStatus;
|
|
41
|
+
origin: "user" | "orchestrator";
|
|
42
|
+
parentConversationId?: string;
|
|
43
|
+
task?: string;
|
|
44
|
+
messageCount: number;
|
|
45
|
+
turnCount: number;
|
|
46
|
+
lastAssistantSnippet?: string;
|
|
47
|
+
lastUserSnippet?: string;
|
|
48
|
+
hasPendingQuestion: boolean;
|
|
49
|
+
activeTool?: {
|
|
50
|
+
label: string;
|
|
51
|
+
detail?: string;
|
|
52
|
+
};
|
|
53
|
+
error?: string;
|
|
54
|
+
updatedAt: string;
|
|
55
|
+
createdAt?: number;
|
|
56
|
+
lastProgress?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Orchestrator service coordinating sibling and child conversations.
|
|
60
|
+
*
|
|
61
|
+
* Provides lifecycle methods (spawn, dispatch, status, list, stop, resume) without
|
|
62
|
+
* duplicating message history or inflating memory.
|
|
63
|
+
*/
|
|
64
|
+
export declare class SessionOrchestratorService {
|
|
65
|
+
private readonly store;
|
|
66
|
+
private readonly conversationService;
|
|
67
|
+
private readonly runService;
|
|
68
|
+
private readonly ledger;
|
|
69
|
+
private readonly broadcaster?;
|
|
70
|
+
constructor(store: Store, conversationService: ConversationService, runService: RunService, ledger: SessionLedger, broadcaster?: EventBroadcaster | undefined);
|
|
71
|
+
/**
|
|
72
|
+
* Spawns a new sibling conversation and immediately dispatches its initial task.
|
|
73
|
+
*/
|
|
74
|
+
spawn(options: SpawnSessionOptions): Promise<{
|
|
75
|
+
conversationId: string;
|
|
76
|
+
}>;
|
|
77
|
+
/**
|
|
78
|
+
* Dispatches a message to an arbitrary session (server-to-server).
|
|
79
|
+
* By default dispatches asynchronously so the orchestrator caller is not blocked.
|
|
80
|
+
*/
|
|
81
|
+
dispatch(conversationId: string, content: string, options?: {
|
|
82
|
+
wait?: boolean;
|
|
83
|
+
}): Promise<{
|
|
84
|
+
conversationId: string;
|
|
85
|
+
}>;
|
|
86
|
+
/**
|
|
87
|
+
* Returns a high-level summary of a session's progress without returning full transcripts.
|
|
88
|
+
* Uses narrow store reader `store.conversation(id)` to avoid cloning the entire state.
|
|
89
|
+
*/
|
|
90
|
+
status(conversationId: string): Promise<SessionSummary>;
|
|
91
|
+
/**
|
|
92
|
+
* Lists summaries of all sessions, optionally filtered by parent conversation.
|
|
93
|
+
*/
|
|
94
|
+
list(parentConversationId?: string): Promise<SessionSummary[]>;
|
|
95
|
+
/**
|
|
96
|
+
* Stops a target session.
|
|
97
|
+
* Can distinguish between interrupting an active turn, cancelling queued messages, or both.
|
|
98
|
+
*/
|
|
99
|
+
stop(conversationId: string, options?: StopSessionOptions): Promise<{
|
|
100
|
+
stopped: boolean;
|
|
101
|
+
mode: string;
|
|
102
|
+
}>;
|
|
103
|
+
/**
|
|
104
|
+
* Resumes execution in a conversation.
|
|
105
|
+
* If prompt is provided, sends it as a new turn.
|
|
106
|
+
* Otherwise, if there is a waiting message in the queue, kicks off its execution.
|
|
107
|
+
*/
|
|
108
|
+
resume(conversationId: string, prompt?: string, options?: {
|
|
109
|
+
wait?: boolean;
|
|
110
|
+
}): Promise<{
|
|
111
|
+
conversationId: string;
|
|
112
|
+
}>;
|
|
113
|
+
}
|