@akira-tl/forgerelay 0.5.2 → 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.
@@ -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(),
@@ -44,6 +44,16 @@ const migrations = [
44
44
  name: "bash-output-audit",
45
45
  up: migrateBashOutputAudit,
46
46
  },
47
+ {
48
+ version: 10,
49
+ name: "activity-host-turns",
50
+ up: migrateActivityHostTurns,
51
+ },
52
+ {
53
+ version: 11,
54
+ name: "activity-parent-child",
55
+ up: migrateActivityParentChild,
56
+ },
47
57
  ];
48
58
  export function migrateDatabase(sqlite) {
49
59
  const migrate = sqlite.transaction(() => {
@@ -296,6 +306,29 @@ function migrateBashOutputAudit(sqlite) {
296
306
  on bash_output_chunks(output_id, sequence);
297
307
  `);
298
308
  }
309
+ function migrateActivityHostTurns(sqlite) {
310
+ sqlite.exec(`
311
+ create table if not exists activity_host_turns (
312
+ turn_id text primary key,
313
+ conversation_scope_id text,
314
+ created_at text not null
315
+ );
316
+
317
+ create index if not exists activity_host_turns_conversation_idx
318
+ on activity_host_turns(conversation_scope_id, created_at desc);
319
+
320
+ create index if not exists activity_host_turns_created_idx
321
+ on activity_host_turns(created_at desc);
322
+ `);
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
+ }
299
332
  function addColumnIfMissing(sqlite, table, column, definition) {
300
333
  const columns = sqlite.prepare(`pragma table_info(${table})`).all();
301
334
  if (columns.some((existingColumn) => existingColumn.name === column))
package/dist/db/schema.js CHANGED
@@ -97,6 +97,14 @@ export const activityAuditEvents = sqliteTable("activity_audit_events", {
97
97
  index("activity_audit_events_turn_idx").on(table.turnId, table.createdAt),
98
98
  index("activity_audit_events_created_idx").on(table.createdAt),
99
99
  ]);
100
+ export const activityHostTurns = sqliteTable("activity_host_turns", {
101
+ turnId: text("turn_id").primaryKey(),
102
+ conversationScopeId: text("conversation_scope_id"),
103
+ createdAt: text("created_at").notNull(),
104
+ }, (table) => [
105
+ index("activity_host_turns_conversation_idx").on(table.conversationScopeId, table.createdAt),
106
+ index("activity_host_turns_created_idx").on(table.createdAt),
107
+ ]);
100
108
  export const bashOutputStreams = sqliteTable("bash_output_streams", {
101
109
  id: text("id").primaryKey(),
102
110
  activityId: text("activity_id").notNull(),
@@ -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);
@@ -6,7 +6,9 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
6
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
7
7
  import { ActivityAuditStore } from "../../activity/audit-store.js";
8
8
  import { BashOutputStore } from "../../activity/bash-output-store.js";
9
+ import { HostTurnStore } from "../../activity/host-turn-store.js";
9
10
  import { ActivityLifecycle } from "../../activity/lifecycle.js";
11
+ import { ActivityQueryService } from "../../activity/query-service.js";
10
12
  import { loadConfig } from "../../config.js";
11
13
  import { createReviewCheckpointManager } from "../../review-checkpoints.js";
12
14
  import { ProcessManager } from "../../process-sessions.js";
@@ -37,10 +39,14 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
37
39
  const workspaces = new WorkspaceRegistry(config, store);
38
40
  const auditStore = new ActivityAuditStore(stateDir);
39
41
  const bashOutputStore = new BashOutputStore(stateDir);
42
+ const hostTurnStore = new HostTurnStore(stateDir);
43
+ const activityQueries = new ActivityQueryService(hostTurnStore, auditStore, bashOutputStore);
40
44
  const processSessions = new ProcessManager({ outputAudit: bashOutputStore });
41
- const activityLifecycle = new ActivityLifecycle(auditStore);
45
+ const activityLifecycle = new ActivityLifecycle(auditStore, {
46
+ turnIdForConversation: (conversationScopeId) => activityQueries.currentTurnId(conversationScopeId),
47
+ });
42
48
  const codeIntelligence = new CodeIntelligenceManager(config, options.codeIntelligenceOptions);
43
- const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle, bashOutputStore);
49
+ const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle, bashOutputStore, activityQueries);
44
50
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
45
51
  const client = new Client({ name: "forgerelay-code-intelligence-test-client", version: "1.0.0" });
46
52
  await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
@@ -53,6 +59,7 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
53
59
  await server.close();
54
60
  await codeIntelligence.shutdown();
55
61
  processSessions.shutdown();
62
+ hostTurnStore.close();
56
63
  bashOutputStore.close();
57
64
  auditStore.close();
58
65
  store.close();
@@ -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
+ }
@@ -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
+ });