@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 +31 -0
- package/capabilities/batch-execution/GUIDE.md +18 -0
- package/dist/activity/audit-store.js +33 -1
- package/dist/activity/host-turn-store.js +46 -0
- package/dist/activity/lifecycle.js +7 -1
- package/dist/activity/mcp-query-tools.js +129 -0
- package/dist/activity/query-service.js +289 -0
- package/dist/capabilities.js +9 -0
- package/dist/capability-registry.js +28 -0
- package/dist/db/migrations.js +33 -0
- package/dist/db/schema.js +8 -0
- package/dist/file-mutations.js +33 -2
- package/dist/lsp/test-support/server-fixture.js +9 -2
- package/dist/mcp/server-instructions.js +3 -3
- package/dist/operations/batch/executor.js +216 -0
- package/dist/operations/batch/scheduler.js +108 -0
- package/dist/operations/batch/types.js +76 -0
- package/dist/operations/bulk-mutation.js +41 -0
- package/dist/operations/bulk-read.js +28 -0
- package/dist/operations/core-operation-executor.js +30 -0
- package/dist/operations/native-bulk-mutations.js +134 -0
- package/dist/pi-tools.js +36 -0
- package/dist/server.js +843 -495
- package/package.json +2 -2
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
|
|
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
|
+
}
|
package/dist/capabilities.js
CHANGED
|
@@ -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
|
}
|