@akira-tl/forgerelay 0.5.4 → 0.5.5

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.5] - 2026-08-15
8
+
9
+ ### Added
10
+
11
+ - Added native multi-target `read`, `edit`, and `delete` operations so Agents can read several files or apply one validated edit/delete intent across multiple paths in a single interaction; bulk mutations preflight every target before the first filesystem change and report mutation-phase partial failures without claiming transactional rollback.
12
+ - Added the `batch.execute` capability for 1–100 heterogeneous Read/Write/Edit/Rename/Delete/Bash/Capability tasks with caller-controlled concurrency from 1–10, stable input-order results, continue-on-error execution, conflict-aware scheduling, conservative Bash/serial-Capability exclusivity, and Host cancellation that never invents Activities for queued work that did not start.
13
+ - Added durable parent/child Activity relationships and aggregate summaries for native bulk and Batch execution, while preserving lazy child detail, compact Bash responses plus stable `outputId`, and restart-safe local audit/query behavior.
14
+
15
+ ### Changed
16
+
17
+ - Capability definitions now declare and advertise an explicit Batch policy (`parallel`, `serial`, or `unsupported`); `hooks.check` and `code.intelligence` are parallel, `review.changes` is serial, while Host-native artifact download and recursive `batch.execute` use are unsupported inside a Batch.
18
+ - Core work operations now share one internal execution seam so single MCP calls and Batch children use the same path validation, Hooks, Activity lifecycle, logging, cancellation, and result semantics instead of duplicating tool handlers.
19
+
7
20
  ## [0.5.4] - 2026-08-15
8
21
 
9
22
  ### Fixed
@@ -0,0 +1,18 @@
1
+ # Batch execution
2
+
3
+ Use `capability` with `name="batch.execute"` when several independent ForgeRelay core operations can be completed in one Agent interaction.
4
+
5
+ ## Contract
6
+
7
+ - Every batch belongs to one already-open `workspaceId`.
8
+ - Supply 1–100 tasks. Every task requires a unique stable `id`.
9
+ - Supported operations are `read`, `write`, `edit`, `rename`, `delete`, `bash.run`, and `capability.run`.
10
+ - Core tasks are single-target operations. Use native `read(paths)`, `edit(paths)`, or `delete(paths)` for one homogeneous operation over multiple targets instead of nesting bulk groups inside a batch.
11
+ - `concurrency` may be 1–10. When omitted, ForgeRelay uses `min(task count, 10)`.
12
+ - Independent tasks may run concurrently. Conflicting filesystem mutations are serialized automatically. `bash.run` is treated conservatively as exclusive work because a shell command may modify arbitrary workspace state.
13
+ - Capability definitions explicitly advertise a batch policy: `parallel`, `serial`, or `unsupported`. Parallel capabilities may run concurrently; serial capabilities run exclusively in v0.5.5; unsupported capabilities return a task-level error while preserving the failed child Activity.
14
+ - One task failure does not stop independent tasks. Results are returned in the same order as the input task list.
15
+ - Host cancellation stops launching queued tasks and is propagated to already-running tasks.
16
+ - Batch execution does not support nested batches, Workspace lifecycle calls, Activity query/control calls, or Bash process-control actions.
17
+
18
+ Each actual task retains its normal ForgeRelay Hooks, Activity audit, validation, and result semantics. The Batch parent is an aggregate Activity and does not execute Tool Hooks itself.
@@ -14,6 +14,15 @@ export class ActivityAuditStore {
14
14
  if (existing.length > 0) {
15
15
  throw new Error(`Activity ${input.activityId} already has audit events.`);
16
16
  }
17
+ if (input.parentActivityId) {
18
+ const parent = this.getActivity(input.parentActivityId);
19
+ if (!parent) {
20
+ throw new Error(`Unknown parent Activity: ${input.parentActivityId}.`);
21
+ }
22
+ if (parent.turnId !== input.turnId) {
23
+ throw new Error(`Parent Activity ${input.parentActivityId} belongs to Host Turn ${parent.turnId}, not ${input.turnId}.`);
24
+ }
25
+ }
17
26
  }
