@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
@@ -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-tBfBUY_8.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-UiqTcZca.css">
11
+ <script type="module" crossorigin src="/assets/index-B1K1OA_6.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-Aa8KN3IB.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -51,6 +51,20 @@ export declare class ProviderService {
51
51
  set(input: unknown): Promise<PublicProvider[]>;
52
52
  /** Removes a provider Super Pi manages, and only one it manages. */
53
53
  remove(providerId: string): Promise<PublicProvider[]>;
54
+ /**
55
+ * Removes ONE model from a provider, leaving the provider and its other models alone.
56
+ *
57
+ * WHY THIS IS NOT JUST A DELETE-AND-RE-ADD. A provider is a group of models sharing one address,
58
+ * one API type and one key, so removing a model must not touch any of those - and the key in
59
+ * particular is not something to re-type. The provider's entry in Pi's file is rewritten with the
60
+ * remaining models, and its credential stays exactly where it was.
61
+ *
62
+ * THE LAST MODEL GOES WITH THE PROVIDER. A provider with no models offers nothing, and Pi's file
63
+ * would keep an entry that can never be selected - so the provider is removed, and with it the
64
+ * credential, because a credential for a provider that no longer exists is a secret left lying in
65
+ * a file for nothing.
66
+ */
67
+ removeModel(providerId: string, modelId: string): Promise<PublicProvider[]>;
54
68
  /**
55
69
  * What has to happen after the files are written, for the change to be REAL rather than pending.
56
70
  *
@@ -106,6 +106,35 @@ export class ProviderService {
106
106
  await this.afterChange();
107
107
  return publicProviders(next);
108
108
  }
109
+ /**
110
+ * Removes ONE model from a provider, leaving the provider and its other models alone.
111
+ *
112
+ * WHY THIS IS NOT JUST A DELETE-AND-RE-ADD. A provider is a group of models sharing one address,
113
+ * one API type and one key, so removing a model must not touch any of those - and the key in
114
+ * particular is not something to re-type. The provider's entry in Pi's file is rewritten with the
115
+ * remaining models, and its credential stays exactly where it was.
116
+ *
117
+ * THE LAST MODEL GOES WITH THE PROVIDER. A provider with no models offers nothing, and Pi's file
118
+ * would keep an entry that can never be selected - so the provider is removed, and with it the
119
+ * credential, because a credential for a provider that no longer exists is a secret left lying in
120
+ * a file for nothing.
121
+ */
122
+ async removeModel(providerId, modelId) {
123
+ const previous = await this.registry.load();
124
+ const existing = previous.find((provider) => provider.id === providerId);
125
+ if (!existing)
126
+ throw new Error(`Super Pi 没有管理供应商「${providerId}」`);
127
+ const remaining = existing.models.filter((model) => model.id !== modelId);
128
+ if (remaining.length === existing.models.length)
129
+ throw new Error(`供应商「${providerId}」里没有模型「${modelId}」`);
130
+ if (!remaining.length)
131
+ return this.remove(providerId);
132
+ const next = previous.map((provider) => (provider.id === providerId ? { ...provider, models: remaining } : provider));
133
+ await this.registry.applyToPi(previous, next);
134
+ await this.registry.save(next);
135
+ await this.afterChange();
136
+ return publicProviders(next);
137
+ }
109
138
  /**
110
139
  * What has to happen after the files are written, for the change to be REAL rather than pending.
111
140
  *
@@ -5,6 +5,7 @@ import type { Conversation, Message, MessageImage, PiQueueSnapshot, QuestionAnsw
5
5
  import type { EventBroadcaster } from "../server-context.js";
6
6
  import type { ExtensionUiService } from "./extension-ui-service.js";
7
7
  import type { WorkflowEngine } from "../workflow-engine.js";
8
+ import { FileChangeTracker } from "../file-changes.js";
8
9
  export interface SubagentDispatchRequestBody {
9
10
  conversationId?: string;
10
11
  subagent_type?: string;
@@ -62,6 +63,16 @@ export declare class RunService {
62
63
  private readonly workflows;
63
64
  private memory?;
64
65
  setMemory(memory: import("../active-memory.js").ActiveMemory): void;
66
+ /**
67
+ * WHAT THE AGENT CHANGED, and what each file looked like before.
68
+ *
69
+ * The tracker is a SETTER rather than a constructor argument, for the same reason `memory` is: it is
70
+ * optional, and a service that must be handed one cannot be built before the thing that builds it.
71
+ */
72
+ private fileChanges?;
73
+ private fileIndexes?;
74
+ setFileChanges(tracker: FileChangeTracker): void;
75
+ setFileIndexes(indexes: Map<string, import("../file-index.js").FileIndex>): void;
65
76
  reviewMemoryBoundary(conversationId: string): void;
