@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.
Files changed (36) hide show
  1. package/agents/super-pi-task/index.ts +21 -7
  2. package/dist/.super-pi-check-inputs.json +2 -2
  3. package/dist/client/.super-pi-client-sources.json +8 -7
  4. package/dist/client/assets/index-Aa8KN3IB.css +1 -0
  5. package/dist/client/assets/index-B1K1OA_6.js +79 -0
  6. package/dist/client/index.html +2 -2
  7. package/dist/server/application/provider-service.d.ts +14 -0
  8. package/dist/server/application/provider-service.js +29 -0
  9. package/dist/server/application/run-service.d.ts +29 -0
  10. package/dist/server/application/run-service.js +109 -0
  11. package/dist/server/command-handler.js +10 -0
  12. package/dist/server/content-search.d.ts +32 -0
  13. package/dist/server/content-search.js +155 -0
  14. package/dist/server/file-changes.d.ts +119 -0
  15. package/dist/server/file-changes.js +278 -0
  16. package/dist/server/file-diff.d.ts +38 -0
  17. package/dist/server/file-diff.js +97 -0
  18. package/dist/server/file-index.d.ts +56 -0
  19. package/dist/server/file-index.js +150 -0
  20. package/dist/server/files.d.ts +8 -0
  21. package/dist/server/files.js +3 -1
  22. package/dist/server/git.d.ts +17 -0
  23. package/dist/server/git.js +39 -0
  24. package/dist/server/http-server.js +121 -1
  25. package/dist/server/index.js +17 -0
  26. package/dist/server/pi-session-events.d.ts +9 -0
  27. package/dist/server/pi-session-events.js +12 -0
  28. package/dist/server/runtime.d.ts +22 -0
  29. package/dist/server/runtime.js +16 -1
  30. package/dist/server/server-context.d.ts +12 -0
  31. package/dist/server/store.d.ts +1 -0
  32. package/dist/server/store.js +43 -1
  33. package/dist/server/types.d.ts +82 -0
  34. package/package.json +13 -1
  35. package/dist/client/assets/index-UiqTcZca.css +0 -1
  36. package/dist/client/assets/index-tBfBUY_8.js +0 -74
@@ -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>;
@@ -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
- json(200, { path: toRelative(root, resolved), ...(await fileMeta(resolved)) });
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
  }
@@ -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": {
@@ -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
  *
@@ -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
  /**
@@ -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
  *
@@ -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
  *
@@ -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.105",
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
  },