18
27
  else if (existing.length === 0 || existing[0]?.event_type !== "started") {
19
28
  throw new Error(`Activity ${input.activityId} must start before recording ${input.type}.`);
@@ -28,6 +37,7 @@ export class ActivityAuditStore {
28
37
  sequence,
29
38
  event_type,
30
39
  turn_id,
40
+ parent_activity_id,
31
41
  conversation_scope_id,
32
42
  tool,
33
43
  workspace_id,
@@ -40,7 +50,7 @@ export class ActivityAuditStore {
40
50
  result_json,
41
51
  error,
42
52
  created_at
43
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.id, row.activity_id, row.sequence, row.event_type, row.turn_id, row.conversation_scope_id, row.tool, row.workspace_id, row.workspace_root, row.workspace_mode, row.workspace_source_root, row.workspace_branch, row.workspace_target_branch, row.request_json, row.result_json, row.error, row.created_at);
53
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.id, row.activity_id, row.sequence, row.event_type, row.turn_id, row.parent_activity_id, row.conversation_scope_id, row.tool, row.workspace_id, row.workspace_root, row.workspace_mode, row.workspace_source_root, row.workspace_branch, row.workspace_target_branch, row.request_json, row.result_json, row.error, row.created_at);
44
54
  return rowToEvent(row);
45
55
  })();
46
56
  }
