@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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ 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
+
20
+ ## [0.5.4] - 2026-08-15
21
+
22
+ ### Fixed
23
+
24
+ - Closed all Activity query SQLite handles before temporary-state cleanup so the backend acceptance suite also passes on Windows, where open database files cannot be unlinked.
25
+
26
+ ## [0.5.3] - 2026-08-15
27
+
28
+ ### Added
29
+
30
+ - Added durable Host Turn records and a stable Activity query projection backed by ForgeRelay's local SQLite audit history, with revision-based lightweight snapshots that remain queryable after ForgeRelay restarts.
31
+ - Added the production backend query contract for future MCP App UI: model-visible `activity_panel` establishes a Host Turn, while app-only `activity_snapshot`, `activity_detail`, and `activity_output` data sources expose summaries, selected lazy detail, and complete Bash output separately.
32
+
33
+ ### Changed
34
+
35
+ - Activity snapshots now use an explicit summary whitelist: read bodies, write/edit patches, full Bash commands/output, and capability-heavy payloads stay out of normal snapshots; rename/delete include complete path targets and are summary-complete without detail requests.
36
+ - Late Bash completion records use the current Host Turn for delivery while preserving the original Bash Activity's immutable returned history and Workspace audit snapshot.
37
+
7
38
  ## [0.5.2] - 2026-08-15
8
39
 
9
40
  ### Added
@@ -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,13 +50,31 @@ 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
  }
47
57
  listEvents(activityId) {
48
58
  return this.readRows(activityId).map(rowToEvent);
49
59
  }
