@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,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
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * WHAT THE AGENT CHANGED, AND WHAT IT LOOKED LIKE BEFORE.
3
+ *
4
+ * WHY THIS FILE EXISTS. The Files panel could show a file, but it could not show a CHANGE - and a panel
5
+ * that cannot show a change is a file browser, not a workbench. The user asked for the opposite of a
6
+ * browser: "看得到文件才能更好地支持 ai 的改动" - being able to SEE the files is what makes the agent's
7
+ * edits reviewable. Reviewing an edit means seeing two things: WHICH files were touched, and WHAT
8
+ * changed in them. Neither existed.
9
+ *
10
+ * THE SERVICE ALREADY KNEW THE FIRST HALF AND THREW IT AWAY. `tool-calls.ts` reads a tool's target path
11
+ * out of its arguments to draw one line on a card ("edit src/server/files.ts"). That line was the ONLY
12
+ * place the information existed, and it was never sent to the Files panel - the WS protocol had no file
13
+ * event at all, so a file the agent rewrote looked exactly like a file it had never touched.
14
+ *
15
+ * WHY THE PATHS ARE READ FROM THE ARGUMENTS RATHER THAN GUESSED FROM A WATCHER. A watcher sees bytes
16
+ * changing; it does not see WHO changed them, and it cannot distinguish the agent's edit from an
17
+ * unrelated build step. The tool call is the agent saying "I am about to change this path" in its own
18
+ * words - the most reliable source available, and one that costs nothing to read.
19
+ *
20
+ * WHY THE "BEFORE" CONTENT IS CAPTURED HERE RATHER THAN RECOVERED LATER. Once the agent has written the
21
+ * file, the old bytes are gone from the disk. A diff needs a baseline, and the baseline has to be taken
22
+ * BEFORE the write. So this file keeps a copy of the content as it was, taken when the agent's write
23
+ * tool was announced - the last moment the old content is still on disk.
24
+ *
25
+ * WHAT THIS FILE DOES NOT DO. It does not watch the disk. An external editor saving a file is noticed by
26
+ * the CLIENT's existing poll (`?meta=1` on open files), not here - and that asymmetry is deliberate: this
27
+ * file is about the agent's changes, which is the half only the server can see. It does not keep a full
28
+ * copy of every file: only the files the agent actually wrote, and only until the diff is read.
29
+ */
30
+ import { readFileSync, statSync } from "node:fs";
31
+ import { createHash } from "node:crypto";
32
+ import { isAbsolute, relative, resolve, sep } from "node:path";
33
+ /**
34
+ * The tools whose arguments name a file the agent is about to WRITE.
35
+ *
36
+ * `edit` and `write` are Pi's two file-writing tools (`node_modules/@earendil-works/pi-coding-agent/
37
+ * dist/core/tools/`), and `notebookedit` is the third in the family that edits a notebook. The list is
38
+ * deliberately narrow: a `read` names a file too, and a `read` changes nothing, so treating it as a
39
+ * change would mark a file the agent merely LOOKED at as one it edited - and a marker that lies is worse
40
+ * than no marker, because the user's trust in every other marker goes with it.
41
+ *
42
+ * The names are matched case-insensitively and after trimming, because the name comes off a wire and a
43
+ * tool named "Edit" is the same tool.
44
+ */
45
+ export const FILE_WRITING_TOOLS = new Set(["edit", "write", "notebookedit", "multiedit"]);
46
+ /**
47
+ * The argument fields that name the file, in the order the tools tend to spell them.
48
+ *
49
+ * The same list `tool-calls.ts` uses to draw its one-line summary, and deliberately the same ORDER: two
50
+ * tables that disagree about which field wins would make the card say "src/a.ts" and the change tracker
51
+ * say "src/b.ts" for the same call, and the user would have no way to tell which one is right.
52
+ *
53
+ * `notebook_path` is here because the notebook tools spell it that way, and it is checked before the
54
+ * generic `path` only because the notebook tools have no plain `path` field to collide with.
55
+ */
56
+ const PATH_KEYS = ["path", "file_path", "filePath", "target_file", "filename", "file", "notebook_path"];
57
+ /**
58
+ * The path an argument names, or `undefined` when the arguments name none.
59
+ *
60
+ * NOT a display string. `describeToolCall` collapses whitespace and caps the length, which is right for
61
+ * 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
62
+ * would be reported as a path with one space, and the file would not be found. This returns the value
63
+ * as the tool spelled it, so the caller can resolve it properly.
64
+ *
65
+ * The value is NOT validated here. The caller resolves it against the workspace root and refuses
66
+ * anything that leaves it - the containment rule lives in `files.ts` and has exactly one owner, and this
67
+ * module does not get a second, weaker copy of it.
68
+ */
69
+ export function toolTargetPath(name, args) {
70
+ if (typeof name !== "string")
71
+ return undefined;
72
+ if (!FILE_WRITING_TOOLS.has(name.trim().toLowerCase()))
73
+ return undefined;
74
+ if (args === null || typeof args !== "object" || Array.isArray(args))
75
+ return undefined;
76
+ const record = args;
77
+ for (const key of PATH_KEYS) {
78
+ const value = record[key];
79
+ if (typeof value !== "string")
80
+ continue;
81
+ const trimmed = value.trim();
82
+ if (trimmed)
83
+ return trimmed;
84
+ }
85
+ return undefined;
86
+ }
87
+ /**
88
+ * The changes recorded for one workspace, and the old contents they need.
89
+ *
90
+ * BOUNDED ON PURPOSE, in two directions. The number of entries is capped because a long session can
91
+ * touch a lot of files, and the copy of every old version is the memory cost of this feature. The size
92
+ * of ONE old version is capped because a single 5MB file would otherwise be the whole budget.
93
+ *
94
+ * When a cap is reached, the OLDEST entry is dropped, and `dropped` counts how many were dropped. That
95
+ * count is reported rather than hidden: a diff view that says "this file changed" and then cannot show
96
+ * the change because the baseline was evicted is a worse experience than one that says "the baseline for
97
+ * this file is no longer available", and the second is what the count makes possible.
98
+ */
99
+ const MAX_TRACKED_CHANGES = 200;
100
+ const MAX_BASELINE_BYTES = 1024 * 1024;
101
+ /**
102
+ * The identity of a file's current bytes - the same formula `files.ts` uses, so the two agree.
103
+ *
104
+ * Size, nanosecond modification time and inode: the two fields together change on any edit a human
105
+ * would call an edit, and `bigint` is what makes the modification time exact - the millisecond float
106
+ * `stat` reports by default rounds two edits inside the same millisecond into one value, and a check
107
+ * that misses a change is worse than no check.
108
+ */
109
+ function etagOf(size, mtimeNs, ino) {
110
+ return createHash("sha1").update(`${size}:${mtimeNs}:${ino}`).digest("hex").slice(0, 20);
111
+ }
112
+ export class FileChangeTracker {
113
+ /** Workspace id -> path -> the change recorded for it. A path is recorded once: the LAST change to a
114
+ * file wins, because a file edited three times in a turn is one file with one final state, and the
115
+ * reader wants the diff from before the FIRST change to after the LAST one. */
116
+ #byWorkspace = new Map();
117
+ /** How many changes were dropped because the cap was reached, per workspace. */
118
+ #dropped = new Map();
119
+ /**
120
+ * Records that the agent is about to write `path`, capturing the current content as the baseline.
121
+ *
122
+ * Called when the tool call is ANNOUNCED, not when it ends. The announcement is the last moment the
123
+ * old bytes are still on the disk: by the time the tool has finished, the file already holds the new
124
+ * content and the baseline is gone forever. This is the whole reason the tracker reads the disk at
125
+ * all.
126
+ *
127
+ * A file that is already tracked is NOT re-baselined. Re-baselining would set the "before" to the
128
+ * agent's own intermediate state, and the diff would then show only the LAST edit rather than
129
+ * everything the agent did to the file in this turn - which is the thing a reviewer needs to see.
130
+ *
131
+ * THE READ IS SYNCHRONOUS, and that is the whole reason this feature works.
132
+ *
133
+ * Pi announces a tool call and then RUNS it, and it does not wait for this server to acknowledge the
134
+ * announcement. So the tool may well have written the file by the time this server gets around to
135
+ * reading it - and an ASYNCHRONOUS read loses that race almost every time, because the read is queued
136
+ * behind the very write it is trying to get ahead of. The first version of this was asynchronous, and
137
+ * the symptom was a diff that said "+0 / -0": the baseline it captured was the file's NEW content,
138
+ * because by the time the read happened the agent had already written.
139
+ *
140
+ * A synchronous read cannot lose that race in the same way - it happens in the same tick the
141
+ * announcement was parsed, before anything else can run - and the cost is bounded by the size cap
142
+ * below. A blocked event loop for the length of one small file read, once per agent write, is a far
143
+ * better trade than a diff that shows nothing.
144
+ */
145
+ record(workspaceId, path, options) {
146
+ const relativePath = this.#relative(options.root, path);
147
+ if (!relativePath)
148
+ return;
149
+ const changes = this.#byWorkspace.get(workspaceId) ?? new Map();
150
+ this.#byWorkspace.set(workspaceId, changes);
151
+ if (changes.has(relativePath))
152
+ return;
153
+ let before = null;
154
+ let beforeUnknown = false;
155
+ let etag;
156
+ try {
157
+ const absolute = resolve(options.root, path);
158
+ // The size is checked BEFORE the read, so an oversized file costs one stat rather than a buffer.
159
+ const info = statSync(absolute, { bigint: true });
160
+ if (info.size > MAX_BASELINE_BYTES) {
161
+ // A baseline larger than the cap is not a baseline - it is a memory cost with no diff to show
162
+ // for it. The change is still recorded (the file WAS changed, and that fact is worth knowing);
163
+ // what is dropped is the ability to show what it was, which `beforeUnknown` says out loud.
164
+ beforeUnknown = true;
165
+ }
166
+ else {
167
+ before = readFileSync(absolute, "utf8");
168
+ etag = etagOf(info.size, info.mtimeNs, info.ino);
169
+ }
170
+ }
171
+ catch (error) {
172
+ const code = error?.code;
173
+ // ENOENT is the ordinary case of a NEW file, and it is a fact rather than a failure: the file did
174
+ // not exist before the agent created it, so there is no "before" to show. Anything else (a
175
+ // permission problem, a directory) means we simply do not know, and the two are told apart by
176
+ // `beforeUnknown`.
177
+ beforeUnknown = code !== "ENOENT" && code !== "ENOTDIR";
178
+ }
179
+ changes.set(relativePath, {
180
+ path: relativePath,
181
+ before,
182
+ beforeUnknown,
183
+ ...(etag ? { etag } : {}),
184
+ at: Date.now(),
185
+ ...(options.conversationId ? { conversationId: options.conversationId } : {}),
186
+ });
187
+ if (changes.size > MAX_TRACKED_CHANGES) {
188
+ // The OLDEST goes, because a reviewer is looking at the most recent work. `Map` preserves
189
+ // insertion order, so the first key is the oldest.
190
+ const oldest = changes.keys().next();
191
+ if (!oldest.done) {
192
+ changes.delete(oldest.value);
193
+ this.#dropped.set(workspaceId, (this.#dropped.get(workspaceId) ?? 0) + 1);
194
+ }
195
+ }
196
+ }
197
+ /**
198
+ * CONFIRMS A BASELINE, once the agent's write has finished.
199
+ *
200
+ * The synchronous read makes the baseline correct in practice, but "in practice" is not a guarantee:
201
+ * if the tool somehow ran before the announcement was processed, the baseline would be the file's NEW
202
+ * content and the diff would show nothing at all - a diff that says "no change" about a file the
203
+ * agent just rewrote, which is the worst possible lie this feature could tell.
204
+ *
205
+ * So the baseline is CHECKED rather than trusted. The file's etag after the write must DIFFER from
206
+ * the etag the baseline was taken at - and if it does not, the baseline was taken after the write and
207
+ * is worthless. The change is then marked as having no usable "before", and the diff view says so
208
+ * instead of showing an empty diff.
209
+ *
210
+ * The one case this cannot tell apart is a write that changed nothing (the agent wrote identical
211
+ * bytes). There the baseline and the file agree, and the diff is empty - which is CORRECT, because
212
+ * nothing changed. The reader is told the before-version is unconfirmed, which is true in both cases.
213
+ */
214
+ confirm(workspaceId, path, root) {
215
+ const changes = this.#byWorkspace.get(workspaceId);
216
+ if (!changes)
217
+ return;
218
+ // The path is spelled the way the tracker spells it - workspace-relative, `/`-separated - and the
219
+ // caller may hold the tool's own spelling, so both are tried.
220
+ const relativePath = this.#relative(root, path);
221
+ const change = (relativePath ? changes.get(relativePath) : undefined) ?? changes.get(path);
222
+ if (!change || !change.etag)
223
+ return;
224
+ try {
225
+ const info = statSync(resolve(root, change.path), { bigint: true });
226
+ const current = etagOf(info.size, info.mtimeNs, info.ino);
227
+ if (current === change.etag) {
228
+ // The file is byte-for-byte what the baseline says it was, so the baseline was taken AFTER the
229
+ // write - or nothing changed at all. Either way it cannot be shown as "before".
230
+ change.before = null;
231
+ change.beforeUnknown = true;
232
+ }
233
+ }
234
+ catch {
235
+ // The file is gone: the agent DELETED it, which is a change of its own. The baseline stands.
236
+ }
237
+ }
238
+ /**
239
+ * The changes recorded for a workspace, newest first.
240
+ *
241
+ * `dropped` says how many baselines were evicted, so the panel can say so rather than quietly showing
242
+ * a shorter list than the agent actually produced.
243
+ */
244
+ list(workspaceId) {
245
+ const changes = [...(this.#byWorkspace.get(workspaceId)?.values() ?? [])];
246
+ changes.reverse();
247
+ return { changes, dropped: this.#dropped.get(workspaceId) ?? 0 };
248
+ }
249
+ /** The recorded change for one path, or `undefined` when there is none. */
250
+ get(workspaceId, path) {
251
+ return this.#byWorkspace.get(workspaceId)?.get(path);
252
+ }
253
+ /** Forgets a workspace's changes. Used when the workspace is deleted. */
254
+ forget(workspaceId) {
255
+ this.#byWorkspace.delete(workspaceId);
256
+ this.#dropped.delete(workspaceId);
257
+ }
258
+ /**
259
+ * The path as the rest of the app spells it: workspace-relative, `/`-separated, no leading slash.
260
+ *
261
+ * REFUSES ANYTHING OUTSIDE THE ROOT, rather than clamping it. A tool that names `../outside.txt` is
262
+ * either broken or hostile, and a "relative path" that silently becomes `../outside.txt` would be a
263
+ * path the panel would then try to read. The containment decision itself belongs to `files.ts`; this
264
+ * is only about the SPELLING, and it declines to spell a path it cannot vouch for.
265
+ */
266
+ #relative(root, path) {
267
+ const base = resolve(root);
268
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(base, path);
269
+ const rel = relative(base, absolute);
270
+ if (rel === "")
271
+ return null;
272
+ if (isAbsolute(rel))
273
+ return null;
274
+ if (rel === ".." || rel.startsWith(`..${sep}`) || rel.startsWith("../"))
275
+ return null;
276
+ return rel.split(sep).join("/");
277
+ }
278
+ }
@@ -0,0 +1,38 @@
1
+ import type { FileChangeTracker } from "./file-changes.js";
2
+ /**
3
+ * WHERE THE "BEFORE" CAME FROM. The client renders a different label for each, because they are
4
+ * different claims: "this is what the agent changed" and "this is what the repository had committed"
5
+ * are not the same sentence, and a diff shown without knowing which one it is against is a diff the
6
+ * reader has to guess about.
7
+ */
8
+ export type DiffBaselineKind = "agent" | "git" | "none";
9
+ export type FileDiff = {
10
+ /** The file, workspace-relative and `/`-separated. */
11
+ path: string;
12
+ /** Which baseline was used. */
13
+ baseline: DiffBaselineKind;
14
+ /** The content before, or `null` when there was none - which is how a NEW file is told from an
15
+ * EDITED one. */
16
+ before: string | null;
17
+ /** True when the "before" could not be READ, as opposed to not existing. A diff against an empty
18
+ * "before" for a file that existed would be a lie, and this is what keeps it from being one. */
19
+ beforeUnknown?: boolean;
20
+ /** The content now, or `null` when the file is gone. */
21
+ after: string | null;
22
+ /** A sentence naming the baseline, for the view to show. */
23
+ label: string;
24
+ };
25
+ /**
26
+ * The diff of one file, with the best baseline available.
27
+ *
28
+ * `preferred` says which baseline the caller wants. `auto` means "pick the best one", which is what
29
+ * the review view asks for; naming a specific baseline means the user has been told which comparison
30
+ * they are looking at and it must not silently become a different one.
31
+ */
32
+ export declare function computeFileDiff(options: {
33
+ root: string;
34
+ path: string;
35
+ preferred?: "auto" | DiffBaselineKind;
36
+ tracker?: FileChangeTracker;
37
+ workspaceId?: string;
38
+ }): Promise<FileDiff>;