66
77
  private readonly activeRuns;
67
78
  /**
@@ -122,6 +133,16 @@ export declare class RunService {
122
133
  * transcript - so a `role: "user"` is never impersonated.
123
134
  */
124
135
  enqueueExecResult(sessionId: string, exitCode: number, stdout: string, stderr: string): void;
136
+ /**
137
+ * Stops an active background task (spawned process or detached subagent) on user request.
138
+ *
139
+ * 1. Kills the underlying PTY child process if one exists in `execService`.
140
+ * 2. If the task originated from a tool call in a conversation transcript, marks the tool
141
+ * call as failed with a clear message and broadcasts `message.tools`.
142
+ * 3. Removes the task from `state.backgroundTasks` and broadcasts `state` so the sidebar
143
+ * updates immediately.
144
+ */
145
+ stopBackgroundTask(taskId: string): Promise<void>;
125
146
  /** Per-conversation command lane. All queue mutations and run claims use the same lane, so
126
147
  * two browser tabs cannot both observe an idle conversation and start duplicate turns. */
127
148
  private readonly conversationOps;
@@ -129,6 +150,14 @@ export declare class RunService {
129
150
  private readonly detached;
130
151
  constructor(store: Store, runtime: AgentRuntime, subagents: SubagentRunRecorder, broadcaster: EventBroadcaster, extensionUiService: ExtensionUiService, agentsDir: string | string[], workflows: WorkflowEngine);
131
152
  setAgentsDir(dir: string | string[]): void;
153
+ /**
154
+ * TELLS THE BROWSER WHICH FILES THE AGENT CHANGED.
155
+ *
156
+ * The whole list goes every time rather than just the newest file, because the event is a SNAPSHOT
157
+ * (like `state` and `subagent.event`): a browser that missed one rebuilds the list from the next
158
+ * one, instead of holding a list assembled from a partial sequence of deltas.
159
+ */
160
+ broadcastFileChanges(workspaceId: string): void;
132
161
  /**
133
162
  * RECORDS WHAT THE PERSON CHOSE, ON THE `question` CALL THAT ASKED.
134
163
  *
@@ -137,6 +137,16 @@ export class RunService {
137
137
  workflows;
138
138
  memory;
139
139
  setMemory(memory) { this.memory = memory; }
140
+ /**
141
+ * WHAT THE AGENT CHANGED, and what each file looked like before.
142
+ *
143
+ * The tracker is a SETTER rather than a constructor argument, for the same reason `memory` is: it is
144
+ * optional, and a service that must be handed one cannot be built before the thing that builds it.
145
+ */
146
+ fileChanges;
147
+ fileIndexes;
148
+ setFileChanges(tracker) { this.fileChanges = tracker; }
149
+ setFileIndexes(indexes) { this.fileIndexes = indexes; }
140
150
  reviewMemoryBoundary(conversationId) {
141
151
  const conversation = this.store.snapshot().conversations.find(c => c.id === conversationId);
142
152
  if (conversation)
@@ -300,6 +310,37 @@ export class RunService {
300
310
  }
301
311
  })());
