@evandu/super-pi 0.1.105 → 0.1.106

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 (33) hide show
  1. package/dist/.super-pi-check-inputs.json +2 -2
  2. package/dist/client/.super-pi-client-sources.json +8 -7
  3. package/dist/client/assets/index-DBR_t9lF.js +79 -0
  4. package/dist/client/assets/index-mo5Dvh_v.css +1 -0
  5. package/dist/client/index.html +2 -2
  6. package/dist/server/application/provider-service.d.ts +14 -0
  7. package/dist/server/application/provider-service.js +29 -0
  8. package/dist/server/application/run-service.d.ts +19 -0
  9. package/dist/server/application/run-service.js +78 -0
  10. package/dist/server/command-handler.js +6 -0
  11. package/dist/server/content-search.d.ts +32 -0
  12. package/dist/server/content-search.js +155 -0
  13. package/dist/server/file-changes.d.ts +119 -0
  14. package/dist/server/file-changes.js +278 -0
  15. package/dist/server/file-diff.d.ts +38 -0
  16. package/dist/server/file-diff.js +97 -0
  17. package/dist/server/file-index.d.ts +56 -0
  18. package/dist/server/file-index.js +150 -0
  19. package/dist/server/files.d.ts +8 -0
  20. package/dist/server/files.js +3 -1
  21. package/dist/server/git.d.ts +17 -0
  22. package/dist/server/git.js +39 -0
  23. package/dist/server/http-server.js +121 -1
  24. package/dist/server/index.js +15 -0
  25. package/dist/server/pi-session-events.d.ts +9 -0
  26. package/dist/server/pi-session-events.js +12 -0
  27. package/dist/server/runtime.d.ts +22 -0
  28. package/dist/server/runtime.js +16 -1
  29. package/dist/server/server-context.d.ts +12 -0
  30. package/dist/server/types.d.ts +71 -0
  31. package/package.json +11 -1
  32. package/dist/client/assets/index-UiqTcZca.css +0 -1
  33. package/dist/client/assets/index-tBfBUY_8.js +0 -74