60
+ listActivitiesByTurn(turnId) {
61
+ const rows = this.database.sqlite.prepare(`select activity_id from activity_audit_events
62
+ where event_type = 'started' and turn_id = ?
63
+ order by rowid asc`).all(turnId);
64
+ return rows.flatMap(({ activity_id }) => {
65
+ const activity = this.getActivity(activity_id);
66
+ return activity ? [activity] : [];
67
+ });
68
+ }
69
+ turnRevision(turnId) {
70
+ const row = this.database.sqlite.prepare(`select count(*) as revision
71
+ from activity_audit_events
72
+ where activity_id in (
73
+ select activity_id from activity_audit_events
74
+ where event_type = 'started' and turn_id = ?
75
+ )`).get(turnId);
76
+ return row.revision;
77
+ }
50
78
  getActivity(activityId) {
51
79
  const events = this.listEvents(activityId);
52
80
  const started = events[0];
@@ -86,6 +114,7 @@ export class ActivityAuditStore {
86
114
  return {
87
115
  activityId: started.activityId,
88
116
  turnId: started.turnId,
117
+ ...(started.parentActivityId ? { parentActivityId: started.parentActivityId } : {}),
89
118
  ...(started.conversationScopeId ? { conversationScopeId: started.conversationScopeId } : {}),
90
119
  tool: started.tool,
91
120
  workspace: started.workspace,
@@ -114,6 +143,7 @@ function eventInputToRow(input, identity) {
114
143
  sequence: identity.sequence,
115
144
  event_type: input.type,
116
145
  turn_id: input.turnId,
146
+ parent_activity_id: input.parentActivityId ?? null,
117
147
  conversation_scope_id: input.conversationScopeId ?? null,
118
148
  tool: input.tool,
119
149
  workspace_id: input.workspace.id ?? null,
@@ -134,6 +164,7 @@ function eventInputToRow(input, identity) {
134
164
  sequence: identity.sequence,
135
165
  event_type: input.type,
136
166
  turn_id: null,
167
+ parent_activity_id: null,
137
168
  conversation_scope_id: null,
138
169
  tool: null,
139
170
  workspace_id: null,
@@ -164,6 +195,7 @@ function rowToEvent(row) {
164
195
  ...base,
165
196
  type: "started",
166
197
  turnId: row.turn_id,
198
+ ...(row.parent_activity_id ? { parentActivityId: row.parent_activity_id } : {}),
167
199
  ...(row.conversation_scope_id ? { conversationScopeId: row.conversation_scope_id } : {}),
168
200
  tool: row.tool,
169
201
  workspace: {
@@ -0,0 +1,46 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { openDatabase } from "../db/client.js";
3
+ export class HostTurnStore {
4
+ database;
5
+ now;
6
+ nextTurnId;
7
+ constructor(stateDir, options = {}) {
8
+ this.database = openDatabase(stateDir);
9
+ this.now = options.now ?? (() => new Date());
10
+ this.nextTurnId = options.turnId ?? (() => `turn_${randomUUID().replaceAll("-", "")}`);
11
+ }
12
+ begin(conversationScopeId) {
13
+ const turnId = this.nextTurnId();
14
+ const createdAt = this.now().toISOString();
15
+ this.database.sqlite.prepare(`insert into activity_host_turns (turn_id, conversation_scope_id, created_at)
16
+ values (?, ?, ?)`).run(turnId, conversationScopeId ?? null, createdAt);
17
+ return {
18
+ turnId,
19
+ ...(conversationScopeId ? { conversationScopeId } : {}),
20
+ createdAt,
21
+ };
22
+ }
23
+ get(turnId) {
24
+ const row = this.database.sqlite.prepare("select * from activity_host_turns where turn_id = ?").get(turnId);
25
+ return row ? rowToTurn(row) : undefined;
26
+ }
27
+ current(conversationScopeId) {
28
+ if (!conversationScopeId)
29
+ return undefined;
30
+ const row = this.database.sqlite.prepare(`select * from activity_host_turns
31
+ where conversation_scope_id = ?
32
+ order by rowid desc
33
+ limit 1`).get(conversationScopeId);
34
+ return row ? rowToTurn(row) : undefined;
35
+ }
36
+ close() {
37
+ this.database.close();
38
+ }
39
+ }
40
+ function rowToTurn(row) {
41
+ return {
42
+ turnId: row.turn_id,
43
+ ...(row.conversation_scope_id ? { conversationScopeId: row.conversation_scope_id } : {}),
44
+ createdAt: row.created_at,
45
+ };
46
+ }
@@ -4,10 +4,12 @@ export class ActivityLifecycle {
4
4
  auditStore;
5
5
  activityId;
6
6
  turnId;
7
+ turnIdForConversation;
7
8
  constructor(auditStore, options = {}) {
8
9
  this.auditStore = auditStore;
9
10
  this.activityId = options.activityId ?? newActivityId;
10
11
  this.turnId = options.turnId ?? newTurnId;
12
+ this.turnIdForConversation = options.turnIdForConversation;
11
13
  }
12
14
  record(options) {
13
15
  const context = this.start(options);
@@ -43,12 +45,15 @@ export class ActivityLifecycle {
43
45
  }
44
46
  start(options) {
45
47
  const activityId = options.activityId ?? this.activityId();
46
- const turnId = options.turnId ?? this.turnId();
48
+ const turnId = options.turnId
49
+ ?? this.turnIdForConversation?.(options.conversationScopeId)
50
+ ?? this.turnId();
47
51
  const request = normalizeAuditValue(options.request);
48
52
  this.auditStore.append({
49
53
  type: "started",
50
54
  activityId,
51
55
  turnId,
56
+ ...(options.parentActivityId ? { parentActivityId: options.parentActivityId } : {}),
52
57
  ...(options.conversationScopeId ? { conversationScopeId: options.conversationScopeId } : {}),
53
58
  tool: options.tool,
54
59
  workspace: options.workspace,
@@ -57,6 +62,7 @@ export class ActivityLifecycle {
57
62
  return {
58
63
  activityId,
59
64
  turnId,
65
+ ...(options.parentActivityId ? { parentActivityId: options.parentActivityId } : {}),
60
66
  ...(options.conversationScopeId ? { conversationScopeId: options.conversationScopeId } : {}),
61
67
  };
62
68
  }
@@ -0,0 +1,129 @@
1
+ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
2
+ import * as z from "zod/v4";
3
+ import { openAiConversationScopeId } from "../request-meta.js";
4
+ const READ_ONLY_ANNOTATIONS = {
5
+ readOnlyHint: true,
6
+ destructiveHint: false,
7
+ idempotentHint: true,
8
+ openWorldHint: false,
9
+ };
10
+ const activitySummarySchema = z.object({
11
+ activityId: z.string(),
12
+ tool: z.string(),
13
+ kind: z.string(),
14
+ status: z.enum(["working", "done", "error"]),
15
+ state: z.enum(["executing", "returned", "done", "failed", "blocked"]),
16
+ title: z.string(),
17
+ target: z.string(),
18
+ detailAvailable: z.boolean(),
19
+ workspaceId: z.string().optional(),
20
+ processId: z.number().int().positive().optional(),
21
+ outputId: z.string().optional(),
22
+ commandLength: z.number().int().nonnegative().optional(),
23
+ bashPhase: z.enum(["executing", "returned", "done", "error"]).optional(),
24
+ startedAt: z.string(),
25
+ finishedAt: z.string().optional(),
26
+ durationMs: z.number().nonnegative().optional(),
27
+ });
28
+ const snapshotOutputSchema = {
29
+ turnId: z.string(),
30
+ revision: z.number().int().nonnegative(),
31
+ changed: z.boolean(),
32
+ state: z.enum(["working", "done", "error"]),
33
+ activities: z.array(activitySummarySchema),
34
+ };
35
+ export function registerActivityQueryTools(server, queries) {
36
+ registerAppTool(server, "activity_panel", {
37
+ title: "Begin Activity Panel",
38
+ description: "Begin one ForgeRelay Host Turn lifecycle for subsequent project work. This orchestration call does not read or modify project files. Call it once before the first ForgeRelay work operation in a Host Turn that performs project work.",
39
+ inputSchema: {},
40
+ outputSchema: snapshotOutputSchema,
41
+ _meta: { ui: { visibility: ["model", "app"] } },
42
+ annotations: {
43
+ ...READ_ONLY_ANNOTATIONS,
44
+ idempotentHint: false,
45
+ },
46
+ }, async (_input, extra) => {
47
+ const snapshot = queries.beginTurn(openAiConversationScopeId(extra._meta));
48
+ return {
49
+ content: [{
50
+ type: "text",
51
+ text: `Started ForgeRelay Activity Panel Host Turn ${snapshot.turnId}.`,
52
+ }],
53
+ structuredContent: { ...snapshot },
54
+ };
55
+ });
56
+ registerAppTool(server, "activity_snapshot", {
57
+ title: "Read Activity snapshot",
58
+ description: "App-only data source for lightweight Activity summaries in one durable Host Turn.",
59
+ inputSchema: {
60
+ turnId: z.string(),
61
+ knownRevision: z.number().int().nonnegative().optional(),
62
+ },
63
+ outputSchema: snapshotOutputSchema,
64
+ _meta: { ui: { visibility: ["app"] } },
65
+ annotations: READ_ONLY_ANNOTATIONS,
66
+ }, async ({ turnId, knownRevision }) => {
67
+ const snapshot = queries.snapshot(turnId, knownRevision);
68
+ return {
69
+ content: [{
70
+ type: "text",
71
+ text: snapshot.changed
72
+ ? `Activity snapshot ${turnId} revision ${snapshot.revision}.`
73
+ : `Activity snapshot ${turnId} unchanged at revision ${snapshot.revision}.`,
74
+ }],
75
+ structuredContent: { ...snapshot },
76
+ };
77
+ });
78
+ registerAppTool(server, "activity_detail", {
79
+ title: "Read Activity detail",
80
+ description: "App-only lazy data source for one selected expandable Activity.",
81
+ inputSchema: {
82
+ turnId: z.string(),
83
+ activityId: z.string(),
84
+ },
85
+ outputSchema: {
86
+ activity: activitySummarySchema,
87
+ request: z.unknown().optional(),
88
+ result: z.unknown().optional(),
89
+ error: z.string().optional(),
90
+ },
91
+ _meta: { ui: { visibility: ["app"] } },
92
+ annotations: READ_ONLY_ANNOTATIONS,
93
+ }, async ({ turnId, activityId }) => {
94
+ const detail = queries.detail(turnId, activityId);
95
+ return {
96
+ content: [{ type: "text", text: `Activity detail ${activityId}.` }],
97
+ structuredContent: { ...detail },
98
+ };
99
+ });
100
+ registerAppTool(server, "activity_output", {
101
+ title: "Read Bash output",
102
+ description: "App-only lazy data source for complete durable Bash command/output by stable outputId.",
103
+ inputSchema: {
104
+ turnId: z.string(),
105
+ outputId: z.string(),
106
+ },
107
+ outputSchema: {
108
+ outputId: z.string(),
109
+ activityId: z.string(),
110
+ processId: z.number().int().positive(),
111
+ command: z.string(),
112
+ output: z.string(),
113
+ status: z.enum(["running", "done", "failed"]),
114
+ exitCode: z.number().int().optional(),
115
+ signal: z.string().optional(),
116
+ timedOut: z.boolean(),
117
+ startedAt: z.string(),
118
+ finishedAt: z.string().optional(),
119
+ },
120
+ _meta: { ui: { visibility: ["app"] } },
121
+ annotations: READ_ONLY_ANNOTATIONS,
122
+ }, async ({ turnId, outputId }) => {
123
+ const output = queries.bashOutput(turnId, outputId);
124
+ return {
125
+ content: [{ type: "text", text: `Bash output ${outputId}.` }],
126
+ structuredContent: { ...output },
127
+ };
128
+ });
129
+ }
@@ -0,0 +1,289 @@
1
+ export class ActivityQueryService {
2
+ turns;
3
+ audit;
4
+ outputs;
5
+ constructor(turns, audit, outputs) {
6
+ this.turns = turns;
7
+ this.audit = audit;
8
+ this.outputs = outputs;
9
+ }
10
+ beginTurn(conversationScopeId) {
11
+ const turn = this.turns.begin(conversationScopeId);
12
+ return this.snapshot(turn.turnId);
13
+ }
14
+ currentTurnId(conversationScopeId) {
15
+ return this.turns.current(conversationScopeId)?.turnId;
16
+ }
17
+ snapshot(turnId, knownRevision) {
18
+ this.requireTurn(turnId);
19
+ const revision = this.audit.turnRevision(turnId);
20
+ const activities = this.summaries(turnId);
21
+ const state = aggregateState(activities);
22
+ const changed = knownRevision === undefined || knownRevision !== revision;
23
+ return {
24
+ turnId,
25
+ revision,
26
+ changed,
27
+ state,
28
+ activities: changed ? activities : [],
29
+ };
30
+ }
31
+ detail(turnId, activityId) {
32
+ this.requireTurn(turnId);
33
+ const record = this.audit.getActivity(activityId);
34
+ if (!record || record.turnId !== turnId) {
35
+ throw new Error(`Unknown Activity ${activityId} in Host Turn ${turnId}.`);
36
+ }
37
+ const activity = this.summaries(turnId)
38
+ .find((summary) => summary.activityId === activityId) ?? toSummary(record);
39
+ if (!activity.detailAvailable) {
40
+ throw new Error(`Activity ${activityId} is summary-complete and has no lazy detail.`);
41
+ }
42
+ return {
43
+ activity,
44
+ ...(record.request !== undefined ? { request: record.request } : {}),
45
+ ...(record.result !== undefined ? { result: record.result } : {}),
46
+ ...(record.error !== undefined ? { error: record.error } : {}),
47
+ };
48
+ }
49
+ bashOutput(turnId, outputId) {
50
+ this.requireTurn(turnId);
51
+ const output = this.outputs.read(outputId);
52
+ if (!output)
53
+ throw new Error(`Unknown Bash output: ${outputId}.`);
54
+ const activities = this.audit.listActivitiesByTurn(turnId);
55
+ const visible = activities.some((activity) => activity.activityId === output.activityId || activityOutputId(activity) === outputId);
56
+ if (!visible) {
57
+ throw new Error(`Bash output ${outputId} is not part of Host Turn ${turnId}.`);
58
+ }
59
+ return {
60
+ outputId: output.outputId,
61
+ activityId: output.activityId,
62
+ processId: output.processId,
63
+ command: output.command,
64
+ output: output.output,
65
+ status: output.status,
66
+ ...(output.exitCode !== undefined ? { exitCode: output.exitCode } : {}),
67
+ ...(output.signal !== undefined ? { signal: output.signal } : {}),
68
+ timedOut: output.timedOut,
69
+ startedAt: output.startedAt,
70
+ ...(output.finishedAt !== undefined ? { finishedAt: output.finishedAt } : {}),
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
+ }
97
+ requireTurn(turnId) {
98
+ if (!this.turns.get(turnId))
99
+ throw new Error(`Unknown Host Turn: ${turnId}.`);
100
+ }
101
+ }
102
+ function toSummary(record) {
103
+ const request = asRecord(record.request);
104
+ const result = asRecord(record.result);
105
+ const structured = asRecord(result?.structuredContent);
106
+ const directProcessId = numberField(result, "processId") ?? numberField(request, "processId");
107
+ const processId = numberField(structured, "processId") ?? directProcessId;
108
+ const outputId = stringField(structured, "outputId")
109
+ ?? stringField(result, "outputId")
110
+ ?? stringField(request, "outputId");
111
+ const command = stringField(request, "command") ?? stringField(request, "cmd");
112
+ const durationMs = numberField(structured, "wallTimeMs")
113
+ ?? numberField(result, "wallTimeMs")
114
+ ?? elapsedMs(record.startedAt, record.updatedAt, record.state);
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");
118
+ return {
119
+ activityId: record.activityId,
120
+ ...(record.parentActivityId ? { parentActivityId: record.parentActivityId } : {}),
121
+ tool: record.tool,
122
+ kind: activityKind(record.tool),
123
+ status: activityStatus(record.state),
124
+ state: record.state,
125
+ title: activityTitle(record.tool),
126
+ target: activityTarget(record, request, result, structured),
127
+ detailAvailable: !bulkGroup && record.tool !== "rename" && record.tool !== "delete" && record.tool !== "batch",
128
+ ...(record.workspace.id ? { workspaceId: record.workspace.id } : {}),
129
+ ...(processId !== undefined ? { processId } : {}),
130
+ ...(outputId !== undefined ? { outputId } : {}),
131
+ ...(bashLike && command !== undefined ? { commandLength: command.length } : {}),
132
+ ...(bashLike ? { bashPhase: bashPhase(record.state) } : {}),
133
+ startedAt: record.startedAt,
134
+ ...(record.state !== "executing" ? { finishedAt: record.updatedAt } : {}),
135
+ ...(durationMs !== undefined ? { durationMs } : {}),
136
+ };
137
+ }
138
+ function activityKind(tool) {
139
+ if (tool === "read")
140
+ return "read";
141
+ if (tool === "write")
142
+ return "write";
143
+ if (tool === "edit" || tool === "apply_patch")
144
+ return "edit";
145
+ if (tool === "rename")
146
+ return "rename";
147
+ if (tool === "delete")
148
+ return "delete";
149
+ if (tool === "bash_result")
150
+ return "shell-result";
151
+ if (tool === "bash" || tool === "exec_command")
152
+ return "shell";
153
+ if (tool === "capability")
154
+ return "capability";
155
+ if (tool === "batch")
156
+ return "batch";
157
+ return "tool";
158
+ }
159
+ function activityTitle(tool) {
160
+ const titles = {
161
+ read: "Read",
162
+ write: "Write",
163
+ edit: "Edit",
164
+ apply_patch: "Edit",
165
+ rename: "Rename",
166
+ delete: "Delete",
167
+ bash: "Bash",
168
+ exec_command: "Command",
169
+ bash_result: "Bash result",
170
+ capability: "Capability",
171
+ batch: "Batch",
172
+ };
173
+ return titles[tool] ?? tool;
174
+ }
175
+ function activityTarget(record, request, result, structured) {
176
+ if (record.tool === "bash" || record.tool === "exec_command")
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
+ }
189
+ if (record.tool === "bash_result") {
190
+ const processId = numberField(result, "processId") ?? numberField(request, "processId");
191
+ const exitCode = numberField(result, "exitCode");
192
+ const signal = stringField(result, "signal");
193
+ const timedOut = booleanField(result, "timedOut");
194
+ const outcome = timedOut
195
+ ? "timed out"
196
+ : signal
197
+ ? `signal ${signal}`
198
+ : exitCode !== undefined
199
+ ? `exit ${exitCode}`
200
+ : record.state === "failed"
201
+ ? "failed"
202
+ : "completed";
203
+ return `Process ${processId ?? "?"} · ${outcome}`;
204
+ }
205
+ if (record.tool === "capability") {
206
+ const name = stringField(request, "name") ?? "capability";
207
+ const action = stringField(request, "action") ?? "run";
208
+ return `${name} · ${action}`;
209
+ }
210
+ if (record.tool === "rename") {
211
+ const from = stringField(request, "path")
212
+ ?? stringField(request, "from")
213
+ ?? stringField(request, "source");
214
+ const to = stringField(request, "newPath")
215
+ ?? stringField(request, "to")
216
+ ?? stringField(request, "destination");
217
+ return [from, to].filter((value) => Boolean(value)).join(" → ") || "path";
218
+ }
219
+ const path = stringField(request, "path");
220
+ if (path)
221
+ return path;
222
+ if (record.tool === "apply_patch") {
223
+ const files = arrayField(structured, "files");
224
+ const first = asRecord(files?.[0]);
225
+ const firstPath = stringField(first, "path");
226
+ if (firstPath)
227
+ return files && files.length > 1 ? `${firstPath} +${files.length - 1}` : firstPath;
228
+ }
229
+ return record.tool;
230
+ }
231
+ function activityStatus(state) {
232
+ if (state === "executing")
233
+ return "working";
234
+ if (state === "failed" || state === "blocked")
235
+ return "error";
236
+ return "done";
237
+ }
238
+ function bashPhase(state) {
239
+ if (state === "executing")
240
+ return "executing";
241
+ if (state === "returned")
242
+ return "returned";
243
+ if (state === "failed" || state === "blocked")
244
+ return "error";
245
+ return "done";
246
+ }
247
+ function aggregateState(activities) {
248
+ if (activities.length === 0)
249
+ return "working";
250
+ if (activities.some((activity) => activity.status === "working"))
251
+ return "working";
252
+ if (activities.some((activity) => activity.status === "error"))
253
+ return "error";
254
+ return "done";
255
+ }
256
+ function activityOutputId(activity) {
257
+ const request = asRecord(activity.request);
258
+ const result = asRecord(activity.result);
259
+ const structured = asRecord(result?.structuredContent);
260
+ return stringField(request, "outputId")
261
+ ?? stringField(result, "outputId")
262
+ ?? stringField(structured, "outputId");
263
+ }
264
+ function asRecord(value) {
265
+ return typeof value === "object" && value !== null && !Array.isArray(value)
266
+ ? value
267
+ : undefined;
268
+ }
269
+ function stringField(record, key) {
270
+ return typeof record?.[key] === "string" ? record[key] : undefined;
271
+ }
272
+ function numberField(record, key) {
273
+ return typeof record?.[key] === "number" ? record[key] : undefined;
274
+ }
275
+ function booleanField(record, key) {
276
+ return typeof record?.[key] === "boolean" ? record[key] : undefined;
277
+ }
278
+ function arrayField(record, key) {
279
+ return Array.isArray(record?.[key]) ? record[key] : undefined;
280
+ }
281
+ function elapsedMs(startedAt, updatedAt, state) {
282
+ if (state === "executing")
283
+ return undefined;
284
+ const start = Date.parse(startedAt);
285
+ const end = Date.parse(updatedAt);
286
+ if (!Number.isFinite(start) || !Number.isFinite(end))
287
+ return undefined;
288
+ return Math.max(0, end - start);
289
+ }
@@ -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
  }