302
312
  }
313
+ /**
314
+ * Stops an active background task (spawned process or detached subagent) on user request.
315
+ *
316
+ * 1. Kills the underlying PTY child process if one exists in `execService`.
317
+ * 2. If the task originated from a tool call in a conversation transcript, marks the tool
318
+ * call as failed with a clear message and broadcasts `message.tools`.
319
+ * 3. Removes the task from `state.backgroundTasks` and broadcasts `state` so the sidebar
320
+ * updates immediately.
321
+ */
322
+ async stopBackgroundTask(taskId) {
323
+ const tasks = this.store.snapshot().backgroundTasks ?? [];
324
+ const task = tasks.find((t) => t.id === taskId);
325
+ if (this.execService) {
326
+ try {
327
+ this.execService.kill(taskId);
328
+ }
329
+ catch { /* ignore if already exited */ }
330
+ }
331
+ if (task && task.originMessageId !== undefined && task.originCallRow !== undefined) {
332
+ const conv = this.store.snapshot().conversations.find((c) => c.id === task.conversationId);
333
+ const msg = conv?.messages.find((m) => m.id === task.originMessageId);
334
+ if (msg?.toolCalls?.[task.originCallRow]?.backgroundTask) {
335
+ const toolCalls = await this.store.updateToolCall(task.conversationId, task.originMessageId, task.originCallRow, {
336
+ backgroundTask: { id: taskId, status: "failed", output: "已由用户手动终止" },
337
+ });
338
+ this.broadcaster.broadcast({ type: "message.tools", conversationId: task.conversationId, messageId: task.originMessageId, toolCalls });
339
+ }
340
+ }
341
+ await this.store.removeBackgroundTask(taskId);
342
+ this.broadcaster.broadcastState();
343
+ }
303
344
  /** Per-conversation command lane. All queue mutations and run claims use the same lane, so
304
345
  * two browser tabs cannot both observe an idle conversation and start duplicate turns. */
305
346
  conversationOps = new Map();