@@ -0,0 +1,97 @@
1
+ /**
2
+ * THE DIFF BETWEEN WHAT A FILE WAS AND WHAT IT IS NOW.
3
+ *
4
+ * WHY THE SERVER SENDS TWO TEXTS RATHER THAN A DIFF. The difference between two texts is computed by
5
+ * an algorithm, and there are good ones (jsdiff, on the client). Sending a diff instead would mean the
6
+ * server owns a second implementation of that algorithm, and the two would disagree at the edges - a
7
+ * hunk boundary, an empty file, a file with no trailing newline. Sending the two TEXTS means there is
8
+ * exactly ONE implementation of "what is the difference between these two strings" in the whole
9
+ * product, and the server's job is reduced to the one thing only it can do: produce the "before".
10
+ *
11
+ * WHERE THE "BEFORE" COMES FROM, in the order the user cares about.
12
+ *
13
+ * 1. THE AGENT'S OWN BASELINE. The server captured the file's content at the moment the agent's write
14
+ * tool was announced (`file-changes.ts`), so this is the exact content the agent started from. It is
15
+ * the right answer for "what did the agent just do", and it works in a directory that is not a git
16
+ * repository - which is most of them, for a user who has not initialised a repo.
17
+ * 2. GIT'S COMMITTED VERSION. For a file in a git repository, `HEAD` is a baseline that exists even
18
+ * when the server was not running when the change was made, and it is the baseline a reviewer
19
+ * already has in mind.
20
+ * 3. NOTHING. When neither exists - a brand new file in a directory that is not a repository, with no
21
+ * agent change to reconstruct - the answer is that there is no baseline, and the view says so
22
+ * rather than showing a diff against nothing.
23
+ *
24
+ * The BUFFER is the fourth baseline and it does not come from the server: the "before" of a buffer is
25
+ * the file on disk and the "after" is what the user has typed, and the client already holds both. So
26
+ * the client computes that one itself and never asks.
27
+ */
28
+ import { readFile } from "node:fs/promises";
29
+ import { gitHeadContent, isGitRepository } from "./git.js";
30
+ import { FilesError, resolveInsideRoot } from "./files.js";
31
+ /**
32
+ * The diff of one file, with the best baseline available.
33
+ *
34
+ * `preferred` says which baseline the caller wants. `auto` means "pick the best one", which is what
35
+ * the review view asks for; naming a specific baseline means the user has been told which comparison
36
+ * they are looking at and it must not silently become a different one.
37
+ */
38
+ export async function computeFileDiff(options) {
39
+ const { root, path, preferred = "auto", tracker, workspaceId } = options;
40
+ const after = await readAfter(root, path);
41
+ // The agent's baseline. It is the RIGHT one whenever it exists, because it is the content the agent
42
+ // actually started from - which is not necessarily HEAD, and not necessarily what the file looked
43
+ // like when the server started.
44
+ const agentChange = tracker && workspaceId ? tracker.get(workspaceId, path) : undefined;
45
+ if ((preferred === "auto" || preferred === "agent") && agentChange) {
46
+ return {
47
+ path,
48
+ baseline: "agent",
49
+ before: agentChange.before,
50
+ ...(agentChange.beforeUnknown ? { beforeUnknown: true } : {}),
51
+ after,
52
+ label: agentChange.before === null && !agentChange.beforeUnknown
53
+ ? "这个文件是 agent 新建的"
54
+ : "agent 改动前",
55
+ };
56
+ }
57
+ if (preferred === "auto" || preferred === "git") {
58
+ if (await isGitRepository(root)) {
59
+ const head = await gitHeadContent(root, path);
60
+ if (head !== null) {
61
+ return { path, baseline: "git", before: head, after, label: "Git 已提交的版本" };
62
+ }
63
+ }
64
+ }
65
+ if (preferred === "agent" && !agentChange) {
66
+ // The caller ASKED for the agent's baseline and there is none. Saying so is the honest answer; a
67
+ // silent fall-back to git would show the user a comparison they did not ask for.
68
+ return { path, baseline: "none", before: null, after, label: "没有 agent 改动前的版本可比较" };
69
+ }
70
+ return { path, baseline: "none", before: null, after, label: "没有可比较的基线" };
71
+ }
72
+ /**
73
+ * The file's content now, or `null` when it is gone.
74
+ *
75
+ * A MISSING FILE IS NOT AN ERROR. The agent deleting a file is a change the user needs to see, and a
76
+ * diff view that refused to open because the file no longer exists would refuse to show exactly the
77
+ * change that matters most.
78
+ *
79
+ * THE PATH GOES THROUGH `resolveInsideRoot`, like every other path this server reads. The path arrives
80
+ * from a browser, so it is untrusted, and the question "does this resolve to somewhere inside the
81
+ * workspace" has exactly one owner in this codebase (`files.ts`). A second, weaker copy of the check
82
+ * here would be a check that is correct once.
83
+ */
84
+ async function readAfter(root, path) {
85
+ try {
86
+ const target = await resolveInsideRoot(root, path);
87
+ return await readFile(target, "utf8");
88
+ }
89
+ catch (error) {
90
+ // A file that is GONE is a fact the diff reports; a path that ESCAPES the workspace is a refusal
91
+ // the route turns into a 403. They are told apart here so the route can do the right thing with
92
+ // each, rather than both becoming "no content".
93
+ if (error instanceof FilesError && (error.status === 403 || error.code === "outside_workspace"))
94
+ throw error;
95
+ return null;
96
+ }
97
+ }
@@ -0,0 +1,56 @@
1
+ import { type FileMeta } from "./files.js";
2
+ export type IndexedFile = {
3
+ path: string;
4
+ kind: "file" | "directory";
5
+ size: number | null;
6
+ mtimeMs: number | null;
7
+ etag: string | null;
8
+ text: boolean | null;
9
+ };
10
+ export type FileIndexEvent = {
11
+ type: "files.index";
12
+ workspaceId: string;
13
+ version: number;
14
+ changed: IndexedFile[];
15
+ removed: string[];
16
+ complete: boolean;
17
+ skipped: string[];
18
+ };
19
+ export type FileIndexSnapshot = {
20
+ version: number;
21
+ builtAt: number;
22
+ complete: boolean;
23
+ changed: IndexedFile[];
24
+ removed: string[];
25
+ skipped: string[];
26
+ };
27
+ /**
28
+ * Per-workspace index. The background pass uses readdir only because measurements on this repository
29
+ * show directory enumeration under a second while stat for every entry costs 2-3 seconds. Most entries
30
+ * deliberately have no etag: an etag belongs to a file a user has opened or a known write has touched,
31
+ * where the existing read/write metadata already paid for it. The client polls only those open files.
32
+ */
33
+ export declare class FileIndex {
34
+ readonly workspaceId: string;
35
+ private readonly broadcast;
36
+ readonly entries: Map<string, IndexedFile>;
37
+ private readonly history;
38
+ private root;
39
+ private queue;
40
+ private building;
41
+ private skipped;
42
+ version: number;
43
+ builtAt: number;
44
+ complete: boolean;
45
+ constructor(workspaceId: string, broadcast: (event: FileIndexEvent) => void);
46
+ start(root: string): void;
47
+ stop(): void;
48
+ snapshot(since?: number): FileIndexSnapshot;
49
+ refresh(path: string): Promise<void>;
50
+ /** Record metadata already obtained by opening, saving, or polling an open file. No second stat: the
51
+ * caller's file API operation has already passed the sole containment guard and read this metadata. */
52
+ recordFile(path: string, meta: FileMeta, mtimeMs?: number): void;
53
+ remove(path: string): void;
54
+ private scan;
55
+ private publish;
56
+ }
@@ -0,0 +1,150 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { SEARCH_SKIP_DIRS, createRootGuard, fileMeta, isTextName, toRelative } from "./files.js";
3
+ const MAX_DIRECTORIES = 4000;
4
+ const MAX_FILES = 30000;
5
+ const MAX_MS = 4000;
6
+ /**
7
+ * Per-workspace index. The background pass uses readdir only because measurements on this repository
8
+ * show directory enumeration under a second while stat for every entry costs 2-3 seconds. Most entries
9
+ * deliberately have no etag: an etag belongs to a file a user has opened or a known write has touched,
10
+ * where the existing read/write metadata already paid for it. The client polls only those open files.
11
+ */
12
+ export class FileIndex {
13
+ workspaceId;
14
+ broadcast;
15
+ entries = new Map();
16
+ history = [];
17
+ root = "";
18
+ queue = [];
19
+ building = false;
20
+ skipped = [];
21
+ version = 0;
22
+ builtAt = 0;
23
+ complete = false;
24
+ constructor(workspaceId, broadcast) {
25
+ this.workspaceId = workspaceId;
26
+ this.broadcast = broadcast;
27
+ }
28
+ start(root) {
29
+ if (this.building || this.builtAt)
30
+ return;
31
+ this.root = root;
32
+ this.building = true;
33
+ this.queue = [""];
34
+ void this.scan();
35
+ }
36
+ stop() { }
37
+ snapshot(since) {
38
+ const valid = Number.isFinite(since) && since !== undefined;
39
+ const deltas = valid ? this.history.filter((item) => item.version > since) : [];
40
+ return { version: this.version, builtAt: this.builtAt, complete: this.complete, skipped: this.skipped,
41
+ changed: valid ? deltas.flatMap((item) => item.changed) : [...this.entries.values()],
42
+ removed: valid ? deltas.flatMap((item) => item.removed) : [] };
43
+ }
44
+ async refresh(path) {
45
+ if (!this.root)
46
+ return;
47
+ const guard = createRootGuard(this.root);
48
+ try {
49
+ const absolute = await guard(path);
50
+ const info = await stat(absolute);
51
+ const rel = toRelative(this.root, absolute);
52
+ if (info.isDirectory())
53
+ this.publish([{ path: rel, kind: "directory", size: null, mtimeMs: null, etag: null, text: null }], []);
54
+ else {
55
+ const meta = await fileMeta(absolute);
56
+ this.recordFile(rel, meta, info.mtimeMs);
57
+ }
58
+ }
59
+ catch (error) {
60
+ const code = error.code;
61
+ if (code === "ENOENT" || code === "ENOTDIR")
62
+ this.remove(path);
63
+ else
64
+ throw error;
65
+ }
66
+ }
67
+ /** Record metadata already obtained by opening, saving, or polling an open file. No second stat: the
68
+ * caller's file API operation has already passed the sole containment guard and read this metadata. */
69
+ recordFile(path, meta, mtimeMs = Date.parse(meta.modifiedAt)) {
70
+ const previous = this.entries.get(path);
71
+ if (previous?.kind === "file" && previous.etag === meta.etag)
72
+ return;
73
+ this.publish([{ path, kind: "file", size: meta.size, mtimeMs, etag: meta.etag, text: isTextName(path) }], []);
74
+ }
75
+ remove(path) { if (this.entries.delete(path))
76
+ this.publish([], [path]); }
77
+ async scan() {
78
+ const started = Date.now();
79
+ let dirs = 0;
80
+ let files = 0;
81
+ let incomplete = false;
82
+ const changed = [];
83
+ const guard = createRootGuard(this.root);
84
+ scan: while (this.queue.length) {
85
+ if (dirs >= MAX_DIRECTORIES || files >= MAX_FILES || Date.now() - started >= MAX_MS) {
86
+ incomplete = true;
87
+ break;
88
+ }
89
+ const directory = this.queue.shift();
90
+ let listed;
91
+ try {
92
+ listed = await readdir(await guard(directory), { withFileTypes: true });
93
+ }
94
+ catch {
95
+ this.skipped.push(directory || "/");
96
+ incomplete = true;
97
+ continue;
98
+ }
99
+ dirs += 1;
100
+ for (const entry of listed) {
101
+ const path = directory ? directory + "/" + entry.name : entry.name;
102
+ if (entry.isSymbolicLink()) {
103
+ this.skipped.push(path);
104
+ continue;
105
+ }
106
+ if (entry.isDirectory()) {
107
+ if (SEARCH_SKIP_DIRS.has(entry.name)) {
108
+ this.skipped.push(path);
109
+ continue;
110
+ }
111
+ this.queue.push(path);
112
+ changed.push({ path, kind: "directory", size: null, mtimeMs: null, etag: null, text: null });
113
+ }
114
+ else if (entry.isFile()) {
115
+ if (files >= MAX_FILES || Date.now() - started >= MAX_MS) {
116
+ incomplete = true;
117
+ break scan;
118
+ }
119
+ files += 1;
120
+ changed.push({ path, kind: "file", size: null, mtimeMs: null, etag: null, text: isTextName(path) });
121
+ }
122
+ }
123
+ await new Promise((resolve) => setTimeout(resolve, 0));
124
+ }
125
+ this.building = false;
126
+ this.complete = !incomplete;
127
+ this.builtAt = Date.now();
128
+ // Quick Open does not need per-directory progress. One final batch bounds WS traffic and keeps the
129
+ // existing on-demand tree interactive throughout the scan (including dependency directories).
130
+ this.publish(changed, [], true);
131
+ }
132
+ publish(changed, removed, force = false) {
133
+ changed = changed.map((entry) => {
134
+ // A targeted stat may race the cheap directory walk. Do not let the later readdir-only result erase
135
+ // metadata that was already paid for and verified.
136
+ const previous = this.entries.get(entry.path);
137
+ const merged = previous?.kind === entry.kind && entry.size === null ? { ...entry, size: previous.size, mtimeMs: previous.mtimeMs, etag: previous.etag } : entry;
138
+ this.entries.set(entry.path, merged);
139
+ return merged;
140
+ });
141
+ if (!changed.length && !removed.length && !force)
142
+ return;
143
+ this.version += 1;
144
+ const event = { type: "files.index", workspaceId: this.workspaceId, version: this.version, changed, removed, complete: this.complete, skipped: this.skipped };
145
+ this.history.push({ version: this.version, changed, removed });
146
+ if (this.history.length > 128)
147
+ this.history.shift();
148
+ this.broadcast(event);
149
+ }
150
+ }
@@ -15,6 +15,14 @@ export declare const MAX_SEARCH_RESULTS = 500;
15
15
  /** How deep the recursive search walks. Twelve levels is past any real source tree; the cap is what
16
16
  * turns a pathologically deep dependency tree from a hang into a result. */
