@akira-tl/forgerelay 0.4.7 → 0.5.1

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,22 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.1] - 2026-08-14
8
+
9
+ ### Added
10
+
11
+ - Routed ForgeRelay's top-level work operations through one persistent Activity lifecycle: read, write, edit, rename, delete, capability, Bash, and Codex-compatible execution/patch operations now record durable started/succeeded/failed/blocked/returned facts without relying on UI inference. Bash/exec process-control follow-ups remain part of the existing semantic operation instead of creating duplicate top-level Activities.
12
+
13
+ ### Fixed
14
+
15
+ - Activity auditing now treats a shell process as `returned` only after its `processId` can actually be delivered to the Host; Host cancellation during post-tool delivery protection records a failed Activity and discards the undelivered process instead of leaving a false returned history entry.
16
+
17
+ ## [0.5.0] - 2026-08-14
18
+
19
+ ### Added
20
+
21
+ - Added the production local Activity audit foundation: append-only Audit Events persist in ForgeRelay's existing SQLite state, queryable Activity Records survive server restarts and Workspace cleanup, and success, failure, and Hook-blocked outcomes retain immutable Workspace/Host Turn execution context for later lifecycle and UI releases.
22
+
7
23
  ## [0.4.7] - 2026-08-11
8
24
 
9
25
  ### Added