@@ -317,6 +358,31 @@ export class RunService {
317
358
  setAgentsDir(dir) {
318
359
  this.agentsDir = dir;
319
360
  }
361
+ /**
362
+ * TELLS THE BROWSER WHICH FILES THE AGENT CHANGED.
363
+ *
364
+ * The whole list goes every time rather than just the newest file, because the event is a SNAPSHOT
365
+ * (like `state` and `subagent.event`): a browser that missed one rebuilds the list from the next
366
+ * one, instead of holding a list assembled from a partial sequence of deltas.
367
+ */
368
+ broadcastFileChanges(workspaceId) {
369
+ const tracker = this.fileChanges;
370
+ if (!tracker)
371
+ return;
372
+ const { changes, dropped } = tracker.list(workspaceId);
373
+ this.broadcaster.broadcast({
374
+ type: "files.changed",
375
+ workspaceId,
376
+ dropped,
377
+ changes: changes.map((change) => ({
378
+ path: change.path,
379
+ baseline: change.before,
380
+ ...(change.beforeUnknown ? { baselineUnknown: true } : {}),
381
+ ...(change.conversationId ? { conversationId: change.conversationId } : {}),
382
+ at: change.at,
383
+ })),
384
+ });
385
+ }
320
386
  /**
321
387
  * RECORDS WHAT THE PERSON CHOSE, ON THE `question` CALL THAT ASKED.
322
388
  *
@@ -867,6 +933,36 @@ export class RunService {
867
933
  }));
868
934
  preparingRows.set(call.contentIndex, { row, name: call.name });
869
935
  };
936
+ /**
937
+ * THE AGENT IS ABOUT TO WRITE A FILE.
938
+ *
939
+ * The baseline is captured HERE, at the announcement, because this is the last moment the old content
940
+ * is still on the disk. By the time the tool has finished, the file holds the new content and the
941
+ * "before" is gone - which is why a diff cannot be reconstructed afterwards from the file itself.
942
+ *
943
+ * The read inside `record` is SYNCHRONOUS on purpose (see `file-changes.ts` for the race it
944
+ * closes), and it is bounded by a size cap, so it costs a fraction of a millisecond for an
945
+ * ordinary source file.
946
+ */
947
+ /**
948
+ * THE PATHS THE AGENT IS WRITING, keyed by tool call id.
949
+ *
950
+ * The key is what lets the tool's END confirm the baseline of the right file. The alternative - the
951
+ * tool card's one-line summary - is a DISPLAY string: whitespace-collapsed and length-capped, so
952
+ * it is not a path the server can read a file by, and a path with a run of spaces in it would come
953
+ * back as a path with one space.
954
+ */
955
+ const fileWritePaths = new Map();
956
+ const onFileWrite = (path, _toolName, toolCallId) => {
957
+ const tracker = this.fileChanges;
958
+ if (!tracker)
959
+ return;
960
+ tracker.record(workspace.id, path, { root: workspace.path, conversationId: conversation.id });
961
+ void this.fileIndexes?.get(workspace.id)?.refresh(path);
962
+ if (toolCallId)
963
+ fileWritePaths.set(toolCallId, path);
964
+ this.broadcastFileChanges(workspace.id);
965
+ };
870
966
  const onToolCall = (call) => {
871
967
  const at = spoken;
872
968
  // THE PLACEHOLDER THAT BELONGS TO THIS CALL, IF ONE IS ON SCREEN.
@@ -920,6 +1016,18 @@ export class RunService {
920
1016
  enqueueTools("a tool call update", () => this.store.updateToolCall(conversation.id, assistant.id, index, { ...(call.args ? { args: call.args } : {}), ...(call.live !== undefined ? { live: call.live } : {}) }));
921
1017
  };
922
1018
  const onToolEnd = (call) => {
1019
+ // THE WRITE IS FINISHED, so the baseline can be CHECKED. The tracker recorded the file's etag when
1020
+ // the write was announced; if the file's etag now differs, the baseline really is the content
1021
+ // from before the write. If it does not, the baseline was taken too late to be shown, and the
1022
+ // tracker says so rather than letting the diff view claim two identical versions are the same.
1023
+ const writtenPath = call.id ? fileWritePaths.get(call.id) : undefined;
1024
+ if (writtenPath) {
1025
+ this.fileChanges?.confirm(workspace.id, writtenPath, workspace.path);
1026
+ void this.fileIndexes?.get(workspace.id)?.refresh(writtenPath);
1027
+ if (call.id)
1028
+ fileWritePaths.delete(call.id);
1029
+ this.broadcastFileChanges(workspace.id);
1030
+ }
923
1031
  const index = call.id ? rowByToolCallId.get(call.id) : undefined;
924
1032
  if (index === undefined)
925
1033
  return;
@@ -992,6 +1100,7 @@ export class RunService {
992
1100
  onUserEntry: (entryId) => this.store.setMessagePiEntryId(conversation.id, userMessageId, entryId),
993
1101
  onSessionFile: (path) => this.store.setConversationPiSessionFile(conversation.id, path),
994
1102
  onToolCall,
1103
+ onFileWrite,
995
1104
  onToolPreparing,
996
1105
  onToolUpdate,
997
1106
  onToolEnd,
@@ -95,6 +95,12 @@ export class CommandHandler {
95
95
  await this.providerService.remove(command.providerId);
96
96
  return;
97
97
  }
98
+ if (command.type === "providers.removeModel") {
99
+ if (!this.providerService)
100
+ throw new Error("供应商功能不可用");
101
+ await this.providerService.removeModel(command.providerId, command.modelId);
102
+ return;
103
+ }
98
104
  if (command.type === "providers.fetch") {
99
105
  if (!this.providerService)
100
106
  throw new Error("供应商功能不可用");
@@ -102,6 +108,10 @@ export class CommandHandler {
102
108
  sendToSocket({ type: "providers.list", providers: await this.providerService.list(), apis: [...KNOWN_APIS] });
103
109
  return;
104
110
  }
111
+ if (command.type === "backgroundTask.stop") {
112
+ await this.runService.stopBackgroundTask(command.taskId);
113
+ return;
114
+ }
105
115
  if (command.type === "workspace.create") {
106
116
  return await this.workspaceService.createWorkspace(command.name, command.path);
107
117
  }
@@ -0,0 +1,32 @@
1
+ import type { FileIndexSnapshot } from "./file-index.js";
2
+ export declare const MAX_CONTENT_RESULTS = 500;
3
+ export declare const MAX_CONTENT_FILE_BYTES: number;
4
+ export declare const MAX_CONTENT_DEPTH = 12;
5
+ export declare const MAX_CONTENT_MS = 10000;
6
+ export type ContentSearchCandidate = {
7
+ path: string;
8
+ kind: "file" | "directory";
9
+ text: boolean | null;
10
+ size: number | null;
11
+ };
12
+ export type ContentSearchHit = {
13
+ path: string;
14
+ name: string;
15
+ line: number;
16
+ text: string;
17
+ };
18
+ export type ContentSearchResult = {
19
+ hits: ContentSearchHit[];
20
+ truncated: boolean;
21
+ skipped: string[];
22
+ complete: boolean;
23
+ reasons: string[];
24
+ };
25
+ /**
26
+ * SDK probe: createGrepTool(root).execute(id, { pattern, path, literal: true }) works without a
27
+ * session or running turn (Pi 0.86). Use it for matching. Directory-wide SDK grep would walk a second
28
+ * tree and cannot accept FileIndex candidates or our per-file containment/size/depth policy.
29
+ * Instead give it one verified indexed file at a time; validate again before publishing any hit.
30
+ * The SDK owns ripgrep and its match/byte/line truncation; we expose those limits, never hide them.
31
+ */
32
+ export declare function searchByContent(root: string, candidates: readonly ContentSearchCandidate[], query: string, indexStatus?: Pick<FileIndexSnapshot, "complete" | "skipped">): Promise<ContentSearchResult>;
@@ -0,0 +1,155 @@
1
+ import { open } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import { createGrepTool } from "@earendil-works/pi-coding-agent";
4
+ import { isTextName, looksBinary, resolveInsideRoot } from "./files.js";
5
+ export const MAX_CONTENT_RESULTS = 500;
6
+ export const MAX_CONTENT_FILE_BYTES = 512 * 1024;
7
+ export const MAX_CONTENT_DEPTH = 12;
8
+ export const MAX_CONTENT_MS = 10000;
9
+ /**
10
+ * SDK probe: createGrepTool(root).execute(id, { pattern, path, literal: true }) works without a
11
+ * session or running turn (Pi 0.86). Use it for matching. Directory-wide SDK grep would walk a second
12
+ * tree and cannot accept FileIndex candidates or our per-file containment/size/depth policy.
13
+ * Instead give it one verified indexed file at a time; validate again before publishing any hit.
14
+ * The SDK owns ripgrep and its match/byte/line truncation; we expose those limits, never hide them.
15
+ */
16
+ export async function searchByContent(root, candidates, query, indexStatus = { complete: true, skipped: [] }) {
17
+ await resolveInsideRoot(root, "");
18
+ const hits = [];
19
+ const skipped = new Set(indexStatus.skipped);
20
+ const reasons = new Set(indexStatus.complete ? [] : ["index"]);
21
+ const needle = query.trim();
22
+ if (!needle)
23
+ return { hits, truncated: false, skipped: [...skipped], complete: indexStatus.complete, reasons: [...reasons] };
24
+ const tool = createGrepTool(root);
25
+ const controller = new AbortController();
26
+ const timer = setTimeout(() => controller.abort(), MAX_CONTENT_MS);
27
+ try {
28
+ for (const candidate of candidates) {
29
+ if (candidate.kind !== "file")
30
+ continue;
31
+ if (controller.signal.aborted) {
32
+ reasons.add("time");
33
+ break;
34
+ }
35
+ // Validate even an excluded candidate: metadata and cached paths are not a security boundary.
36
+ let absolute;
37
+ try {
38
+ absolute = await resolveInsideRoot(root, candidate.path);
39
+ }
40
+ catch (error) {
41
+ const code = error.code;
42
+ if (code === "outside_workspace" || code === "bad_path" || code === "workspace_missing")
43
+ throw error;
44
+ skipped.add(candidate.path);
45
+ reasons.add("unreadable");
46
+ continue;
47
+ }
48
+ if (candidate.path.replace(/\\/g, "/").split("/").length - 1 > MAX_CONTENT_DEPTH) {
49
+ skipped.add(candidate.path);
50
+ reasons.add("depth");
51
+ continue;
52
+ }
53
+ if (candidate.text === false || !isTextName(candidate.path)) {
54
+ skipped.add(candidate.path);
55
+ continue;
56
+ }
57
+ let bytes;
58
+ try {
59
+ // Read at most cap+1 bytes even if a file grows after stat; never allocate its untrusted size.
60
+ const handle = await open(absolute, "r");
61
+ try {
62
+ const info = await handle.stat();
63
+ if (!info.isFile()) {
64
+ skipped.add(candidate.path);
65
+ reasons.add("unreadable");
66
+ continue;
67
+ }
68
+ if (info.size > MAX_CONTENT_FILE_BYTES) {
69
+ skipped.add(candidate.path);
70
+ reasons.add("size");
71
+ continue;
72
+ }
73
+ const buffer = Buffer.alloc(MAX_CONTENT_FILE_BYTES + 1);
74
+ let length = 0;
75
+ while (length < buffer.length) {
76
+ const read = await handle.read(buffer, length, buffer.length - length, null);
77
+ if (!read.bytesRead)
78
+ break;
79
+ length += read.bytesRead;
80
+ }
81
+ if (length > MAX_CONTENT_FILE_BYTES) {
82
+ skipped.add(candidate.path);
83
+ reasons.add("size");
84
+ continue;
85
+ }
86
+ bytes = buffer.subarray(0, length);
87
+ }
88
+ finally {
89
+ await handle.close();
90
+ }
91
+ }
92
+ catch {
93
+ skipped.add(candidate.path);
94
+ reasons.add("unreadable");
95
+ continue;
96
+ }
97
+ if (looksBinary(bytes) || bytes.includes(0)) {
98
+ skipped.add(candidate.path);
99
+ continue;
100
+ }
101
+ try {
102
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
103
+ }
104
+ catch {
105
+ skipped.add(candidate.path);
106
+ continue;
107
+ }
108
+ let result;
109
+ try {
110
+ result = await tool.execute("content-search", {
111
+ pattern: needle, path: absolute, literal: true, limit: MAX_CONTENT_RESULTS - hits.length,
112
+ }, controller.signal);
113
+ }
114
+ catch (error) {
115
+ if (controller.signal.aborted) {
116
+ reasons.add("time");
117
+ break;
118
+ }
119
+ // SDK/ripgrep failures are request failures, never a successful empty search.
120
+ throw error;
121
+ }
122
+ const prefix = basename(absolute) + ":";
123
+ const rows = [];
124
+ for (const block of result.content) {
125
+ if (block.type !== "text")
126
+ continue;
127
+ for (const row of block.text.split("\n")) {
128
+ if (!row.startsWith(prefix))
129
+ continue;
130
+ const match = /^(\d+): (.*)$/.exec(row.slice(prefix.length));
131
+ if (!match)
132
+ continue;
133
+ rows.push({ path: candidate.path, name: basename(absolute), line: Number(match[1]), text: match[2] });
134
+ }
135
+ }
136
+ if (rows.length) {
137
+ await resolveInsideRoot(root, candidate.path);
138
+ hits.push(...rows.slice(0, MAX_CONTENT_RESULTS - hits.length));
139
+ }
140
+ const details = result.details;
141
+ if (details?.truncation?.truncated)
142
+ reasons.add("output");
143
+ if (details?.linesTruncated)
144
+ reasons.add("line");
145
+ if (details?.matchLimitReached || hits.length >= MAX_CONTENT_RESULTS) {
146
+ reasons.add("results");
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ finally {
152
+ clearTimeout(timer);
153
+ }
154
+ return { hits, truncated: reasons.size > 0, skipped: [...skipped], complete: indexStatus.complete, reasons: [...reasons] };
155
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The tools whose arguments name a file the agent is about to WRITE.
3
+ *
4
+ * `edit` and `write` are Pi's two file-writing tools (`node_modules/@earendil-works/pi-coding-agent/
5
+ * dist/core/tools/`), and `notebookedit` is the third in the family that edits a notebook. The list is
6
+ * deliberately narrow: a `read` names a file too, and a `read` changes nothing, so treating it as a
7
+ * change would mark a file the agent merely LOOKED at as one it edited - and a marker that lies is worse
8
+ * than no marker, because the user's trust in every other marker goes with it.
9
+ *
10
+ * The names are matched case-insensitively and after trimming, because the name comes off a wire and a
11
+ * tool named "Edit" is the same tool.
12
+ */
13
+ export declare const FILE_WRITING_TOOLS: Set<string>;
14
+ /**
15
+ * The path an argument names, or `undefined` when the arguments name none.
16
+ *
17
+ * NOT a display string. `describeToolCall` collapses whitespace and caps the length, which is right for
18
+ * a card and wrong for a path that is about to be read off the disk - a path with a run of spaces in it
19
+ * would be reported as a path with one space, and the file would not be found. This returns the value
20
+ * as the tool spelled it, so the caller can resolve it properly.
21
+ *
22
+ * The value is NOT validated here. The caller resolves it against the workspace root and refuses
23
+ * anything that leaves it - the containment rule lives in `files.ts` and has exactly one owner, and this
24
+ * module does not get a second, weaker copy of it.
25
+ */
26
+ export declare function toolTargetPath(name: string, args: unknown): string | undefined;
27
+ /**
28
+ * One recorded change: a file the agent wrote, and what it looked like before.
29
+ *
30
+ * `before` is the content as it was on disk when the write was announced, or `null` when the file did
31
+ * not exist then - which is how a NEW file is told apart from an EDITED one, and the two are different
32
+ * things to a reader. `null` is also the honest value for a file this server never saw the contents of
33
+ * (too large, binary, unreadable), and `beforeUnknown` says which of those it was.
34
+ */
35
+ export type RecordedChange = {
36
+ /** Workspace-relative, `/`-separated - the same spelling every other file path uses on the wire. */
37
+ path: string;
38
+ /** The content before the agent's write, or `null` when there was none (a new file, or unreadable). */
39
+ before: string | null;
40
+ /** True when `before` is `null` because the content could not be read, rather than because the file
41
+ * did not exist. The difference matters to the diff view: "the file is new" and "we could not read
42
+ * the old version" are different facts and the reader is told which one happened. */
43
+ beforeUnknown: boolean;
44
+ /**
45
+ * THE ETAG OF THE FILE AS IT WAS WHEN THE BASELINE WAS TAKEN.
46
+ *
47
+ * This is what makes the baseline TRUSTWORTHY rather than merely plausible. See `confirm` below.
48
+ */
49
+ etag?: string;
50
+ /** When the change was recorded, so the panel can order a turn's changes the way they happened. */
51
+ at: number;
52
+ /** The conversation the change belongs to, so a workspace's changes can be attributed to a session. */
53
+ conversationId?: string;
54
+ };
55
+ export declare class FileChangeTracker {
56
+ #private;
57
+ /**
58
+ * Records that the agent is about to write `path`, capturing the current content as the baseline.
59
+ *
60
+ * Called when the tool call is ANNOUNCED, not when it ends. The announcement is the last moment the
61
+ * old bytes are still on the disk: by the time the tool has finished, the file already holds the new
62
+ * content and the baseline is gone forever. This is the whole reason the tracker reads the disk at
63
+ * all.
64
+ *
65
+ * A file that is already tracked is NOT re-baselined. Re-baselining would set the "before" to the
66
+ * agent's own intermediate state, and the diff would then show only the LAST edit rather than
67
+ * everything the agent did to the file in this turn - which is the thing a reviewer needs to see.
68
+ *
69
+ * THE READ IS SYNCHRONOUS, and that is the whole reason this feature works.
70
+ *
71
+ * Pi announces a tool call and then RUNS it, and it does not wait for this server to acknowledge the
72
+ * announcement. So the tool may well have written the file by the time this server gets around to
73
+ * reading it - and an ASYNCHRONOUS read loses that race almost every time, because the read is queued
74
+ * behind the very write it is trying to get ahead of. The first version of this was asynchronous, and
75
+ * the symptom was a diff that said "+0 / -0": the baseline it captured was the file's NEW content,
76
+ * because by the time the read happened the agent had already written.
77
+ *
78
+ * A synchronous read cannot lose that race in the same way - it happens in the same tick the
79
+ * announcement was parsed, before anything else can run - and the cost is bounded by the size cap
80
+ * below. A blocked event loop for the length of one small file read, once per agent write, is a far
81
+ * better trade than a diff that shows nothing.
82
+ */
83
+ record(workspaceId: string, path: string, options: {
84
+ conversationId?: string;
85
+ root: string;
86
+ }): void;
87
+ /**
88
+ * CONFIRMS A BASELINE, once the agent's write has finished.
89
+ *
90
+ * The synchronous read makes the baseline correct in practice, but "in practice" is not a guarantee:
91
+ * if the tool somehow ran before the announcement was processed, the baseline would be the file's NEW
92
+ * content and the diff would show nothing at all - a diff that says "no change" about a file the
93
+ * agent just rewrote, which is the worst possible lie this feature could tell.
94
+ *
95
+ * So the baseline is CHECKED rather than trusted. The file's etag after the write must DIFFER from
96
+ * the etag the baseline was taken at - and if it does not, the baseline was taken after the write and
97
+ * is worthless. The change is then marked as having no usable "before", and the diff view says so
98
+ * instead of showing an empty diff.
99
+ *
100
+ * The one case this cannot tell apart is a write that changed nothing (the agent wrote identical
101
+ * bytes). There the baseline and the file agree, and the diff is empty - which is CORRECT, because
102
+ * nothing changed. The reader is told the before-version is unconfirmed, which is true in both cases.
103
+ */
104
+ confirm(workspaceId: string, path: string, root: string): void;
105
+ /**
106
+ * The changes recorded for a workspace, newest first.
107
+ *
108
+ * `dropped` says how many baselines were evicted, so the panel can say so rather than quietly showing
109
+ * a shorter list than the agent actually produced.
110
+ */
111
+ list(workspaceId: string): {
112
+ changes: RecordedChange[];
113
+ dropped: number;
114
+ };
115
+ /** The recorded change for one path, or `undefined` when there is none. */
116
+ get(workspaceId: string, path: string): RecordedChange | undefined;
117
+ /** Forgets a workspace's changes. Used when the workspace is deleted. */
118
+ forget(workspaceId: string): void;
119
+ }