@@ -104,6 +114,7 @@ export class ActivityAuditStore {
104
114
  return {
105
115
  activityId: started.activityId,
106
116
  turnId: started.turnId,
117
+ ...(started.parentActivityId ? { parentActivityId: started.parentActivityId } : {}),
107
118
  ...(started.conversationScopeId ? { conversationScopeId: started.conversationScopeId } : {}),
108
119
  tool: started.tool,
109
120
  workspace: started.workspace,
@@ -132,6 +143,7 @@ function eventInputToRow(input, identity) {
132
143
  sequence: identity.sequence,
133
144
  event_type: input.type,
134
145
  turn_id: input.turnId,
146
+ parent_activity_id: input.parentActivityId ?? null,
135
147
  conversation_scope_id: input.conversationScopeId ?? null,
136
148
  tool: input.tool,
137
149
  workspace_id: input.workspace.id ?? null,
@@ -152,6 +164,7 @@ function eventInputToRow(input, identity) {
152
164
  sequence: identity.sequence,
153
165
  event_type: input.type,
154
166
  turn_id: null,
167
+ parent_activity_id: null,
155
168
  conversation_scope_id: null,
156
169
  tool: null,
157
170
  workspace_id: null,
@@ -182,6 +195,7 @@ function rowToEvent(row) {
182
195
  ...base,
183
196
  type: "started",
184
197
  turnId: row.turn_id,
198
+ ...(row.parent_activity_id ? { parentActivityId: row.parent_activity_id } : {}),
185
199
  ...(row.conversation_scope_id ? { conversationScopeId: row.conversation_scope_id } : {}),
186
200
  tool: row.tool,
187
201
  workspace: {
@@ -53,6 +53,7 @@ export class ActivityLifecycle {
53
53
  type: "started",
54
54
  activityId,
55
55
  turnId,
56
+ ...(options.parentActivityId ? { parentActivityId: options.parentActivityId } : {}),
56
57
  ...(options.conversationScopeId ? { conversationScopeId: options.conversationScopeId } : {}),
57
58
  tool: options.tool,
58
59
  workspace: options.workspace,
@@ -61,6 +62,7 @@ export class ActivityLifecycle {
61
62
  return {
62
63
  activityId,
63
64
  turnId,
65
+ ...(options.parentActivityId ? { parentActivityId: options.parentActivityId } : {}),
64
66
  ...(options.conversationScopeId ? { conversationScopeId: options.conversationScopeId } : {}),
65
67
  };
66
68
  }
@@ -17,7 +17,7 @@ export class ActivityQueryService {
17
17
  snapshot(turnId, knownRevision) {
18
18
  this.requireTurn(turnId);
19
19
  const revision = this.audit.turnRevision(turnId);
20
- const activities = this.audit.listActivitiesByTurn(turnId).map(toSummary);
20
+ const activities = this.summaries(turnId);
21
21
  const state = aggregateState(activities);
22
22
  const changed = knownRevision === undefined || knownRevision !== revision;
23
23
  return {
@@ -34,7 +34,8 @@ export class ActivityQueryService {
34
34
  if (!record || record.turnId !== turnId) {
35
35
  throw new Error(`Unknown Activity ${activityId} in Host Turn ${turnId}.`);
36
36
  }
37
- const activity = toSummary(record);
37
+ const activity = this.summaries(turnId)
38
+ .find((summary) => summary.activityId === activityId) ?? toSummary(record);
38
39
  if (!activity.detailAvailable) {
39
40
  throw new Error(`Activity ${activityId} is summary-complete and has no lazy detail.`);
40
41
  }
@@ -69,6 +70,30 @@ export class ActivityQueryService {
69
70
  ...(output.finishedAt !== undefined ? { finishedAt: output.finishedAt } : {}),
70
71
  };
71
72
  }
73
+ summaries(turnId) {
74
+ const records = this.audit.listActivitiesByTurn(turnId);
75
+ const summaries = records.map(toSummary);
76
+ const children = new Map();
77
+ for (const summary of summaries) {
78
+ if (!summary.parentActivityId)
79
+ continue;
80
+ const aggregate = children.get(summary.parentActivityId) ?? {
81
+ total: 0,
82
+ working: 0,
83
+ done: 0,
84
+ error: 0,
85
+ };
86
+ aggregate.total += 1;
87
+ aggregate[summary.status] += 1;
88
+ children.set(summary.parentActivityId, aggregate);
89
+ }
90
+ return summaries.map((summary) => {
91
+ const aggregate = children.get(summary.activityId);
92
+ return aggregate
93
+ ? { ...summary, detailAvailable: false, children: aggregate }
94
+ : summary;
95
+ });
96
+ }
72
97
  requireTurn(turnId) {
73
98
  if (!this.turns.get(turnId))
74
99
  throw new Error(`Unknown Host Turn: ${turnId}.`);
@@ -88,15 +113,18 @@ function toSummary(record) {
88
113
  ?? numberField(result, "wallTimeMs")
89
114
  ?? elapsedMs(record.startedAt, record.updatedAt, record.state);
90
115
  const bashLike = record.tool === "bash" || record.tool === "exec_command" || record.tool === "bash_result";
116
+ const bulkGroup = arrayField(request, "paths") !== undefined &&
117
+ (record.tool === "read" || record.tool === "edit" || record.tool === "delete");
91
118
  return {
92
119
  activityId: record.activityId,
120
+ ...(record.parentActivityId ? { parentActivityId: record.parentActivityId } : {}),
93
121
  tool: record.tool,
94
122
  kind: activityKind(record.tool),
95
123
  status: activityStatus(record.state),
96
124
  state: record.state,
97
125
  title: activityTitle(record.tool),
98
126
  target: activityTarget(record, request, result, structured),
99
- detailAvailable: record.tool !== "rename" && record.tool !== "delete",
127
+ detailAvailable: !bulkGroup && record.tool !== "rename" && record.tool !== "delete" && record.tool !== "batch",
100
128
  ...(record.workspace.id ? { workspaceId: record.workspace.id } : {}),
101
129
  ...(processId !== undefined ? { processId } : {}),
102
130
  ...(outputId !== undefined ? { outputId } : {}),
@@ -124,6 +152,8 @@ function activityKind(tool) {
124
152
  return "shell";
125
153
  if (tool === "capability")
126
154
  return "capability";
155
+ if (tool === "batch")
156
+ return "batch";
127
157
  return "tool";
128
158
  }
129
159
  function activityTitle(tool) {
@@ -138,12 +168,24 @@ function activityTitle(tool) {
138
168
  exec_command: "Command",
139
169
  bash_result: "Bash result",
140
170
  capability: "Capability",
171
+ batch: "Batch",
141
172
  };
142
173
  return titles[tool] ?? tool;
143
174
  }
144
175
  function activityTarget(record, request, result, structured) {
145
176
  if (record.tool === "bash" || record.tool === "exec_command")
146
177
  return "Shell command";
178
+ if (record.tool === "batch") {
179
+ const tasks = arrayField(request, "tasks");
180
+ return `${tasks?.length ?? 0} tasks`;
181
+ }
182
+ const paths = arrayField(request, "paths");
183
+ if (paths && paths.length > 0) {
184
+ if (record.tool === "read" || record.tool === "edit")
185
+ return `${paths.length} files`;
186
+ if (record.tool === "delete")
187
+ return `${paths.length} paths`;
188
+ }
147
189
  if (record.tool === "bash_result") {
148
190
  const processId = numberField(result, "processId") ?? numberField(request, "processId");
149
191
  const exitCode = numberField(result, "exitCode");
@@ -39,6 +39,12 @@ const CAPABILITY_GUIDE_DEFINITIONS = [
39
39
  description: "Read-only semantic code navigation backed by external Language servers.",
40
40
  whenToRead: "Read before using code.intelligence or configuring Language servers.",
41
41
  },
42
+ {
43
+ name: "batch-execution",
44
+ description: "One-call execution of multiple independent ForgeRelay core operations.",
45
+ whenToRead: "Read before using batch.execute for heterogeneous multi-operation work.",
46
+ enabled: (config) => config.toolMode !== "codex",
47
+ },
42
48
  ];
43
49
  function capabilityGuidesDir() {
44
50
  return fileURLToPath(new URL("../capabilities", import.meta.url));
@@ -82,6 +88,9 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
82
88
  "capability-guides.read",
83
89
  "code.intelligence",
84
90
  ];
91
+ if (config.toolMode !== "codex") {
92
+ capabilities.push("batch.execute");
93
+ }
85
94
  if (config.subagents) {
86
95
  capabilities.push("subagent.profiles");
87
96
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { MAX_CODE_INTELLIGENCE_RESULT_LIMIT, } from "./lsp/code-intelligence-types.js";
3
+ import { batchExecuteInputSchema, } from "./operations/batch/types.js";
3
4
  export class CapabilityError extends Error {
4
5
  code;
5
6
  constructor(code, message) {
@@ -25,6 +26,7 @@ export class CapabilityRegistry {
25
26
  name: definition.name,
26
27
  description: definition.description,
27
28
  available,
29
+ batchPolicy: definition.batchPolicy,
28
30
  ...(!available && unavailableReason ? { unavailableReason } : {}),
29
31
  guide: {
30
32
  name: definition.guideName,
@@ -65,6 +67,9 @@ export class CapabilityRegistry {
65
67
  if (!catalogEntry.available) {
66
68
  throw new CapabilityError("capability_unavailable", `Capability ${name} is unavailable${catalogEntry.unavailableReason ? `: ${catalogEntry.unavailableReason}` : "."}`);
67
69
  }
70
+ if (options.batch && definition.batchPolicy === "unsupported") {
71
+ throw new CapabilityError("capability_batch_unsupported", `Capability ${name} is not supported inside batch.execute.`);
72
+ }
68
73
  if (options.nativeFile !== undefined && !definition.nativeFileArgument) {
69
74
  throw new CapabilityError("invalid_arguments", `Capability ${name} does not accept a Host-native file value.`);
70
75
  }
@@ -87,6 +92,9 @@ export class CapabilityRegistry {
87
92
  throw new CapabilityError("execution_failed", `Capability ${name} failed: ${error instanceof Error ? error.message : String(error)}`);
88
93
  }
89
94
  }
95
+ batchPolicy(name) {
96
+ return this.definitions.get(name)?.batchPolicy;
97
+ }
90
98
  requireDefinition(name) {
91
99
  const definition = this.definitions.get(name);
92
100
  if (!definition) {
@@ -105,6 +113,7 @@ export class CapabilityRegistry {
105
113
  name: definition.name,
106
114
  description: definition.description,
107
115
  available,
116
+ batchPolicy: definition.batchPolicy,
108
117
  ...(!available && unavailableReason ? { unavailableReason } : {}),
109
118
  guide: {
110
119
  name: definition.guideName,
@@ -152,6 +161,7 @@ export function createCapabilityRegistry(dependencies) {
152
161
  description: "Validate the active ForgeRelay Hook configuration for this workspace.",
153
162
  guideName: "lifecycle-hooks",
154
163
  readGuideBeforeFirstUse: true,
164
+ batchPolicy: "parallel",
155
165
  inputSchema: hooksCheckInput,
156
166
  availability: () => ({ available: true }),
157
167
  run: async (_input, context) => ({
@@ -167,6 +177,7 @@ export function createCapabilityRegistry(dependencies) {
167
177
  description: "Review accumulated workspace changes from the Git-backed review checkpoint.",
168
178
  guideName: "artifacts-review",
169
179
  readGuideBeforeFirstUse: true,
180
+ batchPolicy: "serial",
170
181
  inputSchema: z.object({}).strict(),
171
182
  availability: () => ({
172
183
  available: dependencies.reviewChanges?.available ?? false,
@@ -181,6 +192,7 @@ export function createCapabilityRegistry(dependencies) {
181
192
  description: "Read semantic code information through an available Language server without changing the Workspace.",
182
193
  guideName: "code-intelligence",
183
194
  readGuideBeforeFirstUse: true,
195
+ batchPolicy: "parallel",
184
196
  inputSchema: codeIntelligenceInput,
185
197
  availability: () => ({
186
198
  available: dependencies.codeIntelligence?.available ?? false,
@@ -189,12 +201,28 @@ export function createCapabilityRegistry(dependencies) {
189
201
  run: async (input, context, options) => dependencies.codeIntelligence.run(input, context, options),
190
202
  }]
191
203
  : []),
204
+ ...(dependencies.batchExecute
205
+ ? [{
206
+ name: "batch.execute",
207
+ description: "Execute multiple independent ForgeRelay core operations in one Agent interaction.",
208
+ guideName: "batch-execution",
209
+ readGuideBeforeFirstUse: true,
210
+ batchPolicy: "unsupported",
211
+ inputSchema: batchExecuteInputSchema,
212
+ availability: () => ({
213
+ available: dependencies.batchExecute?.available ?? false,
214
+ reason: dependencies.batchExecute?.unavailableReason,
215
+ }),
216
+ run: async (input, context, options) => dependencies.batchExecute.run(input, context, options),
217
+ }]
218
+ : []),
192
219
  ...(dependencies.downloadArtifact
193
220
  ? [{
194
221
  name: "artifact.download",
195
222
  description: "Save one Host-native file into a workspace-relative destination without overwriting.",
196
223
  guideName: "artifacts-review",
197
224
  readGuideBeforeFirstUse: true,
225
+ batchPolicy: "unsupported",
198
226
  inputSchema: z.object({
199
227
  file: z.strictObject({
200
228
  download_url: z.string(),
@@ -49,6 +49,11 @@ const migrations = [
49
49
  name: "activity-host-turns",
50
50
  up: migrateActivityHostTurns,
51
51
  },
52
+ {
53
+ version: 11,
54
+ name: "activity-parent-child",
55
+ up: migrateActivityParentChild,
56
+ },
52
57
  ];
53
58
  export function migrateDatabase(sqlite) {
54
59
  const migrate = sqlite.transaction(() => {
@@ -316,6 +321,14 @@ function migrateActivityHostTurns(sqlite) {
316
321
  on activity_host_turns(created_at desc);
317
322
  `);
318
323
  }
324
+ function migrateActivityParentChild(sqlite) {
325
+ sqlite.exec(`
326
+ alter table activity_audit_events add column parent_activity_id text;
327
+
328
+ create index if not exists activity_audit_events_parent_idx
329
+ on activity_audit_events(parent_activity_id, created_at);
330
+ `);
331
+ }
319
332
  function addColumnIfMissing(sqlite, table, column, definition) {
320
333
  const columns = sqlite.prepare(`pragma table_info(${table})`).all();
321
334
  if (columns.some((existingColumn) => existingColumn.name === column))
@@ -1,6 +1,6 @@
1
- import { lstat, realpath, rename, rm, rmdir, unlink } from "node:fs/promises";
1
+ import { lstat, readdir, realpath, rename, rm, rmdir, unlink } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
- import { expandHomePath, resolveCanonicalAllowedPath } from "./roots.js";
3
+ import { expandHomePath, isPathInsideRoot, resolveCanonicalAllowedPath } from "./roots.js";
4
4
  export async function renamePath(input, context) {
5
5
  const source = await resolveCanonicalAllowedPath(input.path, context.cwd, context.allowedRoots);
6
6
  await assertNotAllowedRootItself(source, context.allowedRoots, input.path);
@@ -10,6 +10,37 @@ export async function renamePath(input, context) {
10
10
  await rename(source, destination);
11
11
  return { path: input.path, newPath: input.newPath };
12
12
  }
13
+ export async function preflightDeletePaths(inputs, context) {
14
+ const targets = await Promise.all(inputs.map(async (input) => {
15
+ const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.allowedRoots);
16
+ await assertNotAllowedRootItself(path, context.allowedRoots, input.path);
17
+ const entry = await lstat(path);
18
+ const recursive = input.recursive ?? false;
19
+ if (entry.isDirectory() && !recursive) {
20
+ const entries = await readdir(path);
21
+ if (entries.length > 0) {
22
+ throw new Error(`Directory is non-empty: ${input.path}. Use recursive=true to delete it.`);
23
+ }
24
+ }
25
+ return {
26
+ inputPath: input.path,
27
+ canonicalPath: await realpath(path),
28
+ };
29
+ }));
30
+ for (let index = 0; index < targets.length; index += 1) {
31
+ const current = targets[index];
32
+ for (let otherIndex = index + 1; otherIndex < targets.length; otherIndex += 1) {
33
+ const other = targets[otherIndex];
34
+ if (current.canonicalPath === other.canonicalPath) {
35
+ throw new Error(`Bulk delete targets overlap: ${current.inputPath} and ${other.inputPath} resolve to the same path.`);
36
+ }
37
+ if (isPathInsideRoot(other.canonicalPath, current.canonicalPath) ||
38
+ isPathInsideRoot(current.canonicalPath, other.canonicalPath)) {
39
+ throw new Error(`Bulk delete targets overlap as ancestor/descendant paths: ${current.inputPath} and ${other.inputPath}.`);
40
+ }
41
+ }
42
+ }
43
+ }
13
44
  export async function deletePath(input, context) {
14
45
  const path = await resolveCanonicalAllowedPath(input.path, context.cwd, context.allowedRoots);
15
46
  await assertNotAllowedRootItself(path, context.allowedRoots, input.path);
@@ -24,11 +24,11 @@ export function buildToolDescriptions(config) {
24
24
  ? ""
25
25
  : " Use shell commands for search and directory inspection instead of dedicated MCP search tools.";
26
26
  return {
27
- read: `Read a file inside an open workspace or the OS temp directory. Instruction files and advertised capability guides returned by ${toolNames.openWorkspace} are also readable when applicable.${skillCapability} Only advertised entry files and files under already-loaded advertised directories are readable outside the normal roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
27
+ read: `Read one file or multiple files inside an open workspace or the OS temp directory. Use path for one target or paths for multiple targets; offset/limit apply to every target in a bulk read. Instruction files and advertised capability guides returned by ${toolNames.openWorkspace} are also readable when applicable.${skillCapability} Only advertised entry files and files under already-loaded advertised directories are readable outside the normal roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
28
28
  write: `Create or completely overwrite a file inside an open workspace or the OS temp directory. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
29
- edit: `Edit one file inside an open workspace or the OS temp directory by replacing exact text blocks. Each oldText must match a unique, non-overlapping region of the original file. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
29
+ edit: `Edit one file or multiple files inside an open workspace or the OS temp directory by replacing exact text blocks. Use path for one target or paths for multiple targets; a bulk Edit applies the same edits to every file and preflights all targets before the first mutation. Each oldText must match a unique, non-overlapping region of the original file. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
30
30
  rename: `Rename or move one file or directory inside an open workspace or the OS temp directory without overwriting an existing destination. Source and destination must both remain inside the permitted file roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
31
- delete: `Delete one file or directory inside an open workspace or the OS temp directory. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
31
+ delete: `Delete one path or multiple paths inside an open workspace or the OS temp directory. Use path for one target or paths for multiple targets; a bulk Delete preflights all targets before deleting anything. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
32
32
  applyPatch: `Apply one Codex-style patch inside an open workspace or the OS temp directory. Supports adding, overwriting, updating, deleting, and moving files. Workspace paths must remain relative; absolute paths are accepted only inside the OS temp directory. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
33
33
  shell: `Run or manage a shell process inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace containment does not make shell execution a sandbox. For action=run, yieldTimeMs is only the feedback wait (default 10000ms; 0 returns a processId immediately) and optional timeoutMs is the independent total execution limit. action=process polls/waits for incremental output, writes input, resizes a PTY, or interrupts by processId. Keep explicit waits below the Host request deadline; use 60000ms only when supported. Completed background results may be attached to a later result for the same workspaceId. Call ${toolNames.openWorkspace} first and pass workspaceId. Expose this capability only behind strong authentication.`,
34
34
  shellCommand: "Shell command to run with the local user's authority.",
@@ -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
+ }