@evandu/super-pi 0.1.105 → 0.1.107
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/index.ts +21 -7
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +8 -7
- package/dist/client/assets/index-Aa8KN3IB.css +1 -0
- package/dist/client/assets/index-B1K1OA_6.js +79 -0
- package/dist/client/index.html +2 -2
- package/dist/server/application/provider-service.d.ts +14 -0
- package/dist/server/application/provider-service.js +29 -0
- package/dist/server/application/run-service.d.ts +29 -0
- package/dist/server/application/run-service.js +109 -0
- package/dist/server/command-handler.js +10 -0
- package/dist/server/content-search.d.ts +32 -0
- package/dist/server/content-search.js +155 -0
- package/dist/server/file-changes.d.ts +119 -0
- package/dist/server/file-changes.js +278 -0
- package/dist/server/file-diff.d.ts +38 -0
- package/dist/server/file-diff.js +97 -0
- package/dist/server/file-index.d.ts +56 -0
- package/dist/server/file-index.js +150 -0
- package/dist/server/files.d.ts +8 -0
- package/dist/server/files.js +3 -1
- package/dist/server/git.d.ts +17 -0
- package/dist/server/git.js +39 -0
- package/dist/server/http-server.js +121 -1
- package/dist/server/index.js +17 -0
- package/dist/server/pi-session-events.d.ts +9 -0
- package/dist/server/pi-session-events.js +12 -0
- package/dist/server/runtime.d.ts +22 -0
- package/dist/server/runtime.js +16 -1
- package/dist/server/server-context.d.ts +12 -0
- package/dist/server/store.d.ts +1 -0
- package/dist/server/store.js +43 -1
- package/dist/server/types.d.ts +82 -0
- package/package.json +13 -1
- package/dist/client/assets/index-UiqTcZca.css +0 -1
- package/dist/client/assets/index-tBfBUY_8.js +0 -74
package/dist/server/git.d.ts
CHANGED
|
@@ -35,3 +35,20 @@ export declare function gitDiff(root: string, path: string, staged: boolean): Pr
|
|
|
35
35
|
diff: string;
|
|
36
36
|
kind: "text";
|
|
37
37
|
}>;
|
|
38
|
+
/**
|
|
39
|
+
* The content of a file AS IT WAS COMMITTED, or `null` when there is no such version.
|
|
40
|
+
*
|
|
41
|
+
* WHY THIS EXISTS ALONGSIDE `gitDiff`. `gitDiff` answers "what changed relative to HEAD" as a PATCH -
|
|
42
|
+
* which is a good thing to read and a bad thing to diff against: a patch is a set of hunks, and
|
|
43
|
+
* reconstructing the "before" text from it is a lossy operation that a review view should not depend on.
|
|
44
|
+
* The review view needs the two TEXTS, so that the difference between them is computed by one
|
|
45
|
+
* well-tested algorithm rather than by two different ones.
|
|
46
|
+
*
|
|
47
|
+
* `null` covers three different situations, and the caller must not confuse them: the repository has no
|
|
48
|
+
* commits yet (`unborn`), the file was never committed (it is new or untracked), or the file is not in
|
|
49
|
+
* this repository at all. The first two are the same answer for a review - "there is no committed
|
|
50
|
+
* version to compare against" - and the third is a refusal, which the caller reports.
|
|
51
|
+
*/
|
|
52
|
+
export declare function gitHeadContent(root: string, path: string): Promise<string | null>;
|
|
53
|
+
/** Whether the directory is a git repository at all. The review view asks before it offers a git baseline. */
|
|
54
|
+
export declare function isGitRepository(root: string): Promise<boolean>;
|
package/dist/server/git.js
CHANGED
|
@@ -28,3 +28,42 @@ export async function gitLog(root, limit) { const hasHead = await git(root, ["re
|
|
|
28
28
|
export async function gitDiff(root, path, staged) { const safe = resolve(root, path); if (!safe.startsWith(resolve(root) + sep) || !existsSync(safe))
|
|
29
29
|
throw Object.assign(new Error("文件路径无效"), { code: "path" }); const check = await git(root, ["ls-files", "--error-unmatch", "--", path]).then(() => true).catch(() => false); if (!check)
|
|
30
30
|
return { path, staged, diff: null, kind: "untracked" }; const out = await git(root, ["diff", ...(staged ? ["--cached"] : []), "--", path]); return { path, staged, diff: out.stdout, kind: "text" }; }
|
|
31
|
+
/**
|
|
32
|
+
* The content of a file AS IT WAS COMMITTED, or `null` when there is no such version.
|
|
33
|
+
*
|
|
34
|
+
* WHY THIS EXISTS ALONGSIDE `gitDiff`. `gitDiff` answers "what changed relative to HEAD" as a PATCH -
|
|
35
|
+
* which is a good thing to read and a bad thing to diff against: a patch is a set of hunks, and
|
|
36
|
+
* reconstructing the "before" text from it is a lossy operation that a review view should not depend on.
|
|
37
|
+
* The review view needs the two TEXTS, so that the difference between them is computed by one
|
|
38
|
+
* well-tested algorithm rather than by two different ones.
|
|
39
|
+
*
|
|
40
|
+
* `null` covers three different situations, and the caller must not confuse them: the repository has no
|
|
41
|
+
* commits yet (`unborn`), the file was never committed (it is new or untracked), or the file is not in
|
|
42
|
+
* this repository at all. The first two are the same answer for a review - "there is no committed
|
|
43
|
+
* version to compare against" - and the third is a refusal, which the caller reports.
|
|
44
|
+
*/
|
|
45
|
+
export async function gitHeadContent(root, path) {
|
|
46
|
+
const safe = resolve(root, path);
|
|
47
|
+
if (!safe.startsWith(resolve(root) + sep))
|
|
48
|
+
throw Object.assign(new Error("文件路径无效"), { code: "path" });
|
|
49
|
+
const hasHead = await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false);
|
|
50
|
+
if (!hasHead)
|
|
51
|
+
return null;
|
|
52
|
+
const tracked = await git(root, ["ls-files", "--error-unmatch", "--", path]).then(() => true).catch(() => false);
|
|
53
|
+
if (!tracked)
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
const out = await git(root, ["show", `HEAD:${path}`]);
|
|
57
|
+
return out.stdout;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// The file is tracked but `HEAD:path` does not resolve - a path with characters git quotes, or a
|
|
61
|
+
// repository in a state this server does not model. Treated as "no committed version", which is the
|
|
62
|
+
// honest answer rather than a crash.
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Whether the directory is a git repository at all. The review view asks before it offers a git baseline. */
|
|
67
|
+
export async function isGitRepository(root) {
|
|
68
|
+
return git(root, ["rev-parse", "--git-dir"]).then(() => true).catch(() => false);
|
|
69
|
+
}
|
|
@@ -8,9 +8,12 @@ import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES
|
|
|
8
8
|
import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
|
|
9
9
|
import { checkDiskVersion } from "./version.js";
|
|
10
10
|
import { gitDiff, gitLog, gitStatus } from "./git.js";
|
|
11
|
+
import { computeFileDiff } from "./file-diff.js";
|
|
11
12
|
import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
|
|
12
13
|
import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
|
|
13
14
|
import { packageUpdateArgs } from "./update.js";
|
|
15
|
+
import { FileIndex } from "./file-index.js";
|
|
16
|
+
import { MAX_CONTENT_DEPTH, MAX_CONTENT_FILE_BYTES, MAX_CONTENT_RESULTS, searchByContent } from "./content-search.js";
|
|
14
17
|
const MIME_TYPES = {
|
|
15
18
|
".css": "text/css; charset=utf-8",
|
|
16
19
|
".html": "text/html; charset=utf-8",
|
|
@@ -952,6 +955,118 @@ export function createHttpServer(options) {
|
|
|
952
955
|
}
|
|
953
956
|
return;
|
|
954
957
|
}
|
|
958
|
+
if (url.pathname === "/api/files/diff") {
|
|
959
|
+
const json = (status, body) => {
|
|
960
|
+
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
961
|
+
};
|
|
962
|
+
try {
|
|
963
|
+
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
964
|
+
const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
|
|
965
|
+
if (!workspaceId || !workspace) {
|
|
966
|
+
json(404, { error: `Workspace ${workspaceId ?? "(missing)"} not found`, code: "no_workspace" });
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const path = url.searchParams.get("path")?.trim() ?? "";
|
|
970
|
+
if (!path) {
|
|
971
|
+
json(400, { error: "需要 path 参数", code: "bad_request" });
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const requested = url.searchParams.get("base")?.trim();
|
|
975
|
+
const preferred = requested === "git" || requested === "agent" ? requested : "auto";
|
|
976
|
+
// The baseline is chosen by the server, but the client can name one - and when it names one, that
|
|
977
|
+
// is the one it gets. A silent fall-back would show the user a comparison they did not ask for.
|
|
978
|
+
const diff = await computeFileDiff({
|
|
979
|
+
root: workspace.path,
|
|
980
|
+
path,
|
|
981
|
+
preferred,
|
|
982
|
+
tracker: context.fileChanges,
|
|
983
|
+
workspaceId,
|
|
984
|
+
});
|
|
985
|
+
json(200, diff);
|
|
986
|
+
}
|
|
987
|
+
catch (error) {
|
|
988
|
+
const described = describeFilesError(error);
|
|
989
|
+
json(described.status, described.body);
|
|
990
|
+
}
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (url.pathname === "/api/files/search") {
|
|
994
|
+
const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body)); };
|
|
995
|
+
try {
|
|
996
|
+
if (request.method !== "GET") {
|
|
997
|
+
json(405, { error: "Content search requires GET", code: "method" });
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
1001
|
+
const query = url.searchParams.get("q")?.trim() ?? "";
|
|
1002
|
+
const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
|
|
1003
|
+
if (!workspaceId || !workspace) {
|
|
1004
|
+
json(404, { error: "workspace not found", code: "no_workspace" });
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
await resolveInsideRoot(workspace.path, "");
|
|
1008
|
+
if (query.length > 256 || /[\r\n]/.test(query)) {
|
|
1009
|
+
json(400, { error: "Search query must be one line, at most 256 characters", code: "bad_query" });
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (!query) {
|
|
1013
|
+
json(200, { mode: "content", query, hits: [], truncated: false, skipped: [], complete: true, reasons: [], maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
const indexes = context.fileIndexes ?? new Map();
|
|
1017
|
+
if (!context.fileIndexes)
|
|
1018
|
+
context.fileIndexes = indexes;
|
|
1019
|
+
let index = indexes.get(workspaceId);
|
|
1020
|
+
if (!index) {
|
|
1021
|
+
index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
|
|
1022
|
+
indexes.set(workspaceId, index);
|
|
1023
|
+
}
|
|
1024
|
+
index.start(workspace.path);
|
|
1025
|
+
const deadline = Date.now() + 4500;
|
|
1026
|
+
while (!index.builtAt && Date.now() < deadline)
|
|
1027
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1028
|
+
const snapshot = index.snapshot();
|
|
1029
|
+
const result = await searchByContent(workspace.path, [...index.entries.values()], query, snapshot);
|
|
1030
|
+
json(200, { mode: "content", query, ...result, maxResults: MAX_CONTENT_RESULTS, maxFileBytes: MAX_CONTENT_FILE_BYTES, maxDepth: MAX_CONTENT_DEPTH });
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
const described = describeFilesError(error);
|
|
1034
|
+
json(described.status, described.body);
|
|
1035
|
+
}
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (url.pathname === "/api/files/index") {
|
|
1039
|
+
const json = (status, body) => { response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body)); };
|
|
1040
|
+
try {
|
|
1041
|
+
if (request.method !== "GET") {
|
|
1042
|
+
json(405, { error: "File index requires GET", code: "method" });
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
const workspaceId = url.searchParams.get("workspaceId")?.trim();
|
|
1046
|
+
const workspace = store.snapshot().workspaces.find((item) => item.id === workspaceId);
|
|
1047
|
+
if (!workspaceId || !workspace) {
|
|
1048
|
+
json(404, { error: `Workspace ${workspaceId ?? "(missing)"} not found`, code: "no_workspace" });
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const indexes = context.fileIndexes ?? new Map();
|
|
1052
|
+
if (!context.fileIndexes)
|
|
1053
|
+
context.fileIndexes = indexes;
|
|
1054
|
+
let index = indexes.get(workspaceId);
|
|
1055
|
+
if (!index) {
|
|
1056
|
+
index = new FileIndex(workspaceId, (event) => broadcaster.broadcast(event));
|
|
1057
|
+
indexes.set(workspaceId, index);
|
|
1058
|
+
}
|
|
1059
|
+
index.start(workspace.path);
|
|
1060
|
+
const sinceRaw = url.searchParams.get("since");
|
|
1061
|
+
const since = sinceRaw === null ? undefined : Number(sinceRaw);
|
|
1062
|
+
json(200, index.snapshot(Number.isSafeInteger(since) && since >= 0 ? since : undefined));
|
|
1063
|
+
}
|
|
1064
|
+
catch (error) {
|
|
1065
|
+
const described = describeFilesError(error);
|
|
1066
|
+
json(described.status, described.body);
|
|
1067
|
+
}
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
955
1070
|
if (url.pathname === "/api/files" || url.pathname === "/api/file") {
|
|
956
1071
|
const json = (status, body) => {
|
|
957
1072
|
response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
@@ -983,10 +1098,14 @@ export function createHttpServer(options) {
|
|
|
983
1098
|
const rel = url.searchParams.get("path") ?? "";
|
|
984
1099
|
if (url.searchParams.get("meta") === "1") {
|
|
985
1100
|
const resolved = await resolveInsideRoot(root, rel);
|
|
986
|
-
|
|
1101
|
+
const meta = await fileMeta(resolved);
|
|
1102
|
+
const path = toRelative(root, resolved);
|
|
1103
|
+
context.fileIndexes?.get(workspaceId)?.recordFile(path, meta);
|
|
1104
|
+
json(200, { path, ...meta });
|
|
987
1105
|
return;
|
|
988
1106
|
}
|
|
989
1107
|
const file = await readWorkspaceFile(root, rel);
|
|
1108
|
+
context.fileIndexes?.get(workspaceId)?.recordFile(file.path, file.meta);
|
|
990
1109
|
json(200, { path: file.path, content: file.content, eol: file.eol, bom: file.bom, meta: file.meta, maxBytes: MAX_FILE_BYTES });
|
|
991
1110
|
return;
|
|
992
1111
|
}
|
|
@@ -1018,6 +1137,7 @@ export function createHttpServer(options) {
|
|
|
1018
1137
|
return;
|
|
1019
1138
|
}
|
|
1020
1139
|
const outcome = await writeWorkspaceFile(root, parsed.path, parsed.content, parsed.baseEtag);
|
|
1140
|
+
context.fileIndexes?.get(workspaceId)?.recordFile(outcome.path, outcome.meta);
|
|
1021
1141
|
json(200, outcome);
|
|
1022
1142
|
return;
|
|
1023
1143
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { CatalogService } from "./application/catalog-service.js";
|
|
|
19
19
|
import { ProviderService } from "./application/provider-service.js";
|
|
20
20
|
import { ProviderRegistry } from "./providers.js";
|
|
21
21
|
import { RunService } from "./application/run-service.js";
|
|
22
|
+
import { FileChangeTracker } from "./file-changes.js";
|
|
22
23
|
import { ConversationService } from "./application/conversation-service.js";
|
|
23
24
|
import { WorkspaceService } from "./application/workspace-service.js";
|
|
24
25
|
import { CommandHandler } from "./command-handler.js";
|
|
@@ -191,6 +192,18 @@ const providerRegistry = new ProviderRegistry(dataDir);
|
|
|
191
192
|
const providerService = new ProviderService(providerRegistry, runtime, broadcaster, catalogService, store);
|
|
192
193
|
const runService = new RunService(store, runtime, subagents, broadcaster, extensionUiService, initialAgentDirs, workflows);
|
|
193
194
|
runService.setMemory(memory);
|
|
195
|
+
/**
|
|
196
|
+
* WHAT THE AGENT CHANGED, and what each file looked like before.
|
|
197
|
+
*
|
|
198
|
+
* The tracker is created here rather than inside the service because it outlives one run: the changes a
|
|
199
|
+
* session produced are the session's record, and a tracker that died with the run would take the answer
|
|
200
|
+
* to "what did you just change" with it. It is handed to the run service as a setter, the same shape
|
|
201
|
+
* `memory` uses.
|
|
202
|
+
*/
|
|
203
|
+
const fileChanges = new FileChangeTracker();
|
|
204
|
+
runService.setFileChanges(fileChanges);
|
|
205
|
+
const fileIndexes = new Map();
|
|
206
|
+
runService.setFileIndexes(fileIndexes);
|
|
194
207
|
const conversationService = new ConversationService(store, runtime, subagents, broadcaster, catalogService, runService, extensionUiService, initialAgentDirs);
|
|
195
208
|
conversationService.setMemory(memory);
|
|
196
209
|
const workspaceService = new WorkspaceService(store, runtime, broadcaster, catalogService, runService, extensionUiService, uploadsDir, initialAgentDirs);
|
|
@@ -232,6 +245,8 @@ const serverContext = {
|
|
|
232
245
|
toolRegistry,
|
|
233
246
|
toolRunner,
|
|
234
247
|
toolLlm,
|
|
248
|
+
fileChanges,
|
|
249
|
+
fileIndexes,
|
|
235
250
|
terminalEnabled: () => terminalService.enabled,
|
|
236
251
|
terminalDisabledReason: () => terminalDisabledReason(),
|
|
237
252
|
};
|
|
@@ -268,6 +283,8 @@ async function shutdown(reason) {
|
|
|
268
283
|
// The capabilities die AFTER the sessions that carry them: a session that is still alive could
|
|
269
284
|
// otherwise use a credential the server has already forgotten about.
|
|
270
285
|
releaseAllCapabilities();
|
|
286
|
+
// Clean up background tasks so the flushed state never freezes running zombies on disk.
|
|
287
|
+
await store.clearBackgroundTasks();
|
|
271
288
|
await store.flush();
|
|
272
289
|
wsServer.close();
|
|
273
290
|
httpServer.closeAllConnections();
|
|
@@ -60,6 +60,15 @@ export type TurnItem =
|
|
|
60
60
|
/** The callbacks a turn supplies. The names match the RPC runtime's, so the two paths behave alike. */
|
|
61
61
|
export interface TurnEventHandlers {
|
|
62
62
|
onToolCall?(call: ToolCallNotice): void;
|
|
63
|
+
/**
|
|
64
|
+
* Called when a tool that WRITES A FILE is announced, with the path it is about to write.
|
|
65
|
+
*
|
|
66
|
+
* The same hook the RPC runtime has, and for the same reason: the announcement is the last moment
|
|
67
|
+
* the old content is still on the disk, so it is the only moment a diff baseline can be taken. The two
|
|
68
|
+
* paths (SDK session and spawned RPC) must agree on WHEN this fires, or the same edit would be
|
|
69
|
+
* baselined on one path and not on the other.
|
|
70
|
+
*/
|
|
71
|
+
onFileWrite?(path: string, toolName: string, toolCallId?: string): void;
|
|
63
72
|
onToolUpdate?(call: ToolCallNotice): void;
|
|
64
73
|
onToolPreparing?(call: ToolCallPreparing): void;
|
|
65
74
|
onToolEnd?(call: ToolCallNotice): void;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* from, not what they mean.
|
|
18
18
|
*/
|
|
19
19
|
import { describeToolCall, describeToolCallArgs, extractPartialResultText } from "./tool-calls.js";
|
|
20
|
+
import { toolTargetPath } from "./file-changes.js";
|
|
20
21
|
/** Keeps only real strings, so a malformed queue snapshot cannot put objects into a broadcast. */
|
|
21
22
|
const textList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : undefined;
|
|
22
23
|
/**
|
|
@@ -258,6 +259,17 @@ export function handleSessionEvent(event, queue, handlers, report, failure) {
|
|
|
258
259
|
catch (error) {
|
|
259
260
|
console.warn(`PiRpcRuntime: the tool call handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
260
261
|
}
|
|
262
|
+
// The file-writing call, announced before it runs - the only moment the old content is still on
|
|
263
|
+
// the disk. Fired and forgotten: the listener does its own I/O, off this handler's path.
|
|
264
|
+
const writePath = toolTargetPath(name, record.args);
|
|
265
|
+
if (writePath) {
|
|
266
|
+
try {
|
|
267
|
+
handlers.onFileWrite?.(writePath, name, id);
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
console.warn(`PiRpcRuntime: the file-write handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
261
273
|
return true;
|
|
262
274
|
}
|
|
263
275
|
case "tool_execution_update": {
|
package/dist/server/runtime.d.ts
CHANGED
|
@@ -115,6 +115,28 @@ export interface AnswerInput {
|
|
|
115
115
|
* on a card but by its working directory on a call block would be worse than having neither.
|
|
116
116
|
*/
|
|
117
117
|
onToolCall?(call: ToolCallNotice): void;
|
|
118
|
+
/**
|
|
119
|
+
* Called when a tool that WRITES A FILE is announced, with the path it is about to write.
|
|
120
|
+
*
|
|
121
|
+
* WHY A SEPARATE CALLBACK RATHER THAN A FIELD ON `onToolCall`. Two reasons, and the second is the
|
|
122
|
+
* load-bearing one.
|
|
123
|
+
*
|
|
124
|
+
* The first: this is a fact about the FILESYSTEM, not about the transcript. The transcript's row for
|
|
125
|
+
* an `edit` is a card; this is the server learning that a file on disk is about to change, and the
|
|
126
|
+
* listener for it has to act BEFORE the change - which is the only moment the old content is still on
|
|
127
|
+
* the disk. A card that arrives after the fact is too late to be a baseline.
|
|
128
|
+
*
|
|
129
|
+
* The second: the listener's job is to capture the file's previous content, which means reading the
|
|
130
|
+
* disk, and that must not happen on the path that draws a card. `onToolCall` is called synchronously
|
|
131
|
+
* in the drain loop, between yields, and a disk read there would put an I/O stall in the middle of
|
|
132
|
+
* the stream - the latency the streaming lane exists to avoid. So this callback is fired and FORGOTTEN:
|
|
133
|
+
* the listener does its work on its own time and the drain loop moves on.
|
|
134
|
+
*
|
|
135
|
+
* The path is the tool's own spelling, resolved against the workspace by the listener - the runtime
|
|
136
|
+
* does not know which workspace a call belongs to, and a path it cannot check is a path it does not
|
|
137
|
+
* vouch for.
|
|
138
|
+
*/
|
|
139
|
+
onFileWrite?(path: string, toolName: string, toolCallId?: string): void;
|
|
118
140
|
/**
|
|
119
141
|
* Called on `tool_execution_end` for the same `id`, carrying how the call ended.
|
|
120
142
|
*
|
package/dist/server/runtime.js
CHANGED
|
@@ -22,6 +22,7 @@ import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_END
|
|
|
22
22
|
import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
|
|
23
23
|
import { loadConversationImages } from "./upload.js";
|
|
24
24
|
import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
|
|
25
|
+
import { toolTargetPath } from "./file-changes.js";
|
|
25
26
|
const traceLatency = (conversationId, phase, startedAt, extra = "") => {
|
|
26
27
|
if (process.env.SUPER_PI_TRACE_LATENCY !== "1")
|
|
27
28
|
return;
|
|
@@ -730,6 +731,7 @@ export class PiRpcRuntime {
|
|
|
730
731
|
const queue = new TurnQueue();
|
|
731
732
|
const handlers = {
|
|
732
733
|
onToolCall: input.onToolCall,
|
|
734
|
+
onFileWrite: input.onFileWrite,
|
|
733
735
|
onToolUpdate: input.onToolUpdate,
|
|
734
736
|
onToolPreparing: input.onToolPreparing,
|
|
735
737
|
onToolEnd: input.onToolEnd,
|
|
@@ -1063,7 +1065,7 @@ export class PiRpcRuntime {
|
|
|
1063
1065
|
* Still used for the memory review, the knowledge reranker, and as the fallback when Pi's SDK is not
|
|
1064
1066
|
* installed. The main conversation path does NOT come here - see `answer`.
|
|
1065
1067
|
*/
|
|
1066
|
-
async *answerOneShot({ conversation, prompt, signal, cwd, host, onPiQueue, onSteerDelivered, onUserEntry, onSessionFile, onToolCall, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId, knowledgeRerank }) {
|
|
1068
|
+
async *answerOneShot({ conversation, prompt, signal, cwd, host, onPiQueue, onSteerDelivered, onUserEntry, onSessionFile, onToolCall, onFileWrite, onToolUpdate, onToolPreparing, onToolEnd, onThinking, onThinkingEnd, images, memoryReview, memorySourceConversationId, knowledgeRerank }) {
|
|
1067
1069
|
const latencyStartedAt = performance.now();
|
|
1068
1070
|
traceLatency(conversation.id, "answer.begin", latencyStartedAt);
|
|
1069
1071
|
await mkdir(this.sessionDir, { recursive: true });
|
|
@@ -1390,6 +1392,19 @@ export class PiRpcRuntime {
|
|
|
1390
1392
|
catch (error) {
|
|
1391
1393
|
console.warn(`PiRpcRuntime: the tool call handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
1392
1394
|
}
|
|
1395
|
+
// THE FILE-WRITING CALL, announced before it runs. This is the ONLY moment the old content
|
|
1396
|
+
// is still on the disk, so this is where a baseline has to be taken - and it is taken by a
|
|
1397
|
+
// listener that does its own I/O, off the drain loop's path, because a disk read between
|
|
1398
|
+
// yields would stall the stream.
|
|
1399
|
+
const writePath = toolTargetPath(name, record.args);
|
|
1400
|
+
if (writePath) {
|
|
1401
|
+
try {
|
|
1402
|
+
onFileWrite?.(writePath, name, record.toolCallId ?? undefined);
|
|
1403
|
+
}
|
|
1404
|
+
catch (error) {
|
|
1405
|
+
console.warn(`PiRpcRuntime: the file-write handler threw: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1393
1408
|
continue;
|
|
1394
1409
|
}
|
|
1395
1410
|
if (record.type === "tool_execution_end") {
|
|
@@ -55,6 +55,18 @@ export interface ServerContext {
|
|
|
55
55
|
execService: ExecService;
|
|
56
56
|
toolRegistry: ToolRegistry;
|
|
57
57
|
toolRunner: ToolRunner;
|
|
58
|
+
/**
|
|
59
|
+
* WHAT THE AGENT CHANGED, and what each file looked like before.
|
|
60
|
+
*
|
|
61
|
+
* The tracker is here because TWO layers need it: the run service writes to it (a write tool is
|
|
62
|
+
* announced, so the "before" is captured) and the HTTP layer reads from it (a diff request asks for
|
|
63
|
+
* the "before" of one file). Putting it in the context rather than inside either service is what
|
|
64
|
+
* keeps it ONE tracker - two trackers would be two answers to "what did the agent change", and only
|
|
65
|
+
* one of them would have the baselines.
|
|
66
|
+
*/
|
|
67
|
+
fileChanges?: import("./file-changes.js").FileChangeTracker;
|
|
68
|
+
/** Shared file indexes: the HTTP layer starts them lazily and write paths refresh known entries. */
|
|
69
|
+
fileIndexes?: Map<string, import("./file-index.js").FileIndex>;
|
|
58
70
|
/** Absent only if construction failed; a runtime with no Pi reports itself through `available`. */
|
|
59
71
|
toolLlm?: import("./tool-llm.js").ToolLlmService;
|
|
60
72
|
/**
|
package/dist/server/store.d.ts
CHANGED
|
@@ -647,6 +647,7 @@ export declare class Store {
|
|
|
647
647
|
setWorkflow(plan: WorkflowRun): Promise<WorkflowRun>;
|
|
648
648
|
setBackgroundTask(summary: AppState["backgroundTasks"][number]): Promise<void>;
|
|
649
649
|
removeBackgroundTask(id: string): Promise<void>;
|
|
650
|
+
clearBackgroundTasks(): Promise<void>;
|
|
650
651
|
/**
|
|
651
652
|
* Applies the fields a run's life can change, and nothing else.
|
|
652
653
|
*
|
package/dist/server/store.js
CHANGED
|
@@ -498,6 +498,39 @@ function reconcileTodos(state) {
|
|
|
498
498
|
const after = JSON.stringify({ todos: state.todos, todosByConversation: state.todosByConversation });
|
|
499
499
|
return { changed: before !== after };
|
|
500
500
|
}
|
|
501
|
+
/**
|
|
502
|
+
* Startup reconciliation: cleans up background tasks left over from previous server process.
|
|
503
|
+
*
|
|
504
|
+
* `backgroundTasks` is a LIFECYCLE collection: it tracks active in-flight executions (pty child
|
|
505
|
+
* processes or detached subagents) and is emptied as each task settles. When a server restarts,
|
|
506
|
+
* those processes are dead and cannot report their completion. Leaving them in `backgroundTasks`
|
|
507
|
+
* creates permanent "zombies" in the main sidebar that can never be dismissed.
|
|
508
|
+
*
|
|
509
|
+
* If a task originated from a tool call in a conversation, its tool call block is marked as failed
|
|
510
|
+
* so the chat transcript accurately reflects that the task was interrupted by server restart.
|
|
511
|
+
*/
|
|
512
|
+
function reconcileBackgroundTasks(state) {
|
|
513
|
+
const tasks = Array.isArray(state.backgroundTasks) ? state.backgroundTasks : [];
|
|
514
|
+
state.backgroundTasks = [];
|
|
515
|
+
if (!tasks.length)
|
|
516
|
+
return { orphaned: [], changed: false };
|
|
517
|
+
const orphaned = [];
|
|
518
|
+
for (const task of tasks) {
|
|
519
|
+
if (!task)
|
|
520
|
+
continue;
|
|
521
|
+
orphaned.push(task);
|
|
522
|
+
if (task.conversationId && task.originMessageId !== undefined && task.originCallRow !== undefined) {
|
|
523
|
+
const conv = state.conversations?.find((c) => c?.id === task.conversationId);
|
|
524
|
+
const msg = conv?.messages?.find((m) => m?.id === task.originMessageId);
|
|
525
|
+
const call = msg?.toolCalls?.[task.originCallRow];
|
|
526
|
+
if (call?.backgroundTask && call.backgroundTask.status === "running") {
|
|
527
|
+
call.backgroundTask.status = "failed";
|
|
528
|
+
call.backgroundTask.output = "任务随服务重启被终止";
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return { orphaned, changed: true };
|
|
533
|
+
}
|
|
501
534
|
export class Store {
|
|
502
535
|
repository;
|
|
503
536
|
state;
|
|
@@ -531,6 +564,7 @@ export class Store {
|
|
|
531
564
|
// and the other was not - a half-repaired state that still needs a second restart to finish.
|
|
532
565
|
const orphaned = reconcileSubagentRuns(state);
|
|
533
566
|
const orphanedWorkflows = reconcileWorkflows(state);
|
|
567
|
+
const orphanedTasks = reconcileBackgroundTasks(state);
|
|
534
568
|
const interrupted = reconcileConversations(state);
|
|
535
569
|
const requeued = reconcileWaitingMessages(state);
|
|
536
570
|
const todos = reconcileTodos(state);
|
|
@@ -538,11 +572,13 @@ export class Store {
|
|
|
538
572
|
// Persisted here, in `open`, rather than left for the first unrelated write: a reconciled record that
|
|
539
573
|
// is only rewritten by a later mutation would still read `running` to anything that opened the file
|
|
540
574
|
// in the meantime, and would be reconciled a SECOND time on the next restart.
|
|
541
|
-
if (defaulted || orphaned.length || orphanedWorkflows.length || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
|
|
575
|
+
if (defaulted || orphaned.length || orphanedWorkflows.length || orphanedTasks.changed || interrupted.length || requeued.recovered.length || requeued.changed || todos.changed) {
|
|
542
576
|
for (const run of orphaned)
|
|
543
577
|
console.warn(`Super Pi: sub-agent run ${run.id} was still marked running when the last server stopped - it is now failed, because no child process survived it.`);
|
|
544
578
|
for (const plan of orphanedWorkflows)
|
|
545
579
|
console.warn(`Super Pi: workflow ${plan.id} was still marked running when the last server stopped - it is now stopped, because no step can still be executing.`);
|
|
580
|
+
for (const task of orphanedTasks.orphaned)
|
|
581
|
+
console.warn(`Super Pi: background task ${task.id} (${task.title}) was left in flight when the last server stopped - it has been cleaned up.`);
|
|
546
582
|
// Announced for the same reason: a conversation that failed while nobody was watching is invisible
|
|
547
583
|
// otherwise, and the user who was watching a spinner when the server died deserves to learn that
|
|
548
584
|
// their run is over rather than guessing from the fact that it stopped moving.
|
|
@@ -1825,6 +1861,12 @@ export class Store {
|
|
|
1825
1861
|
this.state.backgroundTasks = this.state.backgroundTasks.filter((item) => item.id !== id);
|
|
1826
1862
|
await this.persistCollections("backgroundTasks");
|
|
1827
1863
|
}
|
|
1864
|
+
async clearBackgroundTasks() {
|
|
1865
|
+
if (!this.state.backgroundTasks.length)
|
|
1866
|
+
return;
|
|
1867
|
+
this.state.backgroundTasks = [];
|
|
1868
|
+
await this.persistCollections("backgroundTasks");
|
|
1869
|
+
}
|
|
1828
1870
|
/**
|
|
1829
1871
|
* Applies the fields a run's life can change, and nothing else.
|
|
1830
1872
|
*
|
package/dist/server/types.d.ts
CHANGED
|
@@ -930,10 +930,32 @@ export type ClientCommandBody =
|
|
|
930
930
|
type: "providers.remove";
|
|
931
931
|
providerId: string;
|
|
932
932
|
}
|
|
933
|
+
/**
|
|
934
|
+
* Removes ONE model from a provider, leaving the provider and its other models alone.
|
|
935
|
+
*
|
|
936
|
+
* Separate from `providers.remove` because a provider is a GROUP of models sharing one address
|
|
937
|
+
* and one key: taking one model out must not cost the others their credential.
|
|
938
|
+
*/
|
|
939
|
+
| {
|
|
940
|
+
type: "providers.removeModel";
|
|
941
|
+
providerId: string;
|
|
942
|
+
modelId: string;
|
|
943
|
+
}
|
|
933
944
|
/** Asks for the providers Super Pi manages. The answer carries no key. */
|
|
934
945
|
| {
|
|
935
946
|
type: "providers.fetch";
|
|
936
947
|
}
|
|
948
|
+
/**
|
|
949
|
+
* Stops and removes an active background task (spawned process or background subagent).
|
|
950
|
+
*
|
|
951
|
+
* If the task has a live pty child process, it is killed. If it originated from a tool call in
|
|
952
|
+
* a conversation, that call is marked as stopped/failed in the transcript so the user sees a
|
|
953
|
+
* settled outcome instead of a permanent spinner. The task is then removed from `backgroundTasks`.
|
|
954
|
+
*/
|
|
955
|
+
| {
|
|
956
|
+
type: "backgroundTask.stop";
|
|
957
|
+
taskId: string;
|
|
958
|
+
}
|
|
937
959
|
/**
|
|
938
960
|
* Sets the app-wide default model. `null` clears it back to "Pi's own default" - the same
|
|
939
961
|
* "absence, not a frozen copy" convention the workspace defaults use, so removing the default
|
|
@@ -1270,9 +1292,69 @@ export type MountedToolSummary = {
|
|
|
1270
1292
|
description?: string;
|
|
1271
1293
|
enabled: boolean;
|
|
1272
1294
|
};
|
|
1295
|
+
/**
|
|
1296
|
+
* A file the agent changed, as the Files panel learns about it.
|
|
1297
|
+
*
|
|
1298
|
+
* `path` is workspace-relative and `/`-separated, the same spelling every other file path uses on the
|
|
1299
|
+
* wire. `baseline` is the content BEFORE the agent's write, or `null` when there was none - which is
|
|
1300
|
+
* how a NEW file is told from an EDITED one, and the two are different things to a reader.
|
|
1301
|
+
*
|
|
1302
|
+
* `baselineUnknown` says the "before" could not be read, as opposed to not existing. A diff view that
|
|
1303
|
+
* shows an empty "before" for a file that existed is a lie, and this flag is what keeps it from being
|
|
1304
|
+
* one.
|
|
1305
|
+
*/
|
|
1306
|
+
export type ChangedFile = {
|
|
1307
|
+
path: string;
|
|
1308
|
+
/** The content before the agent wrote it, or `null` when the file did not exist then. */
|
|
1309
|
+
baseline: string | null;
|
|
1310
|
+
/** True when the baseline is missing because it could not be read, not because the file was new. */
|
|
1311
|
+
baselineUnknown?: boolean;
|
|
1312
|
+
/** The conversation the change belongs to, so a workspace's changes can be attributed to a session. */
|
|
1313
|
+
conversationId?: string;
|
|
1314
|
+
/** When it happened, so the panel can order a turn's changes the way they happened. */
|
|
1315
|
+
at: number;
|
|
1316
|
+
};
|
|
1273
1317
|
export type ServerEvent = {
|
|
1274
1318
|
type: "tools.updated";
|
|
1275
1319
|
tools: MountedToolSummary[];
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* THE AGENT CHANGED THESE FILES.
|
|
1323
|
+
*
|
|
1324
|
+
* WHY THIS EXISTS. The Files panel could browse the workspace but could not show a CHANGE, and a panel
|
|
1325
|
+
* that cannot show a change is a browser rather than a workbench: the user asked for the opposite - being
|
|
1326
|
+
* able to see the files is what makes the agent's edits reviewable. The server already knew which files
|
|
1327
|
+
* the agent wrote (the tool call names the path) and the knowledge went no further than one line on a
|
|
1328
|
+
* card, so a file the agent had rewritten looked exactly like a file it had never touched.
|
|
1329
|
+
*
|
|
1330
|
+
* A SNAPSHOT rather than a delta, like every other state-bearing event here: the client replaces its
|
|
1331
|
+
* list with this one, so a browser that missed an event rebuilds the list from the next one rather than
|
|
1332
|
+
* accumulating a partial truth.
|
|
1333
|
+
*
|
|
1334
|
+
* `dropped` says how many baselines were evicted because the per-workspace cap was reached. It is
|
|
1335
|
+
* reported rather than hidden, because a list that is shorter than the agent's actual work - with no
|
|
1336
|
+
* explanation - reads as "the agent only changed these" and is wrong.
|
|
1337
|
+
*/
|
|
1338
|
+
| {
|
|
1339
|
+
type: "files.changed";
|
|
1340
|
+
workspaceId: string;
|
|
1341
|
+
changes: ChangedFile[];
|
|
1342
|
+
dropped: number;
|
|
1343
|
+
} | {
|
|
1344
|
+
type: "files.index";
|
|
1345
|
+
workspaceId: string;
|
|
1346
|
+
version: number;
|
|
1347
|
+
changed: {
|
|
1348
|
+
path: string;
|
|
1349
|
+
kind: "file" | "directory";
|
|
1350
|
+
size: number | null;
|
|
1351
|
+
mtimeMs: number | null;
|
|
1352
|
+
etag: string | null;
|
|
1353
|
+
text: boolean | null;
|
|
1354
|
+
}[];
|
|
1355
|
+
removed: string[];
|
|
1356
|
+
complete: boolean;
|
|
1357
|
+
skipped: string[];
|
|
1276
1358
|
} | {
|
|
1277
1359
|
type: "operation.result";
|
|
1278
1360
|
requestId: 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.107",
|
|
4
4
|
"description": "Web control plane and companion for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -87,7 +87,13 @@
|
|
|
87
87
|
"scripts/tool-calls.test.mjs",
|
|
88
88
|
"scripts/files-api.test.mjs",
|
|
89
89
|
"scripts/files-missing-path.test.mjs",
|
|
90
|
+
"scripts/file-changes.test.mjs",
|
|
91
|
+
"scripts/file-index.test.mjs",
|
|
92
|
+
"scripts/file-content-search.test.mjs",
|
|
93
|
+
"scripts/file-diff-client.test.mjs",
|
|
90
94
|
"scripts/task-tool.test.mjs",
|
|
95
|
+
"scripts/tool-register-env.test.mjs",
|
|
96
|
+
"scripts/bg-task-zombie.test.mjs",
|
|
91
97
|
"scripts/fork-server.test.mjs",
|
|
92
98
|
"scripts/styles-no-dangling.test.mjs",
|
|
93
99
|
"scripts/theme-no-hardcoded-colours.test.mjs",
|
|
@@ -130,6 +136,9 @@
|
|
|
130
136
|
"scripts/session-context-menu-browser.test.mjs",
|
|
131
137
|
"scripts/tool-call-blocks-browser.test.mjs",
|
|
132
138
|
"scripts/files-tab-browser.test.mjs",
|
|
139
|
+
"scripts/file-quickopen-browser.test.mjs",
|
|
140
|
+
"scripts/file-content-search-browser.test.mjs",
|
|
141
|
+
"scripts/file-changes-browser.test.mjs",
|
|
133
142
|
"scripts/msg-tabs-browser.test.mjs",
|
|
134
143
|
"scripts/tabs-on-top-browser.test.mjs",
|
|
135
144
|
"scripts/subagent-source.test.mjs",
|
|
@@ -154,8 +163,11 @@
|
|
|
154
163
|
],
|
|
155
164
|
"dependencies": {
|
|
156
165
|
"@earendil-works/pi-coding-agent": "^0.86.1",
|
|
166
|
+
"@types/diff": "^5.2.3",
|
|
157
167
|
"@xterm/addon-fit": "^0.11.0",
|
|
158
168
|
"@xterm/xterm": "^6.0.0",
|
|
169
|
+
"diff": "^5.2.2",
|
|
170
|
+
"fuse.js": "^7.5.0",
|
|
159
171
|
"node-pty": "^1.2.0-beta.15",
|
|
160
172
|
"ws": "latest"
|
|
161
173
|
},
|