17
17
  export declare const MAX_SEARCH_DEPTH = 12;
18
+ /** Directories the recursive search skips. The TREE does not skip them - a directory the user cannot
19
+ * browse is a directory they cannot inspect a file in, and the binary handling below is only reachable
20
+ * if `node_modules` is reachable. A recursive filename search, by contrast, returns thousands of matches
21
+ * from these and nothing a person searched for, so they are excluded from the scan and the exclusion is
22
+ * reported in the response rather than being invisible. */
23
+ /** Shared exclusion list for recursive search and the quick-open index. The directory tree intentionally
24
+ * does not use it: users may still browse dependencies on demand. */
25
+ export declare const SEARCH_SKIP_DIRS: Set<string>;
18
26
  /**
19
27
  * Extensions this panel OPENS without needing a sniff. It is a hint used only to label a file in the tree
20
28
  * before it is read, and never as a permission: a `.png` is refused by name even if a sniffing pass would
@@ -52,7 +52,9 @@ export const MAX_SEARCH_DEPTH = 12;
52
52
  * if `node_modules` is reachable. A recursive filename search, by contrast, returns thousands of matches
53
53
  * from these and nothing a person searched for, so they are excluded from the scan and the exclusion is
54
54
  * reported in the response rather than being invisible. */
55
- const SEARCH_SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".super-pi", ".next", ".cache", "coverage", ".venv"]);
55
+ /** Shared exclusion list for recursive search and the quick-open index. The directory tree intentionally
56
+ * does not use it: users may still browse dependencies on demand. */
57
+ export const SEARCH_SKIP_DIRS = new Set([".git", "node_modules", "dist", "build", ".super-pi", ".next", ".cache", "coverage", ".venv"]);
56
58
  /**
57
59
  * Extensions that are binary by definition, checked BEFORE any bytes are read.
58
60
  *
@@ -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
  };
@@ -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": {