@evandu/super-pi 0.1.140 → 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/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +6 -5
- package/dist/client/assets/{CodeMirrorEditor-DvZeOLny.js → CodeMirrorEditor-Dm5IDKy9.js} +2 -2
- package/dist/client/assets/{dist-CTkzLBdb.js → dist-4baaMdqn.js} +1 -1
- package/dist/client/assets/{dist-DAcS4Kj8.js → dist-B-edBMDa.js} +1 -1
- package/dist/client/assets/{dist-Cb1gxS57.js → dist-BGaKp7BG.js} +1 -1
- package/dist/client/assets/{dist-DRgWZqFH.js → dist-BMbwbGH9.js} +1 -1
- package/dist/client/assets/{dist-CaUsvSz1.js → dist-BqXU6S8h.js} +1 -1
- package/dist/client/assets/{dist-DlQ8PMvv.js → dist-BvCNiV_z.js} +1 -1
- package/dist/client/assets/{dist-3BuyVG3H.js → dist-C7UQGfmz.js} +1 -1
- package/dist/client/assets/{dist-C-QmRwbd.js → dist-CMDW84Eh.js} +1 -1
- package/dist/client/assets/{dist-CqDSeoUk.js → dist-CN1j5LO2.js} +1 -1
- package/dist/client/assets/{dist-DA8yW4lK.js → dist-CNgQgdyS.js} +1 -1
- package/dist/client/assets/{dist-DN0M4DAX.js → dist-CSDLiQKt.js} +1 -1
- package/dist/client/assets/{dist-BCUfH4nP.js → dist-D0-9TI_k.js} +1 -1
- package/dist/client/assets/{dist-DdfW8Gjv.js → dist-D0eIQjoI.js} +1 -1
- package/dist/client/assets/{dist-qG53hT1b.js → dist-D7YZUqfA.js} +1 -1
- package/dist/client/assets/{dist-gyzurUDR.js → dist-DQWPdnen.js} +1 -1
- package/dist/client/assets/{dist-HJdP1yxN.js → dist-DWj0wg8M.js} +1 -1
- package/dist/client/assets/{dist-C_VuSpx8.js → dist-DtO-7WEZ.js} +1 -1
- package/dist/client/assets/{dist-r4Kq1ABA.js → dist-F7KOq8Xm.js} +1 -1
- package/dist/client/assets/{dist-D7yfnKNC.js → dist-GAPsnCM8.js} +1 -1
- package/dist/client/assets/{dist-gBAr9yXF.js → dist-LCDxbTTI.js} +1 -1
- package/dist/client/assets/{dist-Dvca0LeR.js → dist-M3BFsjal.js} +1 -1
- package/dist/client/assets/{dist-BIIMtU9W.js → dist-PQZBepmy.js} +1 -1
- package/dist/client/assets/{index-DCSFD4VV.css → index-BJIfLtDs.css} +1 -1
- package/dist/client/assets/{index-CNbh1fL2.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.js +8 -1
- package/dist/server/command-handler.js +4 -0
- package/dist/server/store.d.ts +10 -0
- package/dist/server/store.js +38 -0
- package/dist/server/types.d.ts +33 -1
- package/package.json +2 -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));
|
|
@@ -29,7 +29,14 @@ export class SessionOrchestratorService {
|
|
|
29
29
|
if (!workspaceId) {
|
|
30
30
|
throw new Error("创建兄弟会话需要指定工作区(或提供有效的父会话 ID)。");
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
// THE PARENTAGE IS WRITTEN AT CREATION TIME, onto the conversation itself - not only into the
|
|
33
|
+
// in-memory ledger below. The ledger is task/status/progress metadata and dies with the process;
|
|
34
|
+
// the conversation's parentConversationId/origin is what the sidebar's session tree redraws
|
|
35
|
+
// from after a restart, so it has to be the persistent record.
|
|
36
|
+
const conversationId = await this.conversationService.createConversation(workspaceId, {
|
|
37
|
+
parentConversationId: options.parentConversationId,
|
|
38
|
+
origin: options.parentConversationId ? "orchestrator" : "user",
|
|
39
|
+
});
|
|
33
40
|
// Set title if requested or derive from task
|
|
34
41
|
if (options.title) {
|
|
35
42
|
await this.conversationService.renameConversation(conversationId, options.title);
|
|
@@ -200,6 +200,10 @@ export class CommandHandler {
|
|
|
200
200
|
await this.conversationService.selectConversation(command.conversationId);
|
|
201
201
|
return;
|
|
202
202
|
}
|
|
203
|
+
if (command.type === "conversation.collapse") {
|
|
204
|
+
await this.conversationService.setCollapsed(command.conversationId, command.collapsed);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
203
207
|
if (command.type === "conversation.archive") {
|
|
204
208
|
await this.conversationService.archiveConversation(command.conversationId);
|
|
205
209
|
return;
|
package/dist/server/store.d.ts
CHANGED
|
@@ -270,7 +270,17 @@ export declare class Store {
|
|
|
270
270
|
model?: ModelSelection | null;
|
|
271
271
|
thinkingLevel?: string | null;
|
|
272
272
|
modelNotice?: string;
|
|
273
|
+
parentConversationId?: string;
|
|
274
|
+
origin?: "user" | "orchestrator";
|
|
273
275
|
}): Promise<Conversation>;
|
|
276
|
+
/**
|
|
277
|
+
* Records (or clears) one conversation as collapsed in the sidebar's session tree. The list is a
|
|
278
|
+
* SET in array clothing - order means nothing, duplicates must not accumulate - and it follows the
|
|
279
|
+
* assign -> persist -> rollback pattern `setTerminalShell` established: the in-memory value is the
|
|
280
|
+
* previous value again the moment persistence fails, so a rejected write can never leave the
|
|
281
|
+
* screen and the disk disagreeing.
|
|
282
|
+
*/
|
|
283
|
+
setConversationCollapsed(conversationId: string, collapsed: boolean): Promise<void>;
|
|
274
284
|
/**
|
|
275
285
|
* Copies a conversation into a NEW one, under an id the caller has already minted.
|
|
276
286
|
*
|
package/dist/server/store.js
CHANGED
|
@@ -1086,11 +1086,49 @@ export class Store {
|
|
|
1086
1086
|
}
|
|
1087
1087
|
if (configuration.agentId)
|
|
1088
1088
|
conversation.agentId = configuration.agentId;
|
|
1089
|
+
// THE SESSION TREE'S EDGE, WRITTEN AT BIRTH. Both fields describe how this conversation CAME TO
|
|
1090
|
+
// BE, exactly like modelNotice above, so they are set once here and never rewritten: the tree
|
|
1091
|
+
// the sidebar draws after a restart is the tree that was true when each session was opened.
|
|
1092
|
+
// Written only when present, so a conversation created without them (the user's own "+") reads
|
|
1093
|
+
// as a top-level one on every code path, including every state.json from before this field.
|
|
1094
|
+
if (configuration.parentConversationId)
|
|
1095
|
+
conversation.parentConversationId = configuration.parentConversationId;
|
|
1096
|
+
if (configuration.origin)
|
|
1097
|
+
conversation.origin = configuration.origin;
|
|
1089
1098
|
}
|
|
1090
1099
|
this.state.conversations.unshift(conversation);
|
|
1091
1100
|
await this.persist();
|
|
1092
1101
|
return structuredClone(conversation);
|
|
1093
1102
|
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Records (or clears) one conversation as collapsed in the sidebar's session tree. The list is a
|
|
1105
|
+
* SET in array clothing - order means nothing, duplicates must not accumulate - and it follows the
|
|
1106
|
+
* assign -> persist -> rollback pattern `setTerminalShell` established: the in-memory value is the
|
|
1107
|
+
* previous value again the moment persistence fails, so a rejected write can never leave the
|
|
1108
|
+
* screen and the disk disagreeing.
|
|
1109
|
+
*/
|
|
1110
|
+
async setConversationCollapsed(conversationId, collapsed) {
|
|
1111
|
+
const previous = this.state.collapsedConversationIds;
|
|
1112
|
+
const next = new Set(previous ?? []);
|
|
1113
|
+
if (collapsed)
|
|
1114
|
+
next.add(conversationId);
|
|
1115
|
+
else
|
|
1116
|
+
next.delete(conversationId);
|
|
1117
|
+
if (next.size === 0)
|
|
1118
|
+
delete this.state.collapsedConversationIds;
|
|
1119
|
+
else
|
|
1120
|
+
this.state.collapsedConversationIds = [...next];
|
|
1121
|
+
try {
|
|
1122
|
+
await this.persist();
|
|
1123
|
+
}
|
|
1124
|
+
catch (error) {
|
|
1125
|
+
if (previous === undefined)
|
|
1126
|
+
delete this.state.collapsedConversationIds;
|
|
1127
|
+
else
|
|
1128
|
+
this.state.collapsedConversationIds = previous;
|
|
1129
|
+
throw error;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1094
1132
|
/**
|
|
1095
1133
|
* Copies a conversation into a NEW one, under an id the caller has already minted.
|
|
1096
1134
|
*
|
package/dist/server/types.d.ts
CHANGED
|
@@ -516,6 +516,21 @@ export type Conversation = {
|
|
|
516
516
|
* deleted agent degrades to "no role" instead of leaving a stale snapshot in state.json.
|
|
517
517
|
*/
|
|
518
518
|
agentId?: string;
|
|
519
|
+
/**
|
|
520
|
+
* The conversation that opened this one, when an orchestrator agent created it. Absent for a
|
|
521
|
+
* conversation the user started. This is the session tree's edge, and it PERSISTS: the sidebar
|
|
522
|
+
* draws the hierarchy from it after a restart, which a server-side in-memory ledger cannot do.
|
|
523
|
+
* Absence also reads correctly on every state.json written before this field existed - no parent
|
|
524
|
+
* recorded simply means "top level", which is what those conversations genuinely are.
|
|
525
|
+
*/
|
|
526
|
+
parentConversationId?: string;
|
|
527
|
+
/**
|
|
528
|
+
* Who opened this conversation. "user" for one the user started, "orchestrator" for one an agent
|
|
529
|
+
* created. Kept beside the parent id because a conversation can be an orchestrator's without
|
|
530
|
+
* having a parent (the top-level orchestrator), and because the context menu's "stop the session
|
|
531
|
+
* AND its queue" needs to know a queue exists without polling the orchestrator's ledger.
|
|
532
|
+
*/
|
|
533
|
+
origin?: "user" | "orchestrator";
|
|
519
534
|
};
|
|
520
535
|
/**
|
|
521
536
|
* One parsed agent definition, as read from an `agents/*.md` file. `prompt` is the file body and is the
|
|
@@ -863,7 +878,13 @@ export type AppState = {
|
|
|
863
878
|
todosByConversation?: Record<string, TodoItem[]>;
|
|
864
879
|
workflows?: WorkflowRun[];
|
|
865
880
|
scheduledTasks?: ScheduledTask[];
|
|
866
|
-
customAgentDirs?: string[];
|
|
881
|
+
customAgentDirs?: string[]; /**
|
|
882
|
+
* Which conversations the reader has collapsed in the sidebar's session tree, by conversation id.
|
|
883
|
+
* Server-side so the tree looks the same after a restart and on another machine - the sidebar is a
|
|
884
|
+
* view of the PRODUCT's state, not of one browser's session. Absent (older state.json) means
|
|
885
|
+
* "nothing collapsed", which is the correct reading of a preference that did not exist yet.
|
|
886
|
+
*/
|
|
887
|
+
collapsedConversationIds?: string[];
|
|
867
888
|
};
|
|
868
889
|
export type TodoItem = {
|
|
869
890
|
id?: string;
|
|
@@ -1158,6 +1179,17 @@ export type ClientCommandBody =
|
|
|
1158
1179
|
} | {
|
|
1159
1180
|
type: "conversation.unarchive";
|
|
1160
1181
|
conversationId: string;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Collapses or expands one conversation's whole subtree in the sidebar's session tree. The state
|
|
1185
|
+
* lives SERVER-SIDE (`AppState.collapsedConversationIds`), not in the browser: the tree must look
|
|
1186
|
+
* the same after a restart and on another machine, and a bare WS client must not be able to leave
|
|
1187
|
+
* the server's truth and the screen's truth disagreeing about a preference the product owns.
|
|
1188
|
+
*/
|
|
1189
|
+
| {
|
|
1190
|
+
type: "conversation.collapse";
|
|
1191
|
+
conversationId: string;
|
|
1192
|
+
collapsed: boolean;
|
|
1161
1193
|
} | {
|
|
1162
1194
|
type: "conversation.rename";
|
|
1163
1195
|
conversationId: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evandu/super-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.141",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -75,6 +75,7 @@
|
|
|
75
75
|
"scripts/image-upload.test.mjs",
|
|
76
76
|
"scripts/extension-ui-e2e.test.mjs",
|
|
77
77
|
"scripts/session-picker.test.mjs", "scripts/session-orchestrator.test.mjs", "scripts/session-orchestrator-tools.test.mjs",
|
|
78
|
+
"scripts/session-tree-collapse.test.mjs",
|
|
78
79
|
"scripts/workspace-cwd.test.mjs",
|
|
79
80
|
"scripts/subagents.test.mjs",
|
|
80
81
|
"scripts/subagent-noise.test.mjs",
|