@@ -0,0 +1,220 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { openDatabase } from "../db/client.js";
3
+ export class ActivityAuditStore {
4
+ database;
5
+ now;
6
+ constructor(stateDir, options = {}) {
7
+ this.database = openDatabase(stateDir);
8
+ this.now = options.now ?? (() => new Date());
9
+ }
10
+ append(input) {
11
+ return this.database.sqlite.transaction(() => {
12
+ const existing = this.readRows(input.activityId);
13
+ if (input.type === "started") {
14
+ if (existing.length > 0) {
15
+ throw new Error(`Activity ${input.activityId} already has audit events.`);
16
+ }
17
+ }
18
+ else if (existing.length === 0 || existing[0]?.event_type !== "started") {
19
+ throw new Error(`Activity ${input.activityId} must start before recording ${input.type}.`);
20
+ }
21
+ const sequence = existing.length + 1;
22
+ const id = `evt_${randomUUID().replaceAll("-", "")}`;
23
+ const createdAt = this.now().toISOString();
24
+ const row = eventInputToRow(input, { id, sequence, createdAt });
25
+ this.database.sqlite.prepare(`insert into activity_audit_events (
26
+ id,
27
+ activity_id,
28
+ sequence,
29
+ event_type,
30
+ turn_id,
31
+ conversation_scope_id,
32
+ tool,
33
+ workspace_id,
34
+ workspace_root,
35
+ workspace_mode,
36
+ workspace_source_root,
37
+ workspace_branch,
38
+ workspace_target_branch,
39
+ request_json,
40
+ result_json,
41
+ error,
42
+ 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);
44
+ return rowToEvent(row);
45
+ })();
46
+ }
47
+ listEvents(activityId) {
48
+ return this.readRows(activityId).map(rowToEvent);
49
+ }
50
+ getActivity(activityId) {
51
+ const events = this.listEvents(activityId);
52
+ const started = events[0];
53
+ if (!started || started.type !== "started")
54
+ return undefined;
55
+ let state = "executing";
56
+ let result;
57
+ let error;
58
+ let updatedAt = started.createdAt;
59
+ for (const event of events.slice(1)) {
60
+ updatedAt = event.createdAt;
61
+ switch (event.type) {
62
+ case "started":
63
+ break;
64
+ case "succeeded":
65
+ state = "done";
66
+ result = event.result;
67
+ error = undefined;
68
+ break;
69
+ case "returned":
70
+ state = "returned";
71
+ result = event.result;
72
+ error = undefined;
73
+ break;
74
+ case "failed":
75
+ state = "failed";
76
+ result = event.result;
77
+ error = event.error;
78
+ break;
79
+ case "blocked":
80
+ state = "blocked";
81
+ result = undefined;
82
+ error = event.error;
83
+ break;
84
+ }
85
+ }
86
+ return {
87
+ activityId: started.activityId,
88
+ turnId: started.turnId,
89
+ ...(started.conversationScopeId ? { conversationScopeId: started.conversationScopeId } : {}),
90
+ tool: started.tool,
91
+ workspace: started.workspace,
92
+ state,
93
+ ...(started.request !== undefined ? { request: started.request } : {}),
94
+ ...(result !== undefined ? { result } : {}),
95
+ ...(error !== undefined ? { error } : {}),
96
+ startedAt: started.createdAt,
97
+ updatedAt,
98
+ };
99
+ }
100
+ close() {
101
+ this.database.close();
102
+ }
103
+ readRows(activityId) {
104
+ return this.database.sqlite.prepare(`select * from activity_audit_events
105
+ where activity_id = ?
106
+ order by sequence asc`).all(activityId);
107
+ }
108
+ }
109
+ function eventInputToRow(input, identity) {
110
+ if (input.type === "started") {
111
+ return {
112
+ id: identity.id,
113
+ activity_id: input.activityId,
114
+ sequence: identity.sequence,
115
+ event_type: input.type,
116
+ turn_id: input.turnId,
117
+ conversation_scope_id: input.conversationScopeId ?? null,
118
+ tool: input.tool,
119
+ workspace_id: input.workspace.id ?? null,
120
+ workspace_root: input.workspace.root,
121
+ workspace_mode: input.workspace.mode,
122
+ workspace_source_root: input.workspace.sourceRoot ?? null,
123
+ workspace_branch: input.workspace.branch ?? null,
124
+ workspace_target_branch: input.workspace.targetBranch ?? null,
125
+ request_json: serializeJson(input.request),
126
+ result_json: null,
127
+ error: null,
128
+ created_at: identity.createdAt,
129
+ };
130
+ }
131
+ return {
132
+ id: identity.id,
133
+ activity_id: input.activityId,
134
+ sequence: identity.sequence,
135
+ event_type: input.type,
136
+ turn_id: null,
137
+ conversation_scope_id: null,
138
+ tool: null,
139
+ workspace_id: null,
140
+ workspace_root: null,
141
+ workspace_mode: null,
142
+ workspace_source_root: null,
143
+ workspace_branch: null,
144
+ workspace_target_branch: null,
145
+ request_json: null,
146
+ result_json: "result" in input ? serializeJson(input.result) : null,
147
+ error: "error" in input ? input.error : null,
148
+ created_at: identity.createdAt,
149
+ };
150
+ }
151
+ function rowToEvent(row) {
152
+ const base = {
153
+ id: row.id,
154
+ activityId: row.activity_id,
155
+ sequence: row.sequence,
156
+ createdAt: row.created_at,
157
+ };
158
+ switch (row.event_type) {
159
+ case "started":
160
+ if (!row.turn_id || !row.tool || !row.workspace_root || !isWorkspaceMode(row.workspace_mode)) {
161
+ throw new Error(`Activity audit start event ${row.id} is missing required context.`);
162
+ }
163
+ return {
164
+ ...base,
165
+ type: "started",
166
+ turnId: row.turn_id,
167
+ ...(row.conversation_scope_id ? { conversationScopeId: row.conversation_scope_id } : {}),
168
+ tool: row.tool,
169
+ workspace: {
170
+ ...(row.workspace_id ? { id: row.workspace_id } : {}),
171
+ root: row.workspace_root,
172
+ mode: row.workspace_mode,
173
+ ...(row.workspace_source_root ? { sourceRoot: row.workspace_source_root } : {}),
174
+ ...(row.workspace_branch ? { branch: row.workspace_branch } : {}),
175
+ ...(row.workspace_target_branch ? { targetBranch: row.workspace_target_branch } : {}),
176
+ },
177
+ ...(row.request_json !== null ? { request: parseJson(row.request_json) } : {}),
178
+ };
179
+ case "succeeded":
180
+ return {
181
+ ...base,
182
+ type: "succeeded",
183
+ result: parseJson(row.result_json),
184
+ };
185
+ case "returned":
186
+ return {
187
+ ...base,
188
+ type: "returned",
189
+ result: parseJson(row.result_json),
190
+ };
191
+ case "failed":
192
+ if (!row.error)
193
+ throw new Error(`Activity audit failed event ${row.id} is missing an error.`);
194
+ return {
195
+ ...base,
196
+ type: "failed",
197
+ result: parseJson(row.result_json),
198
+ error: row.error,
199
+ };
200
+ case "blocked":
201
+ if (!row.error)
202
+ throw new Error(`Activity audit blocked event ${row.id} is missing an error.`);
203
+ return {
204
+ ...base,
205
+ type: "blocked",
206
+ error: row.error,
207
+ };
208
+ default:
209
+ throw new Error(`Unknown Activity audit event type: ${row.event_type}`);
210
+ }
211
+ }
212
+ function isWorkspaceMode(value) {
213
+ return value === "checkout" || value === "worktree";
214
+ }
215
+ function serializeJson(value) {
216
+ return value === undefined ? null : JSON.stringify(value);
217
+ }
218
+ function parseJson(value) {
219
+ return value === null ? undefined : JSON.parse(value);
220
+ }
@@ -0,0 +1,111 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { HookExecutionError } from "../hooks.js";
3
+ export class ActivityLifecycle {
4
+ auditStore;
5
+ activityId;
6
+ turnId;
7
+ constructor(auditStore, options = {}) {
8
+ this.auditStore = auditStore;
9
+ this.activityId = options.activityId ?? newActivityId;
10
+ this.turnId = options.turnId ?? newTurnId;
11
+ }
12
+ async run(options) {
13
+ const activityId = options.activityId ?? this.activityId();
14
+ const turnId = options.turnId ?? this.turnId();
15
+ const request = normalizeAuditValue(options.request);
16
+ this.auditStore.append({
17
+ type: "started",
18
+ activityId,
19
+ turnId,
20
+ ...(options.conversationScopeId ? { conversationScopeId: options.conversationScopeId } : {}),
21
+ tool: options.tool,
22
+ workspace: options.workspace,
23
+ ...(request !== undefined ? { request } : {}),
24
+ });
25
+ try {
26
+ const result = await options.operation();
27
+ const normalizedResult = normalizeAuditValue(result);
28
+ const outcome = options.outcome?.(result) ?? { type: "succeeded" };
29
+ switch (outcome.type) {
30
+ case "succeeded":
31
+ case "returned":
32
+ this.auditStore.append({
33
+ type: outcome.type,
34
+ activityId,
35
+ ...(normalizedResult !== undefined ? { result: normalizedResult } : {}),
36
+ });
37
+ break;
38
+ case "failed":
39
+ this.auditStore.append({
40
+ type: "failed",
41
+ activityId,
42
+ ...(normalizedResult !== undefined ? { result: normalizedResult } : {}),
43
+ error: outcome.error,
44
+ });
45
+ break;
46
+ }
47
+ return result;
48
+ }
49
+ catch (error) {
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ this.auditStore.append(error instanceof HookExecutionError && error.event === "BeforeTool"
52
+ ? { type: "blocked", activityId, error: message }
53
+ : { type: "failed", activityId, error: message });
54
+ throw error;
55
+ }
56
+ }
57
+ }
58
+ function newActivityId() {
59
+ return `act_${randomUUID().replaceAll("-", "")}`;
60
+ }
61
+ function newTurnId() {
62
+ return `turn_${randomUUID().replaceAll("-", "")}`;
63
+ }
64
+ export function normalizeAuditValue(value) {
65
+ return normalizeAuditValueInternal(value, new WeakSet());
66
+ }
67
+ function normalizeAuditValueInternal(value, seen) {
68
+ if (value === undefined || typeof value === "function" || typeof value === "symbol")
69
+ return undefined;
70
+ if (value === null || typeof value === "string" || typeof value === "boolean")
71
+ return value;
72
+ if (typeof value === "number")
73
+ return Number.isFinite(value) ? value : String(value);
74
+ if (typeof value === "bigint")
75
+ return value.toString();
76
+ if (value instanceof Date)
77
+ return value.toISOString();
78
+ if (value instanceof Error) {
79
+ return {
80
+ name: value.name,
81
+ message: value.message,
82
+ };
83
+ }
84
+ if (value instanceof Uint8Array) {
85
+ return {
86
+ type: "bytes",
87
+ encoding: "base64",
88
+ data: Buffer.from(value).toString("base64"),
89
+ };
90
+ }
91
+ if (Array.isArray(value)) {
92
+ return value.map((entry) => normalizeAuditValueInternal(entry, seen) ?? null);
93
+ }
94
+ if (typeof value !== "object")
95
+ return String(value);
96
+ if (seen.has(value))
97
+ return "[Circular]";
98
+ seen.add(value);
99
+ try {
100
+ const normalized = {};
101
+ for (const [key, entry] of Object.entries(value)) {
102
+ const next = normalizeAuditValueInternal(entry, seen);
103
+ if (next !== undefined)
104
+ normalized[key] = next;
105
+ }
106
+ return normalized;
107
+ }
108
+ finally {
109
+ seen.delete(value);
110
+ }
111
+ }
@@ -34,6 +34,11 @@ const migrations = [
34
34
  name: "workspace-context-deliveries",
35
35
  up: migrateWorkspaceContextDeliveries,
36
36
  },
37
+ {
38
+ version: 8,
39
+ name: "activity-audit",
40
+ up: migrateActivityAudit,
41
+ },
37
42
  ];
38
43
  export function migrateDatabase(sqlite) {
39
44
  const migrate = sqlite.transaction(() => {
@@ -208,6 +213,41 @@ function migrateWorkspaceContextDeliveries(sqlite) {
208
213
  on workspace_context_deliveries(delivered_at desc);
209
214
  `);
210
215
  }
216
+ function migrateActivityAudit(sqlite) {
217
+ sqlite.exec(`
218
+ create table if not exists activity_audit_events (
219
+ id text primary key,
220
+ activity_id text not null,
221
+ sequence integer not null,
222
+ event_type text not null,
223
+ turn_id text,
224
+ conversation_scope_id text,
225
+ tool text,
226
+ workspace_id text,
227
+ workspace_root text,
228
+ workspace_mode text,
229
+ workspace_source_root text,
230
+ workspace_branch text,
231
+ workspace_target_branch text,
232
+ request_json text,
233
+ result_json text,
234
+ error text,
235
+ created_at text not null
236
+ );
237
+
238
+ create unique index if not exists activity_audit_events_activity_sequence_unique_idx
239
+ on activity_audit_events(activity_id, sequence);
240
+
241
+ create index if not exists activity_audit_events_activity_idx
242
+ on activity_audit_events(activity_id, sequence);
243
+
244
+ create index if not exists activity_audit_events_turn_idx
245
+ on activity_audit_events(turn_id, created_at);
246
+
247
+ create index if not exists activity_audit_events_created_idx
248
+ on activity_audit_events(created_at);
249
+ `);
250
+ }
211
251
  function addColumnIfMissing(sqlite, table, column, definition) {
212
252
  const columns = sqlite.prepare(`pragma table_info(${table})`).all();
213
253
  if (columns.some((existingColumn) => existingColumn.name === column))
package/dist/db/schema.js CHANGED
@@ -1,4 +1,4 @@
1
- import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
1
+ import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
2
2
  export const workspaceSessions = sqliteTable("workspace_sessions", {
3
3
  id: text("id").primaryKey(),
4
4
  root: text("root").notNull(),
@@ -73,6 +73,30 @@ export const oauthRefreshTokens = sqliteTable("oauth_refresh_tokens", {
73
73
  expiresAt: integer("expires_at").notNull(),
74
74
  resource: text("resource"),
75
75
  });
76
+ export const activityAuditEvents = sqliteTable("activity_audit_events", {
77
+ id: text("id").primaryKey(),
78
+ activityId: text("activity_id").notNull(),
79
+ sequence: integer("sequence").notNull(),
80
+ eventType: text("event_type").notNull(),
81
+ turnId: text("turn_id"),
82
+ conversationScopeId: text("conversation_scope_id"),
83
+ tool: text("tool"),
84
+ workspaceId: text("workspace_id"),
85
+ workspaceRoot: text("workspace_root"),
86
+ workspaceMode: text("workspace_mode"),
87
+ workspaceSourceRoot: text("workspace_source_root"),
88
+ workspaceBranch: text("workspace_branch"),
89
+ workspaceTargetBranch: text("workspace_target_branch"),
90
+ requestJson: text("request_json"),
91
+ resultJson: text("result_json"),
92
+ error: text("error"),
93
+ createdAt: text("created_at").notNull(),
94
+ }, (table) => [
95
+ uniqueIndex("activity_audit_events_activity_sequence_unique_idx").on(table.activityId, table.sequence),
96
+ index("activity_audit_events_activity_idx").on(table.activityId, table.sequence),
97
+ index("activity_audit_events_turn_idx").on(table.turnId, table.createdAt),
98
+ index("activity_audit_events_created_idx").on(table.createdAt),
99
+ ]);
76
100
  export const localAgentSessions = sqliteTable("local_agent_sessions", {
77
101
  id: text("id").primaryKey(),
78
102
  workspaceId: text("workspace_id"),
@@ -4,6 +4,8 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
6
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
7
+ import { ActivityAuditStore } from "../../activity/audit-store.js";
8
+ import { ActivityLifecycle } from "../../activity/lifecycle.js";
7
9
  import { loadConfig } from "../../config.js";
8
10
  import { createReviewCheckpointManager } from "../../review-checkpoints.js";
9
11
  import { ProcessManager } from "../../process-sessions.js";
@@ -33,8 +35,10 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
33
35
  const store = new SqliteWorkspaceStore(stateDir);
34
36
  const workspaces = new WorkspaceRegistry(config, store);
35
37
  const processSessions = new ProcessManager();
38
+ const auditStore = new ActivityAuditStore(stateDir);
39
+ const activityLifecycle = new ActivityLifecycle(auditStore);
36
40
  const codeIntelligence = new CodeIntelligenceManager(config, options.codeIntelligenceOptions);
37
- const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence);
41
+ const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle);
38
42
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
39
43
  const client = new Client({ name: "forgerelay-code-intelligence-test-client", version: "1.0.0" });
40
44
  await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
@@ -47,6 +51,7 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
47
51
  await server.close();
48
52
  await codeIntelligence.shutdown();
49
53
  processSessions.shutdown();
54
+ auditStore.close();
50
55
  store.close();
51
56
  };
52
57
  t.after(async () => {
package/dist/server.js CHANGED
@@ -15,6 +15,8 @@ import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@mode
15
15
  import express from "express";
16
16
  import * as z from "zod/v4";
17
17
  import { applyPatch } from "./apply-patch.js";
18
+ import { ActivityAuditStore } from "./activity/audit-store.js";
19
+ import { ActivityLifecycle } from "./activity/lifecycle.js";
18
20
  import { buildCapabilityFingerprint } from "./capabilities.js";
19
21
  import { CapabilityError, createCapabilityRegistry, } from "./capability-registry.js";
20
22
  import { deletePath, renamePath } from "./file-mutations.js";
@@ -597,7 +599,77 @@ async function reviewWorkspaceChanges(reviewCheckpoints, workspace) {
597
599
  function toolResultIsError(result) {
598
600
  return typeof result === "object" && result !== null && result.isError === true;
599
601
  }
600
- function registerProcessTools(server, config, workspaces, processSessions, hooks) {
602
+ function workspaceActivitySnapshot(workspace) {
603
+ return {
604
+ id: workspace.id,
605
+ root: workspace.root,
606
+ mode: workspace.mode,
607
+ ...(workspace.sourceRoot ? { sourceRoot: workspace.sourceRoot } : {}),
608
+ ...(workspace.worktree?.branch ? { branch: workspace.worktree.branch } : {}),
609
+ ...(workspace.worktree?.targetBranch ? { targetBranch: workspace.worktree.targetBranch } : {}),
610
+ };
611
+ }
612
+ function activityFailureMessage(result) {
613
+ if (typeof result !== "object" || result === null)
614
+ return "Tool returned a failed result.";
615
+ const record = result;
616
+ if (Array.isArray(record.content)) {
617
+ const text = record.content
618
+ .map((entry) => {
619
+ if (typeof entry !== "object" || entry === null)
620
+ return "";
621
+ const value = entry.text;
622
+ return typeof value === "string" ? value : "";
623
+ })
624
+ .filter(Boolean)
625
+ .join("\n");
626
+ if (text)
627
+ return text;
628
+ }
629
+ if (typeof record.structuredContent === "object" && record.structuredContent !== null) {
630
+ const value = record.structuredContent.result;
631
+ if (typeof value === "string" && value)
632
+ return value;
633
+ }
634
+ return "Tool returned a failed result.";
635
+ }
636
+ function standardActivityOutcome(result) {
637
+ return toolResultIsError(result)
638
+ ? { type: "failed", error: activityFailureMessage(result) }
639
+ : { type: "succeeded" };
640
+ }
641
+ function processActivityOutcome(result) {
642
+ if (toolResultIsError(result))
643
+ return { type: "failed", error: activityFailureMessage(result) };
644
+ if (typeof result !== "object" || result === null)
645
+ return { type: "succeeded" };
646
+ const structured = result.structuredContent;
647
+ if (typeof structured !== "object" || structured === null)
648
+ return { type: "succeeded" };
649
+ const process = structured;
650
+ if (process.running === true)
651
+ return { type: "returned" };
652
+ if (process.timedOut === true ||
653
+ typeof process.signal === "string" ||
654
+ (typeof process.exitCode === "number" && process.exitCode !== 0)) {
655
+ return { type: "failed", error: activityFailureMessage(result) };
656
+ }
657
+ return { type: "succeeded" };
658
+ }
659
+ function runActivityTool(lifecycle, workspace, requestMeta, tool, request, operation, outcome = standardActivityOutcome) {
660
+ return lifecycle.run({
661
+ tool,
662
+ workspace: workspaceActivitySnapshot(workspace),
663
+ conversationScopeId: openAiConversationScopeId(requestMeta),
664
+ request,
665
+ operation,
666
+ outcome,
667
+ });
668
+ }
669
+ function runActivityToolWithHooks(lifecycle, hooks, workspace, requestMeta, request, hookOptions) {
670
+ return runActivityTool(lifecycle, workspace, requestMeta, hookOptions.tool, request, () => runToolWithHooks(hooks, hookOptions));
671
+ }
672
+ function registerProcessTools(server, config, workspaces, processSessions, hooks, activityLifecycle) {
601
673
  if (config.toolMode === "codex") {
602
674
  registerAppTool(server, "exec_command", {
603
675
  title: "Execute command",
@@ -643,61 +715,63 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
643
715
  }, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens }, extra) => {
644
716
  const workspace = workspaces.getWorkspace(workspaceId);
645
717
  let undeliveredProcessId;
646
- try {
647
- const result = await runToolWithHooks(hooks, {
648
- signal: extra.signal,
649
- tool: "exec_command",
650
- invocation: workspaceHookInvocation(workspace),
651
- payload: { command: cmd, workingDirectory: workingDirectory ?? "." },
652
- operation: async () => {
653
- const startedAt = performance.now();
654
- const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
655
- await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
656
- const snapshot = await processSessions.start({
657
- workspaceId,
658
- command: cmd,
659
- cwd,
660
- workspaceRoot: workspace.root,
661
- tty,
662
- columns,
663
- rows,
664
- yieldTimeMs,
665
- timeoutMs,
666
- maxOutputTokens,
667
- codexCi: true,
668
- signal: extra.signal,
669
- });
670
- undeliveredProcessId = snapshot.running ? snapshot.processId : undefined;
671
- logToolCall(config, {
672
- tool: "exec_command",
673
- ...workspaceLogContext(workspace, extra.sessionId),
674
- workingDirectory: workingDirectory ?? ".",
675
- command: cmd,
676
- commandLength: cmd.length,
677
- exitCode: snapshot.exitCode,
678
- running: snapshot.running,
679
- processId: snapshot.processId,
680
- success: snapshot.running || snapshot.exitCode === 0,
681
- durationMs: Math.round(performance.now() - startedAt),
682
- });
683
- return processToolResponse("exec_command", workspaceId, snapshot, {
684
- command: cmd,
685
- workingDirectory: workingDirectory ?? ".",
686
- running: snapshot.running,
687
- exitCode: snapshot.exitCode,
688
- wallTimeMs: snapshot.wallTimeMs,
689
- });
690
- },
691
- });
692
- extra.signal.throwIfAborted();
693
- return result;
694
- }
695
- catch (error) {
696
- if (undeliveredProcessId !== undefined) {
697
- processSessions.discardUndelivered(workspaceId, undeliveredProcessId);
718
+ return runActivityTool(activityLifecycle, workspace, extra._meta, "exec_command", { workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens }, async () => {
719
+ try {
720
+ const result = await runToolWithHooks(hooks, {
721
+ signal: extra.signal,
722
+ tool: "exec_command",
723
+ invocation: workspaceHookInvocation(workspace),
724
+ payload: { command: cmd, workingDirectory: workingDirectory ?? "." },
725
+ operation: async () => {
726
+ const startedAt = performance.now();
727
+ const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
728
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
729
+ const snapshot = await processSessions.start({
730
+ workspaceId,
731
+ command: cmd,
732
+ cwd,
733
+ workspaceRoot: workspace.root,
734
+ tty,
735
+ columns,
736
+ rows,
737
+ yieldTimeMs,
738
+ timeoutMs,
739
+ maxOutputTokens,
740
+ codexCi: true,
741
+ signal: extra.signal,
742
+ });
743
+ undeliveredProcessId = snapshot.running ? snapshot.processId : undefined;
744
+ logToolCall(config, {
745
+ tool: "exec_command",
746
+ ...workspaceLogContext(workspace, extra.sessionId),
747
+ workingDirectory: workingDirectory ?? ".",
748
+ command: cmd,
749
+ commandLength: cmd.length,
750
+ exitCode: snapshot.exitCode,
751
+ running: snapshot.running,
752
+ processId: snapshot.processId,
753
+ success: snapshot.running || snapshot.exitCode === 0,
754
+ durationMs: Math.round(performance.now() - startedAt),
755
+ });
756
+ return processToolResponse("exec_command", workspaceId, snapshot, {
757
+ command: cmd,
758
+ workingDirectory: workingDirectory ?? ".",
759
+ running: snapshot.running,
760
+ exitCode: snapshot.exitCode,
761
+ wallTimeMs: snapshot.wallTimeMs,
762
+ });
763
+ },
764
+ });
765
+ extra.signal.throwIfAborted();
766
+ return result;
698
767
  }
699
- throw error;
700
- }
768
+ catch (error) {
769
+ if (undeliveredProcessId !== undefined) {
770
+ processSessions.discardUndelivered(workspaceId, undeliveredProcessId);
771
+ }
772
+ throw error;
773
+ }
774
+ }, processActivityOutcome);
701
775
  });
702
776
  }
703
777
  if (config.toolMode !== "codex")
@@ -775,7 +849,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
775
849
  });
776
850
  });
777
851
  }
778
- export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence) {
852
+ export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle) {
779
853
  const toolDescriptions = buildToolDescriptions(config);
780
854
  const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result));
781
855
  const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
@@ -1287,7 +1361,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1287
1361
  }, async ({ workspaceId, name, action, arguments: capabilityArguments, file }, extra) => {
1288
1362
  const workspace = workspaces.getWorkspace(workspaceId);
1289
1363
  let changedPaths = [];
1290
- return runToolWithHooks(hooks, {
1364
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, extra._meta, { workspaceId, name, action, arguments: capabilityArguments, file }, {
1291
1365
  signal: extra.signal,
1292
1366
  tool: toolNames.capability,
1293
1367
  invocation: workspaceHookInvocation(workspace),
@@ -1510,7 +1584,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1510
1584
  annotations: { readOnlyHint: true },
1511
1585
  }, async ({ workspaceId, ...input }, extra) => {
1512
1586
  const workspace = workspaces.getWorkspace(workspaceId);
1513
- return runToolWithHooks(hooks, {
1587
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, extra._meta, { workspaceId, ...input }, {
1514
1588
  signal: extra.signal,
1515
1589
  tool: toolNames.read,
1516
1590
  invocation: workspaceHookInvocation(workspace),
@@ -1597,7 +1671,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1597
1671
  annotations: WRITE_TOOL_ANNOTATIONS,
1598
1672
  }, async ({ workspaceId, ...input }, extra) => {
1599
1673
  const workspace = workspaces.getWorkspace(workspaceId);
1600
- return runToolWithHooks(hooks, {
1674
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, extra._meta, { workspaceId, ...input }, {
1601
1675
  signal: extra.signal,
1602
1676
  tool: toolNames.write,
1603
1677
  invocation: workspaceHookInvocation(workspace),
@@ -1681,7 +1755,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1681
1755
  annotations: EDIT_TOOL_ANNOTATIONS,
1682
1756
  }, async ({ workspaceId, ...input }, extra) => {
1683
1757
  const workspace = workspaces.getWorkspace(workspaceId);
1684
- return runToolWithHooks(hooks, {
1758
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, extra._meta, { workspaceId, ...input }, {
1685
1759
  signal: extra.signal,
1686
1760
  tool: toolNames.edit,
1687
1761
  invocation: workspaceHookInvocation(workspace),
@@ -1758,7 +1832,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1758
1832
  annotations: EDIT_TOOL_ANNOTATIONS,
1759
1833
  }, async ({ workspaceId, path, newPath }, extra) => {
1760
1834
  const workspace = workspaces.getWorkspace(workspaceId);
1761
- return runToolWithHooks(hooks, {
1835
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, extra._meta, { workspaceId, path, newPath }, {
1762
1836
  signal: extra.signal,
1763
1837
  tool: toolNames.rename,
1764
1838
  invocation: workspaceHookInvocation(workspace),
@@ -1831,7 +1905,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1831
1905
  annotations: EDIT_TOOL_ANNOTATIONS,
1832
1906
  }, async ({ workspaceId, path, recursive }, extra) => {
1833
1907
  const workspace = workspaces.getWorkspace(workspaceId);
1834
- return runToolWithHooks(hooks, {
1908
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, extra._meta, { workspaceId, path, recursive }, {
1835
1909
  signal: extra.signal,
1836
1910
  tool: toolNames.delete,
1837
1911
  invocation: workspaceHookInvocation(workspace),
@@ -1912,7 +1986,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1912
1986
  annotations: EDIT_TOOL_ANNOTATIONS,
1913
1987
  }, async ({ workspaceId, patch }, extra) => {
1914
1988
  const workspace = workspaces.getWorkspace(workspaceId);
1915
- return runToolWithHooks(hooks, {
1989
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, extra._meta, { workspaceId, patch }, {
1916
1990
  signal: extra.signal,
1917
1991
  tool: "apply_patch",
1918
1992
  invocation: workspaceHookInvocation(workspace),
@@ -2048,69 +2122,82 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2048
2122
  throw new Error("bash action=run does not accept processId, input, or interrupt.");
2049
2123
  }
2050
2124
  let undeliveredProcessId;
2051
- try {
2052
- const result = await runToolWithHooks(hooks, {
2053
- signal: extra.signal,
2054
- tool: toolNames.shell,
2055
- invocation: workspaceHookInvocation(workspace),
2056
- payload: {
2057
- action,
2058
- command,
2059
- workingDirectory: workingDirectory ?? ".",
2060
- },
2061
- isFailure: toolResultIsError,
2062
- operation: async () => {
2063
- const startedAt = performance.now();
2064
- const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
2065
- await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
2066
- const snapshot = await processSessions.start({
2067
- workspaceId,
2068
- command,
2069
- cwd,
2070
- workspaceRoot: workspace.root,
2071
- tty,
2072
- columns,
2073
- rows,
2074
- yieldTimeMs,
2075
- timeoutMs,
2076
- maxOutputTokens,
2077
- signal: extra.signal,
2078
- });
2079
- undeliveredProcessId = snapshot.running ? snapshot.processId : undefined;
2080
- logToolCall(config, {
2081
- tool: toolNames.shell,
2082
- ...workspaceLogContext(workspace, extra.sessionId),
2083
- workingDirectory: workingDirectory ?? ".",
2084
- command,
2085
- commandLength: command.length,
2086
- exitCode: snapshot.exitCode,
2087
- running: snapshot.running,
2088
- processId: snapshot.processId,
2089
- success: snapshot.running || (snapshot.exitCode === 0 && !snapshot.signal),
2090
- durationMs: Math.round(performance.now() - startedAt),
2091
- });
2092
- const response = processToolResponse(toolNames.shell, workspaceId, snapshot, {
2125
+ return runActivityTool(activityLifecycle, workspace, extra._meta, toolNames.shell, {
2126
+ workspaceId,
2127
+ action,
2128
+ command,
2129
+ tty,
2130
+ columns,
2131
+ rows,
2132
+ workingDirectory,
2133
+ yieldTimeMs,
2134
+ timeoutMs,
2135
+ maxOutputTokens,
2136
+ }, async () => {
2137
+ try {
2138
+ const result = await runToolWithHooks(hooks, {
2139
+ signal: extra.signal,
2140
+ tool: toolNames.shell,
2141
+ invocation: workspaceHookInvocation(workspace),
2142
+ payload: {
2093
2143
  action,
2094
2144
  command,
2095
2145
  workingDirectory: workingDirectory ?? ".",
2096
- running: snapshot.running,
2097
- exitCode: snapshot.exitCode,
2098
- wallTimeMs: snapshot.wallTimeMs,
2099
- });
2100
- return !snapshot.running && (snapshot.signal || snapshot.exitCode !== 0)
2101
- ? { ...response, isError: true }
2102
- : response;
2103
- },
2104
- });
2105
- extra.signal.throwIfAborted();
2106
- return result;
2107
- }
2108
- catch (error) {
2109
- if (undeliveredProcessId !== undefined) {
2110
- processSessions.discardUndelivered(workspaceId, undeliveredProcessId);
2146
+ },
2147
+ isFailure: toolResultIsError,
2148
+ operation: async () => {
2149
+ const startedAt = performance.now();
2150
+ const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
2151
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
2152
+ const snapshot = await processSessions.start({
2153
+ workspaceId,
2154
+ command,
2155
+ cwd,
2156
+ workspaceRoot: workspace.root,
2157
+ tty,
2158
+ columns,
2159
+ rows,
2160
+ yieldTimeMs,
2161
+ timeoutMs,
2162
+ maxOutputTokens,
2163
+ signal: extra.signal,
2164
+ });
2165
+ undeliveredProcessId = snapshot.running ? snapshot.processId : undefined;
2166
+ logToolCall(config, {
2167
+ tool: toolNames.shell,
2168
+ ...workspaceLogContext(workspace, extra.sessionId),
2169
+ workingDirectory: workingDirectory ?? ".",
2170
+ command,
2171
+ commandLength: command.length,
2172
+ exitCode: snapshot.exitCode,
2173
+ running: snapshot.running,
2174
+ processId: snapshot.processId,
2175
+ success: snapshot.running || (snapshot.exitCode === 0 && !snapshot.signal),
2176
+ durationMs: Math.round(performance.now() - startedAt),
2177
+ });
2178
+ const response = processToolResponse(toolNames.shell, workspaceId, snapshot, {
2179
+ action,
2180
+ command,
2181
+ workingDirectory: workingDirectory ?? ".",
2182
+ running: snapshot.running,
2183
+ exitCode: snapshot.exitCode,
2184
+ wallTimeMs: snapshot.wallTimeMs,
2185
+ });
2186
+ return !snapshot.running && (snapshot.signal || snapshot.exitCode !== 0)
2187
+ ? { ...response, isError: true }
2188
+ : response;
2189
+ },
2190
+ });
2191
+ extra.signal.throwIfAborted();
2192
+ return result;
2111
2193
  }
2112
- throw error;
2113
- }
2194
+ catch (error) {
2195
+ if (undeliveredProcessId !== undefined) {
2196
+ processSessions.discardUndelivered(workspaceId, undeliveredProcessId);
2197
+ }
2198
+ throw error;
2199
+ }
2200
+ }, processActivityOutcome);
2114
2201
  }
2115
2202
  if (command !== undefined || workingDirectory !== undefined || tty !== undefined || timeoutMs !== undefined) {
2116
2203
  throw new Error("bash action=process does not accept command, workingDirectory, tty, or timeoutMs.");
@@ -2167,7 +2254,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2167
2254
  });
2168
2255
  });
2169
2256
  }
2170
- registerProcessTools(server, config, workspaces, processSessions, hooks);
2257
+ registerProcessTools(server, config, workspaces, processSessions, hooks, activityLifecycle);
2171
2258
  return server;
2172
2259
  }
2173
2260
  export function createServer(config = loadConfig(), options = {}) {
@@ -2193,6 +2280,8 @@ export function createServer(config = loadConfig(), options = {}) {
2193
2280
  });
2194
2281
  const workspaceStore = createWorkspaceStore(config.stateDir);
2195
2282
  const workspaces = new WorkspaceRegistry(config, workspaceStore);
2283
+ const activityAuditStore = new ActivityAuditStore(config.stateDir);
2284
+ const activityLifecycle = new ActivityLifecycle(activityAuditStore);
2196
2285
  const reviewCheckpoints = createReviewCheckpointManager();
2197
2286
  const processSessions = new ProcessManager();
2198
2287
  const codeIntelligence = new CodeIntelligenceManager(config);
@@ -2379,7 +2468,7 @@ export function createServer(config = loadConfig(), options = {}) {
2379
2468
  });
2380
2469
  }
2381
2470
  };
2382
- const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence);
2471
+ const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle);
2383
2472
  await server.connect(transport);
2384
2473
  }
2385
2474
  else {
@@ -2411,6 +2500,7 @@ export function createServer(config = loadConfig(), options = {}) {
2411
2500
  processSessions.shutdown();
2412
2501
  await codeIntelligence.shutdown();
2413
2502
  oauthProvider.close();
2503
+ activityAuditStore.close();
2414
2504
  workspaceStore.close?.();
2415
2505
  })();
2416
2506
  return closePromise;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.4.7",
3
+ "version": "0.5.1",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -44,7 +44,7 @@
44
44
  "release:parity": "node scripts/release-parity.mjs",
45
45
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
46
46
  "start": "node dist/cli.js serve",
47
- "test": "node --test scripts/release-proof.test.mjs && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
47
+ "test": "node --test scripts/release-proof.test.mjs && tsx src/config.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/activity/audit-store.test.ts && tsx src/activity/lifecycle.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
48
48
  "typecheck": "tsc -p tsconfig.json --noEmit",
49
49
  "release:check": "node scripts/release-version.mjs check",
50
50
  "release:tag-check": "node scripts/release-version.mjs tag",