pi-posthorse 0.10.2 → 0.10.4

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.
@@ -0,0 +1,185 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import {
4
+ copyFile,
5
+ lstat,
6
+ mkdir,
7
+ mkdtemp,
8
+ open,
9
+ readFile,
10
+ rename,
11
+ rm,
12
+ stat,
13
+ utimes,
14
+ writeFile,
15
+ } from "node:fs/promises";
16
+ import { dirname, join } from "node:path";
17
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
18
+ import { isRecord } from "./contracts.ts";
19
+ import { publishFile } from "./files.ts";
20
+ import {
21
+ notePath,
22
+ parseBinding,
23
+ sameOwner,
24
+ scopeDirectory,
25
+ type ForkBinding,
26
+ type NoteAddress,
27
+ type NoteBinding,
28
+ type NoteIdentity,
29
+ type NoteSeed,
30
+ } from "./note-scopes.ts";
31
+
32
+ export function fileIdentity(info: NoteIdentity): NoteIdentity {
33
+ return {
34
+ size: info.size,
35
+ mtimeMs: info.mtimeMs,
36
+ ctimeMs: info.ctimeMs,
37
+ ino: info.ino,
38
+ dev: info.dev,
39
+ };
40
+ }
41
+ export function sameIdentity(left: NoteIdentity, right: NoteIdentity): boolean {
42
+ return (
43
+ left.size === right.size &&
44
+ left.mtimeMs === right.mtimeMs &&
45
+ left.ctimeMs === right.ctimeMs &&
46
+ left.ino === right.ino &&
47
+ left.dev === right.dev
48
+ );
49
+ }
50
+ export async function fileDigest(path: string, signal?: AbortSignal): Promise<string> {
51
+ const file = await open(path, "r");
52
+ try {
53
+ const hash = createHash("sha256");
54
+ for await (const chunk of file.createReadStream({ autoClose: false })) {
55
+ signal?.throwIfAborted();
56
+ const bytes: unknown = chunk;
57
+ if (!(bytes instanceof Uint8Array)) {
58
+ throw new TypeError("Expected binary file data.");
59
+ }
60
+ hash.update(bytes);
61
+ }
62
+ return hash.digest("hex");
63
+ } finally {
64
+ await file.close();
65
+ }
66
+ }
67
+ export async function readManifest(directory: string): Promise<ForkBinding | undefined> {
68
+ try {
69
+ const raw: unknown = JSON.parse(
70
+ await readFile(join(dirname(directory), "manifest.json"), "utf8"),
71
+ );
72
+ const binding = parseBinding(raw);
73
+ return binding?.kind === "fork" && scopeDirectory(binding) === directory
74
+ ? binding
75
+ : undefined;
76
+ } catch (error) {
77
+ if (
78
+ error instanceof SyntaxError ||
79
+ (isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))
80
+ ) {
81
+ return;
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+ export type SnapshotNote = {
87
+ readonly path: string;
88
+ readonly source: NoteAddress;
89
+ readonly lineage: NoteAddress;
90
+ readonly anchors: readonly string[];
91
+ };
92
+ async function copyNote(note: SnapshotNote, destination: string): Promise<NoteSeed> {
93
+ const source = notePath(note.source, note.source.path);
94
+ const before = await stat(source);
95
+ await mkdir(dirname(destination), { recursive: true });
96
+ await copyFile(source, destination, constants.COPYFILE_EXCL);
97
+ const after = await stat(source);
98
+ if (!sameIdentity(before, after)) {
99
+ throw new Error(`Source note changed during snapshot: ${source}. Retry initialization.`);
100
+ }
101
+ await utimes(destination, before.atimeMs / 1000, before.mtimeMs / 1000);
102
+ const digest = await fileDigest(destination);
103
+ return { ...note, digest, identity: fileIdentity(await stat(destination)) };
104
+ }
105
+ /** Publication is the ownership boundary. An existing unrelated directory is never overwritten. */
106
+ export async function snapshot(
107
+ binding: ForkBinding,
108
+ notes: readonly SnapshotNote[],
109
+ ): Promise<ForkBinding> {
110
+ const directory = scopeDirectory(binding);
111
+ const existing = await readManifest(directory);
112
+ if (existing !== undefined && sameOwner(existing.owner, binding.owner)) {
113
+ return existing;
114
+ }
115
+ const container = dirname(directory);
116
+ if (
117
+ await lstat(container).catch((error: unknown) => {
118
+ if (isRecord(error) && error.code === "ENOENT") {
119
+ return;
120
+ }
121
+ throw error;
122
+ })
123
+ ) {
124
+ throw new Error(`Incomplete notes snapshot: preserving unexpected data at ${container}.`);
125
+ }
126
+ if (notes.length === 0) {
127
+ return binding;
128
+ }
129
+ await mkdir(dirname(container), { recursive: true });
130
+ const stage = await mkdtemp(join(dirname(container), ".staging-"));
131
+ try {
132
+ const seeds: NoteSeed[] = [];
133
+ for (const note of notes) {
134
+ // Keep each file's identity and digest paired with the bytes actually copied.
135
+ seeds.push(await copyNote(note, notePath({ root: stage, scope: "notes" }, note.path)));
136
+ }
137
+ const complete: ForkBinding = { ...binding, seeds };
138
+ await writeFile(join(stage, "manifest.json"), JSON.stringify(complete), { flag: "wx" });
139
+ await rename(stage, container);
140
+ return complete;
141
+ } catch (error) {
142
+ throw new Error(
143
+ `Incomplete notes snapshot for ${directory}: ${error instanceof Error ? error.message : String(error)}`,
144
+ { cause: error },
145
+ );
146
+ } finally {
147
+ await rm(stage, { recursive: true, force: true });
148
+ }
149
+ }
150
+ /** Called inside the destination's native mutation queue, never queues that path recursively. */
151
+ export async function adoptNote(binding: NoteBinding, note: SnapshotNote): Promise<NoteSeed> {
152
+ return copyNote(note, notePath(binding, note.path));
153
+ }
154
+ /** Empty initialized scopes publish their manifest when the first note is created. */
155
+ export async function ensureManifest(binding: NoteBinding): Promise<void> {
156
+ if (binding.kind !== "fork") {
157
+ return;
158
+ }
159
+ const path = join(dirname(scopeDirectory(binding)), "manifest.json");
160
+ await mkdir(dirname(path), { recursive: true });
161
+ try {
162
+ await writeFile(path, JSON.stringify(binding), { flag: "wx" });
163
+ } catch (error) {
164
+ if (!isRecord(error) || error.code !== "EEXIST") {
165
+ throw error;
166
+ }
167
+ const existing = await readManifest(scopeDirectory(binding));
168
+ if (existing === undefined || !sameOwner(existing.owner, binding.owner)) {
169
+ throw new Error(`Preserving unexpected notes manifest at ${path}.`, { cause: error });
170
+ }
171
+ }
172
+ }
173
+
174
+ export async function saveAdoption(binding: NoteBinding, seed: NoteSeed): Promise<void> {
175
+ if (binding.kind !== "fork") {
176
+ return;
177
+ }
178
+ const path = join(dirname(scopeDirectory(binding)), "manifest.json");
179
+ await withFileMutationQueue(path, async () => {
180
+ await ensureManifest(binding);
181
+ const previous = (await readManifest(scopeDirectory(binding))) ?? binding;
182
+ const seeds = [...previous.seeds.filter((item) => item.path !== seed.path), seed];
183
+ await publishFile(path, JSON.stringify({ ...previous, seeds }));
184
+ });
185
+ }
@@ -0,0 +1,278 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ withFileMutationQueue,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import type { EntryLike, ProjectedEntry } from "./contracts.ts";
9
+ import { notesDir } from "./files.ts";
10
+ import { adoptNote, ensureManifest, readManifest, saveAdoption } from "./note-snapshots.ts";
11
+ import {
12
+ loadNotesBinding,
13
+ resolveReference,
14
+ sourceEntries,
15
+ type NoteSource,
16
+ } from "./note-provenance.ts";
17
+ import {
18
+ NOTES_BINDING,
19
+ NOTES_TOUCH,
20
+ bindingsOf,
21
+ canonicalPath,
22
+ inside,
23
+ notePath,
24
+ noteRelative,
25
+ ownedPaths,
26
+ sameOwner,
27
+ sameScope,
28
+ scopeDirectory,
29
+ type NoteAddress,
30
+ type NoteBinding,
31
+ type NoteScope,
32
+ type NoteTouchRecord,
33
+ } from "./note-scopes.ts";
34
+ import type { NotesRecovery } from "./note-evidence.ts";
35
+
36
+ type NotesManager = ExtensionContext["sessionManager"];
37
+ export type NoteCall = { readonly id: string; readonly rootId: string };
38
+ export type NoteRead = {
39
+ readonly address: NoteAddress;
40
+ readonly path: string;
41
+ readonly writable: boolean;
42
+ };
43
+ export class SessionNotes {
44
+ readonly #pi: ExtensionAPI;
45
+ readonly binding: NoteBinding;
46
+ readonly #manager: NotesManager;
47
+ readonly #source: NoteSource;
48
+ readonly #owned: Set<string>;
49
+ readonly scopes: readonly NoteScope[];
50
+ readonly #calls = new Map<string, string>();
51
+ readonly #revisions = new Map<string, number>();
52
+ constructor(pi: ExtensionAPI, binding: NoteBinding, manager: NotesManager, source: NoteSource) {
53
+ this.#pi = pi;
54
+ this.binding = binding;
55
+ this.#manager = manager;
56
+ this.#source = source;
57
+ this.#owned = ownedPaths(binding, manager.getEntries());
58
+ this.scopes = [
59
+ binding,
60
+ { root: binding.root, scope: "" },
61
+ ...(binding.kind === "fork" && binding.source !== null ? [binding.source] : []),
62
+ ...bindingsOf([...manager.getEntries(), ...source.entries]),
63
+ ];
64
+ }
65
+ get directory(): string {
66
+ return scopeDirectory(this.binding);
67
+ }
68
+ get branch(): readonly EntryLike[] {
69
+ return this.#manager.getBranch();
70
+ }
71
+ get projection(): readonly ProjectedEntry[] {
72
+ return this.#manager.buildSessionProjection().entries;
73
+ }
74
+ revision(path: string): number {
75
+ return this.#revisions.get(canonicalPath(path)) ?? 0;
76
+ }
77
+ get recovery(): NotesRecovery {
78
+ return { binding: this.binding, entries: this.#manager.getEntries() };
79
+ }
80
+ startCall(id: string, parent: string | undefined): NoteCall {
81
+ const rootId = parent === undefined ? id : (this.#calls.get(parent) ?? parent);
82
+ this.#calls.set(id, rootId);
83
+ return { id, rootId };
84
+ }
85
+ finishCall(id: string): void {
86
+ this.#calls.delete(id);
87
+ }
88
+ call(id: string): NoteCall {
89
+ return { id, rootId: this.#calls.get(id) ?? id };
90
+ }
91
+ owns(path: string): boolean {
92
+ return (
93
+ this.binding.kind === "project" ||
94
+ this.#owned.has(path) ||
95
+ existsSync(notePath(this.binding, path))
96
+ );
97
+ }
98
+ record(files: NoteTouchRecord["files"], call: NoteCall): void {
99
+ if (files.length === 0) {
100
+ return;
101
+ }
102
+ for (const file of files) {
103
+ if (file.kind !== "read" && sameScope(file, this.binding)) {
104
+ this.#owned.add(file.path);
105
+ const path = canonicalPath(notePath(file, file.path));
106
+ this.#revisions.set(path, this.revision(path) + 1);
107
+ }
108
+ }
109
+ this.#pi.appendEntry(NOTES_TOUCH, {
110
+ version: 1,
111
+ owner: this.binding.owner,
112
+ scope: { root: this.binding.root, scope: this.binding.scope },
113
+ rootToolCallId: call.rootId,
114
+ toolCallId: call.id,
115
+ files,
116
+ } satisfies NoteTouchRecord);
117
+ }
118
+ async selectScope(selector: string): Promise<NoteScope> {
119
+ const directory = canonicalPath(selector);
120
+ const known = this.scopes.find(
121
+ (scope) => canonicalPath(scopeDirectory(scope)) === directory,
122
+ );
123
+ if (known !== undefined) {
124
+ return known;
125
+ }
126
+ const manifest = await readManifest(directory);
127
+ if (
128
+ manifest !== undefined &&
129
+ manifest.root === this.binding.root &&
130
+ inside(join(this.binding.root, ".forks"), directory)
131
+ ) {
132
+ return manifest;
133
+ }
134
+ throw new Error(
135
+ `Unknown notes scope ${selector}. Copy a scope selector from notes list/search.`,
136
+ );
137
+ }
138
+ async #reference(path: string, signal?: AbortSignal): Promise<NoteAddress | undefined> {
139
+ if (this.binding.kind !== "fork" || this.binding.source === null) {
140
+ return;
141
+ }
142
+ const entries = [
143
+ ...this.#manager.getEntries(),
144
+ ...this.#source.entries,
145
+ ...(await sourceEntries(this.#source.file, signal)),
146
+ ];
147
+ return resolveReference(this.binding.source, path, entries);
148
+ }
149
+ async read(
150
+ path: string,
151
+ options: {
152
+ readonly scope?: string | undefined;
153
+ readonly signal?: AbortSignal | undefined;
154
+ } = {},
155
+ ): Promise<NoteRead> {
156
+ const relative = noteRelative(path);
157
+ if (options.scope !== undefined) {
158
+ const scope = await this.selectScope(options.scope);
159
+ return {
160
+ address: { ...scope, path: relative },
161
+ path: notePath(scope, relative),
162
+ writable: sameScope(scope, this.binding),
163
+ };
164
+ }
165
+ const reference = this.owns(relative)
166
+ ? undefined
167
+ : await this.#reference(relative, options.signal);
168
+ const address = reference ?? {
169
+ root: this.binding.root,
170
+ scope: this.binding.scope,
171
+ path: relative,
172
+ };
173
+ return { address, path: notePath(address, relative), writable: reference === undefined };
174
+ }
175
+ async adopt(path: string, call: NoteCall): Promise<boolean> {
176
+ const relative = noteRelative(path);
177
+ if (this.owns(relative)) {
178
+ return false;
179
+ }
180
+ const source = await this.#reference(relative);
181
+ if (source === undefined) {
182
+ return false;
183
+ }
184
+ const seed = await adoptNote(this.binding, {
185
+ path: relative,
186
+ source,
187
+ lineage: source,
188
+ anchors: [],
189
+ });
190
+ this.record(
191
+ [
192
+ {
193
+ root: this.binding.root,
194
+ scope: this.binding.scope,
195
+ path: relative,
196
+ kind: "adopt",
197
+ seed,
198
+ },
199
+ ],
200
+ call,
201
+ );
202
+ try {
203
+ await saveAdoption(this.binding, seed);
204
+ } catch (error) {
205
+ throw new Error(
206
+ `Private copy created at ${notePath(this.binding, relative)}; source not edited, but its adoption manifest could not be saved. Read the private copy before retrying.`,
207
+ { cause: error },
208
+ );
209
+ }
210
+ return true;
211
+ }
212
+ async prepareWrite(): Promise<void> {
213
+ await ensureManifest(this.binding);
214
+ }
215
+ async guardReference(path: string, call: NoteCall): Promise<string> {
216
+ const destination = notePath(this.binding, path);
217
+ const copied = await withFileMutationQueue(destination, () => this.adopt(path, call));
218
+ return copied
219
+ ? `Private copy created at ${destination}; the source was not edited. Read the private copy, then retry the edit on that path.`
220
+ : `This session writes only ${this.directory}. Read ${destination}, then retry there; the foreign path was not edited.`;
221
+ }
222
+ }
223
+ function sessionKey(ctx: ExtensionContext): string {
224
+ const header = ctx.sessionManager.getHeader();
225
+ if (header === null) {
226
+ throw new Error("Pi session header is required for notes.");
227
+ }
228
+ return `${header.id}\0${header.timestamp}`;
229
+ }
230
+ export type NotesStore = {
231
+ readonly get: (ctx: ExtensionContext) => Promise<SessionNotes>;
232
+ readonly roots: () => readonly string[];
233
+ readonly recovery: (ctx: ExtensionContext) => NotesRecovery | undefined;
234
+ };
235
+ export function createNotesStore(pi: ExtensionAPI): NotesStore {
236
+ const sessions = new Map<string, Promise<SessionNotes>>();
237
+ const ready = new Map<string, SessionNotes>();
238
+ const roots = new Set<string>();
239
+ const get = (ctx: ExtensionContext): Promise<SessionNotes> => {
240
+ const key = sessionKey(ctx);
241
+ const existing = sessions.get(key);
242
+ if (existing !== undefined) {
243
+ return existing;
244
+ }
245
+ const bindings = bindingsOf(ctx.sessionManager.getEntries());
246
+ const header = ctx.sessionManager.getHeader();
247
+ const root =
248
+ bindings.findLast((binding) => header !== null && sameOwner(binding.owner, header))
249
+ ?.root ?? canonicalPath(notesDir(ctx.cwd));
250
+ roots.add(root);
251
+ for (const binding of bindings) {
252
+ roots.add(binding.root);
253
+ }
254
+ const initialized = loadNotesBinding(ctx.sessionManager, root)
255
+ .then(({ binding, source, created }) => {
256
+ if (created) {
257
+ pi.appendEntry(NOTES_BINDING, binding);
258
+ }
259
+ const session = new SessionNotes(pi, binding, ctx.sessionManager, source);
260
+ for (const scope of session.scopes) {
261
+ roots.add(scope.root);
262
+ }
263
+ ready.set(key, session);
264
+ return session;
265
+ })
266
+ .catch((error: unknown) => {
267
+ sessions.delete(key);
268
+ throw error;
269
+ });
270
+ sessions.set(key, initialized);
271
+ return initialized;
272
+ };
273
+ return {
274
+ get,
275
+ roots: () => [...roots],
276
+ recovery: (ctx: ExtensionContext) => ready.get(sessionKey(ctx))?.recovery,
277
+ };
278
+ }
@@ -0,0 +1,190 @@
1
+ import { isRecord, optionalString, unknownArray } from "./contracts.ts";
2
+
3
+ export type NoteAction =
4
+ | "read"
5
+ | "write"
6
+ | "clear"
7
+ | "append"
8
+ | "edit"
9
+ | "create"
10
+ | "create-empty"
11
+ | "delete";
12
+ export type NoteOperation = {
13
+ readonly path: string;
14
+ readonly action: NoteAction;
15
+ readonly scope?: string | undefined;
16
+ };
17
+ export function bareToolName(name: string | undefined): string {
18
+ const cut = Math.max(name?.lastIndexOf(":") ?? -1, name?.lastIndexOf("/") ?? -1);
19
+ return name?.slice(cut + 1) ?? "";
20
+ }
21
+ function patchOperations(input: unknown): NoteOperation[] {
22
+ if (typeof input !== "string") {
23
+ return [];
24
+ }
25
+ const operations: NoteOperation[] = [];
26
+ for (const match of input.matchAll(
27
+ /^\*\*\* (Update File|Add File|Delete File|Move to): ([^\r\n]+)$/gmu,
28
+ )) {
29
+ const path = match[2];
30
+ if (path === undefined) {
31
+ continue;
32
+ }
33
+ if (match[1] === "Move to") {
34
+ const previous = operations.pop();
35
+ if (previous !== undefined) {
36
+ operations.push({ ...previous, action: "delete" });
37
+ }
38
+ }
39
+ let action: NoteAction = "create";
40
+ if (match[1] === "Update File") {
41
+ action = "edit";
42
+ } else if (match[1] === "Delete File") {
43
+ action = "delete";
44
+ }
45
+ operations.push({ path, action });
46
+ }
47
+ return operations;
48
+ }
49
+ function batchOperations(tool: string, files: unknown): NoteOperation[] {
50
+ return (unknownArray(files) ?? []).flatMap<NoteOperation>((file) => {
51
+ if (!isRecord(file) || typeof file.path !== "string") {
52
+ return [];
53
+ }
54
+ if (tool === "replace_text") {
55
+ return [{ path: file.path, action: "edit" }];
56
+ }
57
+ if (file.mode === "create") {
58
+ return [{ path: file.path, action: file.content === "" ? "create-empty" : "create" }];
59
+ }
60
+ return [{ path: file.path, action: file.content === "" ? "clear" : "write" }];
61
+ });
62
+ }
63
+ function notesAction(args: Readonly<Record<string, unknown>>): NoteAction | undefined {
64
+ switch (args.op) {
65
+ case "write":
66
+ return args.content === "" ? "clear" : "write";
67
+ case "append":
68
+ case "read":
69
+ return args.op;
70
+ default:
71
+ return;
72
+ }
73
+ }
74
+ function singleOperation(tool: string, args: Readonly<Record<string, unknown>>): NoteOperation[] {
75
+ const path = optionalString(args.path);
76
+ if (path === undefined) {
77
+ return [];
78
+ }
79
+ switch (tool) {
80
+ case "notes": {
81
+ const action = notesAction(args);
82
+ return action === undefined
83
+ ? []
84
+ : [{ path, action, scope: optionalString(args.scope) }];
85
+ }
86
+ case "read":
87
+ case "edit":
88
+ return [{ path, action: tool }];
89
+ case "write":
90
+ return [{ path, action: args.content === "" ? "clear" : "write" }];
91
+ default:
92
+ return [];
93
+ }
94
+ }
95
+ export function noteOperations(tool: string, input: unknown): NoteOperation[] {
96
+ const args = isRecord(input) ? input : {};
97
+ if (args.preview === true || tool === "preview_patch") {
98
+ return [];
99
+ }
100
+ switch (tool) {
101
+ case "apply_patch":
102
+ return patchOperations(typeof input === "string" ? input : args.input);
103
+ case "write_files":
104
+ case "replace_text":
105
+ return batchOperations(tool, args.files);
106
+ default:
107
+ return singleOperation(tool, args);
108
+ }
109
+ }
110
+ export function noteOperationKind(
111
+ action: NoteAction,
112
+ ): "read" | "full-write" | "partial-write" | "clear" {
113
+ switch (action) {
114
+ case "read":
115
+ return "read";
116
+ case "write":
117
+ case "create":
118
+ return "full-write";
119
+ case "edit":
120
+ case "append":
121
+ return "partial-write";
122
+ case "clear":
123
+ case "create-empty":
124
+ case "delete":
125
+ return "clear";
126
+ }
127
+ }
128
+ function committedAction(
129
+ file: unknown,
130
+ operation: NoteOperation | undefined,
131
+ path: string,
132
+ ): NoteAction {
133
+ if (!isRecord(file)) {
134
+ return operation?.action ?? "edit";
135
+ }
136
+ // modifiedFiles is path-qualified; a partly committed move can still have status "failed".
137
+ if (file.operation === "delete" || (file.operation === "move" && file.path === path)) {
138
+ return "delete";
139
+ }
140
+ if (file.bytesAfter === 0) {
141
+ return "clear";
142
+ }
143
+ return operation?.action ?? "edit";
144
+ }
145
+ function committedOperations(
146
+ details: Readonly<Record<string, unknown>>,
147
+ operations: readonly NoteOperation[],
148
+ ): NoteOperation[] | undefined {
149
+ const modified = unknownArray(details.modifiedFiles);
150
+ if (modified === undefined) {
151
+ return;
152
+ }
153
+ const files = unknownArray(details.files) ?? [];
154
+ return modified.flatMap((path) => {
155
+ if (typeof path !== "string") {
156
+ return [];
157
+ }
158
+ const file = files.find(
159
+ (value) => isRecord(value) && (value.path === path || value.moveTo === path),
160
+ );
161
+ const action = committedAction(
162
+ file,
163
+ operations.find((operation) => operation.path === path),
164
+ path,
165
+ );
166
+ return [{ path, action }];
167
+ });
168
+ }
169
+ /** pi-apply-edits reports committed paths even when a later publication failed. */
170
+ export function verifiedNoteOperations(
171
+ tool: string,
172
+ input: unknown,
173
+ result: {
174
+ readonly isError: boolean;
175
+ readonly details: unknown;
176
+ },
177
+ ): NoteOperation[] {
178
+ const operations = noteOperations(tool, input);
179
+ const details = isRecord(result.details) ? result.details : undefined;
180
+ if (details?.preview === true) {
181
+ return [];
182
+ }
183
+ if (details !== undefined && ["replace_text", "write_files", "apply_patch"].includes(tool)) {
184
+ const committed = committedOperations(details, operations);
185
+ if (committed !== undefined) {
186
+ return committed;
187
+ }
188
+ }
189
+ return result.isError ? [] : operations;
190
+ }