@akira-tl/forgerelay 0.5.4 → 0.5.6

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,216 @@
1
+ import { resolve } from "node:path";
2
+ import { openAiConversationScopeId } from "../../request-meta.js";
3
+ import { BatchScheduler } from "./scheduler.js";
4
+ export class BatchExecutor {
5
+ dependencies;
6
+ scheduler;
7
+ shellSurface;
8
+ constructor(dependencies) {
9
+ this.dependencies = dependencies;
10
+ this.scheduler = dependencies.scheduler ?? new BatchScheduler();
11
+ this.shellSurface = dependencies.shellSurface ?? "bash";
12
+ }
13
+ async run(workspaceId, input, context) {
14
+ const workspace = this.dependencies.workspaces.getWorkspace(workspaceId);
15
+ let response;
16
+ await this.dependencies.lifecycle.run({
17
+ tool: "batch",
18
+ workspace: workspaceSnapshot(workspace),
19
+ conversationScopeId: openAiConversationScopeId(context.requestMeta),
20
+ request: {
21
+ workspaceId,
22
+ concurrency: input.concurrency ?? Math.min(input.tasks.length, 10),
23
+ tasks: input.tasks.map((task) => ({ id: task.id, operation: task.operation })),
24
+ },
25
+ operation: async (parentContext) => {
26
+ const scheduled = input.tasks.map((task) => this.scheduledTask(workspace, task, context, parentContext));
27
+ const results = await this.scheduler.run(scheduled, {
28
+ concurrency: input.concurrency,
29
+ signal: context.signal,
30
+ });
31
+ const children = results.map((result, index) => {
32
+ const task = input.tasks[index];
33
+ if (result.status === "error") {
34
+ return {
35
+ id: task.id,
36
+ operation: task.operation,
37
+ status: "error",
38
+ error: result.error,
39
+ };
40
+ }
41
+ const childResult = sanitizeChildResult(result.value.response);
42
+ return {
43
+ id: task.id,
44
+ operation: task.operation,
45
+ status: result.value.failed ? "error" : "done",
46
+ result: childResult,
47
+ ...(result.value.failed
48
+ ? { error: childFailureMessage(result.value.response) }
49
+ : {}),
50
+ };
51
+ });
52
+ const failed = children.filter((child) => child.status === "error").length;
53
+ response = {
54
+ status: failed > 0 ? "partial" : "done",
55
+ tasks: children.length,
56
+ completed: children.length - failed,
57
+ failed,
58
+ results: children,
59
+ };
60
+ return {
61
+ childCount: children.length,
62
+ completed: children.length - failed,
63
+ failed,
64
+ };
65
+ },
66
+ outcome: batchParentOutcome,
67
+ });
68
+ if (!response)
69
+ throw new Error("Batch execution completed without a response.");
70
+ return response;
71
+ }
72
+ scheduledTask(workspace, task, context, parent) {
73
+ return {
74
+ id: task.id,
75
+ claims: taskClaims(workspace.root, task),
76
+ ...(batchTaskIsExclusive(task, this.dependencies.capabilityBatchPolicy) ? { exclusive: true } : {}),
77
+ run: async (signal) => {
78
+ const response = await this.runCoreTask(workspace.id, task, {
79
+ ...context,
80
+ signal,
81
+ parentActivityId: parent.activityId,
82
+ turnId: parent.turnId,
83
+ });
84
+ return {
85
+ response,
86
+ failed: this.dependencies.resultIsError(response),
87
+ };
88
+ },
89
+ };
90
+ }
91
+ runCoreTask(workspaceId, task, context) {
92
+ switch (task.operation) {
93
+ case "read":
94
+ return this.dependencies.coreOperations.read({
95
+ workspaceId,
96
+ path: task.path,
97
+ offset: task.offset,
98
+ limit: task.limit,
99
+ }, context);
100
+ case "write":
101
+ return this.dependencies.coreOperations.write({
102
+ workspaceId,
103
+ path: task.path,
104
+ content: task.content,
105
+ }, context);
106
+ case "edit":
107
+ return this.dependencies.coreOperations.edit({
108
+ workspaceId,
109
+ path: task.path,
110
+ edits: task.edits,
111
+ }, context);
112
+ case "rename":
113
+ return this.dependencies.coreOperations.rename({
114
+ workspaceId,
115
+ path: task.path,
116
+ newPath: task.newPath,
117
+ }, context);
118
+ case "delete":
119
+ return this.dependencies.coreOperations.delete({
120
+ workspaceId,
121
+ path: task.path,
122
+ recursive: task.recursive,
123
+ }, context);
124
+ case "capability.run":
125
+ return this.dependencies.coreOperations.capabilityRun({
126
+ workspaceId,
127
+ name: task.name,
128
+ arguments: task.arguments,
129
+ }, { ...context, batch: true });
130
+ case "bash.run":
131
+ return this.dependencies.coreOperations.shellRun({
132
+ workspaceId,
133
+ command: task.command,
134
+ surface: this.shellSurface,
135
+ tty: task.tty,
136
+ columns: task.columns,
137
+ rows: task.rows,
138
+ workingDirectory: task.workingDirectory,
139
+ yieldTimeMs: task.yieldTimeMs,
140
+ timeoutMs: task.timeoutMs,
141
+ maxOutputTokens: task.maxOutputTokens,
142
+ }, context);
143
+ }
144
+ }
145
+ }
146
+ export function batchTaskIsExclusive(task, capabilityBatchPolicy) {
147
+ if (task.operation === "bash.run")
148
+ return true;
149
+ if (task.operation !== "capability.run")
150
+ return false;
151
+ return capabilityBatchPolicy?.(task.name) === "serial";
152
+ }
153
+ function batchParentOutcome(summary) {
154
+ return summary.failed > 0
155
+ ? { type: "failed", error: `${summary.failed} of ${summary.childCount} Batch tasks failed.` }
156
+ : { type: "succeeded" };
157
+ }
158
+ function taskClaims(root, task) {
159
+ switch (task.operation) {
160
+ case "read":
161
+ return [{ key: batchPathKey(root, task.path), mode: "read" }];
162
+ case "write":
163
+ case "edit":
164
+ case "delete":
165
+ return [{ key: batchPathKey(root, task.path), mode: "write" }];
166
+ case "rename":
167
+ return [
168
+ { key: batchPathKey(root, task.path), mode: "write" },
169
+ { key: batchPathKey(root, task.newPath), mode: "write" },
170
+ ];
171
+ case "capability.run":
172
+ case "bash.run":
173
+ return [];
174
+ }
175
+ }
176
+ function batchPathKey(root, path) {
177
+ const resolved = resolve(root, path);
178
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
179
+ }
180
+ function sanitizeChildResult(result) {
181
+ if (typeof result !== "object" || result === null) {
182
+ return { content: [{ type: "text", text: String(result ?? "") }] };
183
+ }
184
+ const record = result;
185
+ const content = Array.isArray(record.content) ? record.content : [];
186
+ const structuredContent = typeof record.structuredContent === "object" &&
187
+ record.structuredContent !== null &&
188
+ !Array.isArray(record.structuredContent)
189
+ ? record.structuredContent
190
+ : undefined;
191
+ return {
192
+ content,
193
+ ...(structuredContent ? { structuredContent } : {}),
194
+ ...(record.isError === true ? { isError: true } : {}),
195
+ };
196
+ }
197
+ function childFailureMessage(result) {
198
+ const sanitized = sanitizeChildResult(result);
199
+ const text = sanitized.content.flatMap((entry) => {
200
+ if (typeof entry !== "object" || entry === null)
201
+ return [];
202
+ const value = entry.text;
203
+ return typeof value === "string" ? [value] : [];
204
+ }).join("\n").trim();
205
+ return text || "Batch child returned an error result.";
206
+ }
207
+ function workspaceSnapshot(workspace) {
208
+ return {
209
+ id: workspace.id,
210
+ root: workspace.root,
211
+ mode: workspace.mode,
212
+ ...(workspace.sourceRoot ? { sourceRoot: workspace.sourceRoot } : {}),
213
+ ...(workspace.worktree?.branch ? { branch: workspace.worktree.branch } : {}),
214
+ ...(workspace.worktree?.targetBranch ? { targetBranch: workspace.worktree.targetBranch } : {}),
215
+ };
216
+ }
@@ -0,0 +1,108 @@
1
+ import { relative, sep } from "node:path";
2
+ export class BatchScheduler {
3
+ async run(tasks, options = {}) {
4
+ validateTasks(tasks);
5
+ const concurrency = resolveConcurrency(tasks.length, options.concurrency);
6
+ const signal = options.signal ?? new AbortController().signal;
7
+ signal.throwIfAborted();
8
+ const pending = new Set(tasks.map((_task, index) => index));
9
+ const active = new Map();
10
+ const results = new Array(tasks.length);
11
+ while (pending.size > 0 || active.size > 0) {
12
+ signal.throwIfAborted();
13
+ this.startRunnable(tasks, pending, active, concurrency, signal);
14
+ if (active.size === 0) {
15
+ throw new Error("Batch scheduler could not make progress.");
16
+ }
17
+ const settled = await Promise.race([...active.values()].map((entry) => entry.promise));
18
+ active.delete(settled.index);
19
+ if (signal.aborted) {
20
+ await Promise.allSettled([...active.values()].map((entry) => entry.promise));
21
+ throw abortReason(signal);
22
+ }
23
+ const task = tasks[settled.index];
24
+ results[settled.index] = settled.status === "done"
25
+ ? { id: task.id, status: "done", value: settled.value }
26
+ : { id: task.id, status: "error", error: errorMessage(settled.error) };
27
+ }
28
+ return results.map((result, index) => {
29
+ if (!result)
30
+ throw new Error(`Batch task ${tasks[index]?.id ?? index} did not produce a result.`);
31
+ return result;
32
+ });
33
+ }
34
+ startRunnable(tasks, pending, active, concurrency, signal) {
35
+ while (active.size < concurrency) {
36
+ signal.throwIfAborted();
37
+ const index = [...pending].find((candidate) => canStart(tasks[candidate], active));
38
+ if (index === undefined)
39
+ return;
40
+ const task = tasks[index];
41
+ pending.delete(index);
42
+ const promise = Promise.resolve()
43
+ .then(() => task.run(signal))
44
+ .then((value) => ({ index, status: "done", value }))
45
+ .catch((error) => ({ index, status: "error", error }));
46
+ active.set(index, { task, promise });
47
+ }
48
+ }
49
+ }
50
+ function validateTasks(tasks) {
51
+ if (tasks.length < 1 || tasks.length > 100) {
52
+ throw new Error(`Batch task count must be between 1 and 100; received ${tasks.length}.`);
53
+ }
54
+ const ids = new Set();
55
+ for (const task of tasks) {
56
+ if (!task.id.trim())
57
+ throw new Error("Batch task id must not be empty.");
58
+ if (ids.has(task.id))
59
+ throw new Error(`Batch task ids must be unique; duplicate id: ${task.id}.`);
60
+ ids.add(task.id);
61
+ }
62
+ }
63
+ function resolveConcurrency(taskCount, requested) {
64
+ const concurrency = requested ?? Math.min(taskCount, 10);
65
+ if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > 10) {
66
+ throw new Error(`Batch concurrency must be an integer between 1 and 10; received ${concurrency}.`);
67
+ }
68
+ return concurrency;
69
+ }
70
+ function canStart(task, active) {
71
+ if (task.exclusive)
72
+ return active.size === 0;
73
+ for (const entry of active.values()) {
74
+ if (entry.task.exclusive)
75
+ return false;
76
+ if (claimsConflict(task.claims, entry.task.claims))
77
+ return false;
78
+ }
79
+ return true;
80
+ }
81
+ function claimsConflict(left, right) {
82
+ for (const first of left) {
83
+ for (const second of right) {
84
+ if (first.mode === "read" && second.mode === "read")
85
+ continue;
86
+ if (pathsOverlap(first.key, second.key))
87
+ return true;
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+ function pathsOverlap(left, right) {
93
+ if (left === right)
94
+ return true;
95
+ return isInside(left, right) || isInside(right, left);
96
+ }
97
+ function isInside(path, root) {
98
+ const rel = relative(root, path);
99
+ return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`);
100
+ }
101
+ function abortReason(signal) {
102
+ return signal.reason instanceof Error
103
+ ? signal.reason
104
+ : new Error(signal.reason === undefined ? "Batch execution was cancelled." : String(signal.reason));
105
+ }
106
+ function errorMessage(error) {
107
+ return error instanceof Error ? error.message : String(error);
108
+ }
@@ -0,0 +1,76 @@
1
+ import { z } from "zod";
2
+ const taskId = z.string().min(1).max(128);
3
+ const path = z.string().min(1);
4
+ const capabilityName = z.string().regex(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/);
5
+ const editEntry = z.object({
6
+ oldText: z.string(),
7
+ newText: z.string(),
8
+ }).strict();
9
+ export const batchCoreTaskSchema = z.discriminatedUnion("operation", [
10
+ z.object({
11
+ id: taskId,
12
+ operation: z.literal("read"),
13
+ path,
14
+ offset: z.number().int().positive().optional(),
15
+ limit: z.number().int().positive().optional(),
16
+ }).strict(),
17
+ z.object({
18
+ id: taskId,
19
+ operation: z.literal("write"),
20
+ path,
21
+ content: z.string(),
22
+ }).strict(),
23
+ z.object({
24
+ id: taskId,
25
+ operation: z.literal("edit"),
26
+ path,
27
+ edits: z.array(editEntry).min(1),
28
+ }).strict(),
29
+ z.object({
30
+ id: taskId,
31
+ operation: z.literal("rename"),
32
+ path,
33
+ newPath: path,
34
+ }).strict(),
35
+ z.object({
36
+ id: taskId,
37
+ operation: z.literal("delete"),
38
+ path,
39
+ recursive: z.boolean().optional(),
40
+ }).strict(),
41
+ z.object({
42
+ id: taskId,
43
+ operation: z.literal("capability.run"),
44
+ name: capabilityName,
45
+ arguments: z.record(z.string(), z.unknown()).optional(),
46
+ }).strict(),
47
+ z.object({
48
+ id: taskId,
49
+ operation: z.literal("bash.run"),
50
+ command: z.string().min(1),
51
+ tty: z.boolean().optional(),
52
+ columns: z.number().int().min(1).max(1_000).optional(),
53
+ rows: z.number().int().min(1).max(1_000).optional(),
54
+ workingDirectory: z.string().optional(),
55
+ yieldTimeMs: z.number().int().min(0).max(300_000).optional(),
56
+ timeoutMs: z.number().int().min(1).max(86_400_000).optional(),
57
+ maxOutputTokens: z.number().int().positive().max(100_000).optional(),
58
+ }).strict(),
59
+ ]);
60
+ export const batchExecuteInputSchema = z.object({
61
+ tasks: z.array(batchCoreTaskSchema).min(1).max(100),
62
+ concurrency: z.number().int().min(1).max(10).optional(),
63
+ }).strict().superRefine((input, context) => {
64
+ const ids = new Set();
65
+ for (const task of input.tasks) {
66
+ if (ids.has(task.id)) {
67
+ context.addIssue({
68
+ code: "custom",
69
+ path: ["tasks"],
70
+ message: `Batch task ids must be unique; duplicate id: ${task.id}.`,
71
+ });
72
+ return;
73
+ }
74
+ ids.add(task.id);
75
+ }
76
+ });
@@ -0,0 +1,41 @@
1
+ export async function executeSequentialBulkMutation(options) {
2
+ const items = [];
3
+ for (let index = 0; index < options.paths.length; index += 1) {
4
+ options.signal?.throwIfAborted();
5
+ const path = options.paths[index];
6
+ try {
7
+ const response = await options.run(path);
8
+ const error = options.isError(response);
9
+ items.push({
10
+ path,
11
+ status: error ? "error" : "done",
12
+ result: options.resultText(response),
13
+ response,
14
+ });
15
+ if (error) {
16
+ appendUnexecuted(items, options.paths, index + 1);
17
+ break;
18
+ }
19
+ }
20
+ catch (error) {
21
+ if (options.signal?.aborted)
22
+ throw error;
23
+ items.push({
24
+ path,
25
+ status: "error",
26
+ result: error instanceof Error ? error.message : String(error),
27
+ });
28
+ appendUnexecuted(items, options.paths, index + 1);
29
+ break;
30
+ }
31
+ }
32
+ const completed = items.filter((item) => item.status === "done").length;
33
+ const failed = items.filter((item) => item.status === "error").length;
34
+ const unexecuted = items.filter((item) => item.status === "unexecuted").length;
35
+ return { items, completed, failed, unexecuted };
36
+ }
37
+ function appendUnexecuted(items, paths, start) {
38
+ for (let index = start; index < paths.length; index += 1) {
39
+ items.push({ path: paths[index], status: "unexecuted" });
40
+ }
41
+ }
@@ -0,0 +1,28 @@
1
+ export async function executeBulkRead(options) {
2
+ const children = await Promise.all(options.paths.map(async (path) => {
3
+ try {
4
+ const response = await options.run(path);
5
+ return {
6
+ path,
7
+ status: options.isError(response) ? "error" : "done",
8
+ result: options.resultText(response),
9
+ response,
10
+ };
11
+ }
12
+ catch (error) {
13
+ if (options.signal?.aborted)
14
+ throw error;
15
+ return {
16
+ path,
17
+ status: "error",
18
+ result: error instanceof Error ? error.message : String(error),
19
+ };
20
+ }
21
+ }));
22
+ const failed = children.filter((child) => child.status === "error").length;
23
+ return {
24
+ children,
25
+ succeeded: children.length - failed,
26
+ failed,
27
+ };
28
+ }
@@ -0,0 +1,30 @@
1
+ export class CoreOperationExecutor {
2
+ handlers;
3
+ constructor(handlers) {
4
+ this.handlers = handlers;
5
+ }
6
+ read(input, context) {
7
+ return this.handlers.read(input, context);
8
+ }
9
+ write(input, context) {
10
+ return this.handlers.write(input, context);
11
+ }
12
+ edit(input, context) {
13
+ return this.handlers.edit(input, context);
14
+ }
15
+ rename(input, context) {
16
+ return this.handlers.rename(input, context);
17
+ }
18
+ delete(input, context) {
19
+ return this.handlers.delete(input, context);
20
+ }
21
+ shellRun(input, context) {
22
+ return this.handlers.shellRun(input, context);
23
+ }
24
+ capabilityRun(input, context) {
25
+ return this.handlers.capabilityRun(input, context);
26
+ }
27
+ }
28
+ export function createCoreOperationExecutor(handlers) {
29
+ return new CoreOperationExecutor(handlers);
30
+ }
@@ -0,0 +1,134 @@
1
+ import { preflightDeletePaths } from "../file-mutations.js";
2
+ import { preflightEditFiles } from "../pi-tools.js";
3
+ import { openAiConversationScopeId } from "../request-meta.js";
4
+ import { executeSequentialBulkMutation } from "./bulk-mutation.js";
5
+ export class NativeBulkMutationExecutor {
6
+ dependencies;
7
+ constructor(dependencies) {
8
+ this.dependencies = dependencies;
9
+ }
10
+ async edit(input, context) {
11
+ const { workspaceId, paths, edits } = input;
12
+ const workspace = this.dependencies.workspaces.getWorkspace(workspaceId);
13
+ let response;
14
+ await this.runParent(workspace, context, "edit", { workspaceId, paths, edits }, async (parentContext) => {
15
+ await this.dependencies.preflightInstructions(workspace, paths);
16
+ await preflightEditFiles(paths, edits, {
17
+ cwd: workspace.root,
18
+ root: workspace.root,
19
+ fileRoots: this.dependencies.workspaces.fileToolRoots(workspace),
20
+ }, context.signal);
21
+ const execution = await executeSequentialBulkMutation({
22
+ paths,
23
+ signal: context.signal,
24
+ run: (path) => this.dependencies.coreOperations.edit({ workspaceId, path, edits }, childContext(context, parentContext)),
25
+ isError: this.dependencies.resultIsError,
26
+ resultText: this.dependencies.resultText,
27
+ });
28
+ response = buildResponse(paths.length, execution, "applied", this.dependencies.resultContent);
29
+ return summary(paths.length, execution.completed, execution.failed, execution.unexecuted);
30
+ }, mutationParentOutcome("Edit"));
31
+ if (!response)
32
+ throw new Error("Bulk Edit completed without a response.");
33
+ return response;
34
+ }
35
+ async delete(input, context) {
36
+ const { workspaceId, paths, recursive } = input;
37
+ const workspace = this.dependencies.workspaces.getWorkspace(workspaceId);
38
+ let response;
39
+ await this.runParent(workspace, context, "delete", { workspaceId, paths, recursive: recursive ?? false }, async (parentContext) => {
40
+ await this.dependencies.preflightInstructions(workspace, paths);
41
+ await preflightDeletePaths(paths.map((path) => ({ path, recursive })), {
42
+ cwd: workspace.root,
43
+ allowedRoots: this.dependencies.workspaces.fileToolRoots(workspace),
44
+ });
45
+ const execution = await executeSequentialBulkMutation({
46
+ paths,
47
+ signal: context.signal,
48
+ run: (path) => this.dependencies.coreOperations.delete({ workspaceId, path, recursive }, childContext(context, parentContext)),
49
+ isError: this.dependencies.resultIsError,
50
+ resultText: this.dependencies.resultText,
51
+ });
52
+ response = buildResponse(paths.length, execution, "deleted", this.dependencies.resultContent);
53
+ return summary(paths.length, execution.completed, execution.failed, execution.unexecuted);
54
+ }, mutationParentOutcome("Delete"));
55
+ if (!response)
56
+ throw new Error("Bulk Delete completed without a response.");
57
+ return response;
58
+ }
59
+ runParent(workspace, context, tool, request, operation, outcome) {
60
+ return this.dependencies.lifecycle.run({
61
+ tool,
62
+ workspace: workspaceSnapshot(workspace),
63
+ conversationScopeId: openAiConversationScopeId(context.requestMeta),
64
+ request,
65
+ operation,
66
+ outcome,
67
+ });
68
+ }
69
+ }
70
+ function childContext(context, parent) {
71
+ return {
72
+ ...context,
73
+ parentActivityId: parent.activityId,
74
+ turnId: parent.turnId,
75
+ };
76
+ }
77
+ function workspaceSnapshot(workspace) {
78
+ return {
79
+ id: workspace.id,
80
+ root: workspace.root,
81
+ mode: workspace.mode,
82
+ ...(workspace.sourceRoot ? { sourceRoot: workspace.sourceRoot } : {}),
83
+ ...(workspace.worktree?.branch ? { branch: workspace.worktree.branch } : {}),
84
+ ...(workspace.worktree?.targetBranch ? { targetBranch: workspace.worktree.targetBranch } : {}),
85
+ };
86
+ }
87
+ function summary(requested, completed, failed, unexecuted) {
88
+ return {
89
+ childCount: completed + failed,
90
+ requested,
91
+ completed,
92
+ failed,
93
+ unexecuted,
94
+ };
95
+ }
96
+ function mutationParentOutcome(tool) {
97
+ return (result) => result.failed > 0
98
+ ? {
99
+ type: "failed",
100
+ error: `${result.failed} child ${tool} failed; ${result.unexecuted} target(s) were not executed.`,
101
+ }
102
+ : { type: "succeeded" };
103
+ }
104
+ function buildResponse(requested, execution, successStatus, resultContent) {
105
+ const content = execution.items.flatMap((item) => [
106
+ { type: "text", text: `--- ${item.path} · ${item.status} ---` },
107
+ ...(item.response
108
+ ? resultContent(item.response)
109
+ : item.result
110
+ ? [{ type: "text", text: item.result }]
111
+ : []),
112
+ ]);
113
+ const result = content
114
+ .filter((entry) => entry.type === "text")
115
+ .map((entry) => entry.text)
116
+ .join("\n");
117
+ return {
118
+ content,
119
+ structuredContent: {
120
+ result,
121
+ status: execution.failed > 0 ? "partial" : successStatus,
122
+ results: execution.items.map(({ path, status, result: itemResult }) => ({
123
+ path,
124
+ status,
125
+ ...(itemResult !== undefined ? { result: itemResult } : {}),
126
+ })),
127
+ ...(successStatus === "applied" ? { files: requested } : { paths: requested }),
128
+ completed: execution.completed,
129
+ failed: execution.failed,
130
+ unexecuted: execution.unexecuted,
131
+ },
132
+ ...(execution.failed > 0 ? { isError: true } : {}),
133
+ };
134
+ }
package/dist/pi-tools.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { constants } from "node:fs";
2
+ import { access as fsAccess, readFile as fsReadFile, realpath as fsRealpath } from "node:fs/promises";
1
3
  import { createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool, } from "@earendil-works/pi-coding-agent";
2
4
  import { resolveCanonicalAllowedPath } from "./roots.js";
3
5
  function toMcpContent(result) {
@@ -53,6 +55,40 @@ export async function editFileTool(input, context) {
53
55
  edits: input.edits,
54
56
  }, context);
55
57
  }
58
+ export async function preflightEditFiles(paths, edits, context, signal) {
59
+ const seen = new Map();
60
+ for (const path of paths) {
61
+ const response = await preflightEditFileTool({ path, edits }, context, signal);
62
+ if (response.isError) {
63
+ const message = response.content
64
+ .filter((entry) => entry.type === "text")
65
+ .map((entry) => entry.text)
66
+ .join("\n");
67
+ throw new Error(`Bulk Edit preflight failed for ${path}: ${message}`);
68
+ }
69
+ const absolute = await resolveCanonicalAllowedPath(path, context.cwd, context.fileRoots ?? [context.root]);
70
+ const canonical = await fsRealpath(absolute);
71
+ const previous = seen.get(canonical);
72
+ if (previous) {
73
+ throw new Error(`Bulk Edit targets overlap: ${previous} and ${path} resolve to the same file.`);
74
+ }
75
+ seen.set(canonical, path);
76
+ }
77
+ }
78
+ export async function preflightEditFileTool(input, context, signal) {
79
+ signal?.throwIfAborted();
80
+ const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.fileRoots ?? [context.root]);
81
+ const tool = createEditTool(context.cwd, {
82
+ operations: {
83
+ readFile: fsReadFile,
84
+ writeFile: async () => { },
85
+ access: (absolutePath) => fsAccess(absolutePath, constants.R_OK | constants.W_OK),
86
+ },
87
+ });
88
+ const response = await runTool((params) => tool.execute("edit_file", params, signal), { path, edits: input.edits }, context);
89
+ signal?.throwIfAborted();
90
+ return response;
91
+ }
56
92
  export async function grepFilesTool(input, context) {
57
93
  const path = input.path
58
94
  ? await resolveCanonicalAllowedPath(input.path, context.cwd, context.fileRoots ?? [context.root])