@akira-tl/forgerelay 0.5.1 → 0.5.2

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,17 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.2] - 2026-08-15
8
+
9
+ ### Added
10
+
11
+ - Added durable Bash output audit streams in ForgeRelay's local SQLite state: complete commands and original stdout/stderr/PTY output are retained under a stable `outputId`, can be retrieved after restart through regular Bash or Codex-compatible process tooling, and remain independent of the bounded in-memory process buffer.
12
+ - Background commands that were previously returned to the Host now produce a separate durable `bash_result` Activity exactly once when their completion is delivered, while the original Bash Activity remains historical `returned` state.
13
+
14
+ ### Changed
15
+
16
+ - Normal Bash, `exec_command`, and process-control responses now keep Agent context compact by returning only the final 10 output lines plus the stable full-output identifier; explicit output lookup returns the complete persisted process output.
17
+
7
18
  ## [0.5.1] - 2026-08-14
8
19
 
9
20
  ### Added
@@ -0,0 +1,127 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ import { openDatabase } from "../db/client.js";
4
+ export class BashOutputStore {
5
+ database;
6
+ now;
7
+ nextOutputId;
8
+ nextSequences = new Map();
9
+ constructor(stateDir, options = {}) {
10
+ this.database = openDatabase(stateDir);
11
+ this.now = options.now ?? (() => new Date());
12
+ this.nextOutputId = options.outputId ?? (() => `out_${randomUUID().replaceAll("-", "")}`);
13
+ }
14
+ begin(input) {
15
+ const outputId = this.nextOutputId();
16
+ const startedAt = this.now().toISOString();
17
+ this.database.sqlite.prepare(`insert into bash_output_streams (
18
+ id, activity_id, turn_id, conversation_scope_id, process_id,
19
+ workspace_id, workspace_root, command, tty, status, timed_out, started_at
20
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', 0, ?)`).run(outputId, input.activityId, input.turnId, input.conversationScopeId ?? null, input.processId, input.workspaceId, input.workspaceRoot, input.command, input.tty ? 1 : 0, startedAt);
21
+ this.nextSequences.set(outputId, 1);
22
+ return outputId;
23
+ }
24
+ append(outputId, channel, data) {
25
+ const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
26
+ if (bytes.length === 0)
27
+ return;
28
+ const sequence = this.nextSequence(outputId);
29
+ this.database.sqlite.prepare(`insert into bash_output_chunks (output_id, sequence, channel, data, created_at)
30
+ values (?, ?, ?, ?, ?)`).run(outputId, sequence, channel, bytes, this.now().toISOString());
31
+ }
32
+ markReturned(outputId) {
33
+ this.database.sqlite.prepare("update bash_output_streams set returned = 1 where id = ?").run(outputId);
34
+ }
35
+ claimCompletion(outputId) {
36
+ const claimedAt = this.now().toISOString();
37
+ const claimed = this.database.sqlite.prepare(`update bash_output_streams
38
+ set completion_claimed_at = ?
39
+ where id = ? and returned = 1 and status != 'running' and completion_claimed_at is null`).run(claimedAt, outputId);
40
+ return claimed.changes === 1 ? this.read(outputId) : undefined;
41
+ }
42
+ finish(outputId, input) {
43
+ const status = input.error || input.timedOut || input.signal || (input.exitCode !== undefined && input.exitCode !== 0)
44
+ ? "failed"
45
+ : "done";
46
+ this.database.sqlite.prepare(`update bash_output_streams
47
+ set status = ?, exit_code = ?, signal = ?, timed_out = ?, error = ?, finished_at = ?
48
+ where id = ?`).run(status, input.exitCode ?? null, input.signal ?? null, input.timedOut ? 1 : 0, input.error ?? null, this.now().toISOString(), outputId);
49
+ }
50
+ read(outputId) {
51
+ const stream = this.database.sqlite.prepare("select * from bash_output_streams where id = ?").get(outputId);
52
+ if (!stream)
53
+ return undefined;
54
+ const rows = this.database.sqlite.prepare(`select * from bash_output_chunks
55
+ where output_id = ?
56
+ order by sequence asc`).all(outputId);
57
+ const decoded = decodeChunks(rows);
58
+ return {
59
+ outputId: stream.id,
60
+ activityId: stream.activity_id,
61
+ turnId: stream.turn_id,
62
+ ...(stream.conversation_scope_id ? { conversationScopeId: stream.conversation_scope_id } : {}),
63
+ processId: stream.process_id,
64
+ workspaceId: stream.workspace_id,
65
+ workspaceRoot: stream.workspace_root,
66
+ command: stream.command,
67
+ tty: stream.tty === 1,
68
+ output: decoded.map((chunk) => chunk.data).join(""),
69
+ chunks: decoded,
70
+ status: isBashOutputStatus(stream.status) ? stream.status : "failed",
71
+ ...(stream.exit_code !== null ? { exitCode: stream.exit_code } : {}),
72
+ ...(stream.signal ? { signal: stream.signal } : {}),
73
+ timedOut: stream.timed_out === 1,
74
+ ...(stream.error ? { error: stream.error } : {}),
75
+ returned: stream.returned === 1,
76
+ startedAt: stream.started_at,
77
+ ...(stream.finished_at ? { finishedAt: stream.finished_at } : {}),
78
+ };
79
+ }
80
+ close() {
81
+ this.database.close();
82
+ }
83
+ nextSequence(outputId) {
84
+ const known = this.nextSequences.get(outputId);
85
+ if (known !== undefined) {
86
+ this.nextSequences.set(outputId, known + 1);
87
+ return known;
88
+ }
89
+ const row = this.database.sqlite.prepare("select coalesce(max(sequence), 0) as sequence from bash_output_chunks where output_id = ?").get(outputId);
90
+ const sequence = row.sequence + 1;
91
+ this.nextSequences.set(outputId, sequence + 1);
92
+ return sequence;
93
+ }
94
+ }
95
+ function decodeChunks(rows) {
96
+ const decoders = new Map();
97
+ const chunks = [];
98
+ const lastChunkIndex = new Map();
99
+ for (const row of rows) {
100
+ if (!isBashOutputChannel(row.channel)) {
101
+ throw new Error(`Unknown Bash output channel: ${row.channel}`);
102
+ }
103
+ const decoder = decoders.get(row.channel) ?? new StringDecoder("utf8");
104
+ decoders.set(row.channel, decoder);
105
+ const data = decoder.write(row.data);
106
+ chunks.push({ sequence: row.sequence, channel: row.channel, data });
107
+ lastChunkIndex.set(row.channel, chunks.length - 1);
108
+ }
109
+ for (const [channel, decoder] of decoders) {
110
+ const tail = decoder.end();
111
+ if (!tail)
112
+ continue;
113
+ const index = lastChunkIndex.get(channel);
114
+ if (index === undefined)
115
+ continue;
116
+ const chunk = chunks[index];
117
+ if (chunk)
118
+ chunk.data += tail;
119
+ }
120
+ return chunks;
121
+ }
122
+ function isBashOutputChannel(value) {
123
+ return value === "stdout" || value === "stderr" || value === "pty" || value === "process";
124
+ }
125
+ function isBashOutputStatus(value) {
126
+ return value === "running" || value === "done" || value === "failed";
127
+ }
@@ -9,7 +9,39 @@ export class ActivityLifecycle {
9
9
  this.activityId = options.activityId ?? newActivityId;
10
10
  this.turnId = options.turnId ?? newTurnId;
11
11
  }
12
+ record(options) {
13
+ const context = this.start(options);
14
+ this.finish(context.activityId, options.result, options.outcome);
15
+ return context;
16
+ }
17
+ recordLinked(options) {
18
+ const source = this.auditStore.getActivity(options.sourceActivityId);
19
+ if (!source)
20
+ throw new Error(`Unknown source Activity: ${options.sourceActivityId}`);
21
+ const { sourceActivityId: _sourceActivityId, ...record } = options;
22
+ return this.record({
23
+ ...record,
24
+ ...(source.conversationScopeId ? { conversationScopeId: source.conversationScopeId } : {}),
25
+ workspace: source.workspace,
26
+ });
27
+ }
12
28
  async run(options) {
29
+ const executionContext = this.start(options);
30
+ const activityId = executionContext.activityId;
31
+ try {
32
+ const result = await options.operation(executionContext);
33
+ this.finish(activityId, result, options.outcome?.(result) ?? { type: "succeeded" });
34
+ return result;
35
+ }
36
+ catch (error) {
37
+ const message = error instanceof Error ? error.message : String(error);
38
+ this.auditStore.append(error instanceof HookExecutionError && error.event === "BeforeTool"
39
+ ? { type: "blocked", activityId, error: message }
40
+ : { type: "failed", activityId, error: message });
41
+ throw error;
42
+ }
43
+ }
44
+ start(options) {
13
45
  const activityId = options.activityId ?? this.activityId();
14
46
  const turnId = options.turnId ?? this.turnId();
15
47
  const request = normalizeAuditValue(options.request);
@@ -22,36 +54,31 @@ export class ActivityLifecycle {
22
54
  workspace: options.workspace,
23
55
  ...(request !== undefined ? { request } : {}),
24
56
  });
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;
57
+ return {
58
+ activityId,
59
+ turnId,
60
+ ...(options.conversationScopeId ? { conversationScopeId: options.conversationScopeId } : {}),
61
+ };
62
+ }
63
+ finish(activityId, result, outcome) {
64
+ const normalizedResult = normalizeAuditValue(result);
65
+ switch (outcome.type) {
66
+ case "succeeded":
67
+ case "returned":
68
+ this.auditStore.append({
69
+ type: outcome.type,
70
+ activityId,
71
+ ...(normalizedResult !== undefined ? { result: normalizedResult } : {}),
72
+ });
73
+ break;
74
+ case "failed":
75
+ this.auditStore.append({
76
+ type: "failed",
77
+ activityId,
78
+ ...(normalizedResult !== undefined ? { result: normalizedResult } : {}),
79
+ error: outcome.error,
80
+ });
81
+ break;
55
82
  }
56
83
  }
57
84
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -39,6 +39,11 @@ const migrations = [
39
39
  name: "activity-audit",
40
40
  up: migrateActivityAudit,
41
41
  },
42
+ {
43
+ version: 9,
44
+ name: "bash-output-audit",
45
+ up: migrateBashOutputAudit,
46
+ },
42
47
  ];
43
48
  export function migrateDatabase(sqlite) {
44
49
  const migrate = sqlite.transaction(() => {
@@ -248,6 +253,49 @@ function migrateActivityAudit(sqlite) {
248
253
  on activity_audit_events(created_at);
249
254
  `);
250
255
  }
256
+ function migrateBashOutputAudit(sqlite) {
257
+ sqlite.exec(`
258
+ create table if not exists bash_output_streams (
259
+ id text primary key,
260
+ activity_id text not null,
261
+ turn_id text not null,
262
+ conversation_scope_id text,
263
+ process_id integer not null,
264
+ workspace_id text not null,
265
+ workspace_root text not null,
266
+ command text not null,
267
+ tty integer not null default 0,
268
+ status text not null default 'running',
269
+ exit_code integer,
270
+ signal text,
271
+ timed_out integer not null default 0,
272
+ error text,
273
+ returned integer not null default 0,
274
+ completion_claimed_at text,
275
+ started_at text not null,
276
+ finished_at text
277
+ );
278
+
279
+ create index if not exists bash_output_streams_activity_idx
280
+ on bash_output_streams(activity_id);
281
+
282
+ create index if not exists bash_output_streams_workspace_idx
283
+ on bash_output_streams(workspace_id, started_at);
284
+
285
+ create table if not exists bash_output_chunks (
286
+ output_id text not null,
287
+ sequence integer not null,
288
+ channel text not null,
289
+ data blob not null,
290
+ created_at text not null,
291
+ primary key (output_id, sequence),
292
+ foreign key (output_id) references bash_output_streams(id) on delete cascade
293
+ );
294
+
295
+ create index if not exists bash_output_chunks_output_idx
296
+ on bash_output_chunks(output_id, sequence);
297
+ `);
298
+ }
251
299
  function addColumnIfMissing(sqlite, table, column, definition) {
252
300
  const columns = sqlite.prepare(`pragma table_info(${table})`).all();
253
301
  if (columns.some((existingColumn) => existingColumn.name === column))
package/dist/db/schema.js CHANGED
@@ -1,4 +1,4 @@
1
- import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
1
+ import { blob, 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(),
@@ -97,6 +97,41 @@ export const activityAuditEvents = sqliteTable("activity_audit_events", {
97
97
  index("activity_audit_events_turn_idx").on(table.turnId, table.createdAt),
98
98
  index("activity_audit_events_created_idx").on(table.createdAt),
99
99
  ]);
100
+ export const bashOutputStreams = sqliteTable("bash_output_streams", {
101
+ id: text("id").primaryKey(),
102
+ activityId: text("activity_id").notNull(),
103
+ turnId: text("turn_id").notNull(),
104
+ conversationScopeId: text("conversation_scope_id"),
105
+ processId: integer("process_id").notNull(),
106
+ workspaceId: text("workspace_id").notNull(),
107
+ workspaceRoot: text("workspace_root").notNull(),
108
+ command: text("command").notNull(),
109
+ tty: integer("tty", { mode: "boolean" }).notNull().default(false),
110
+ status: text("status").notNull().default("running"),
111
+ exitCode: integer("exit_code"),
112
+ signal: text("signal"),
113
+ timedOut: integer("timed_out", { mode: "boolean" }).notNull().default(false),
114
+ error: text("error"),
115
+ returned: integer("returned", { mode: "boolean" }).notNull().default(false),
116
+ completionClaimedAt: text("completion_claimed_at"),
117
+ startedAt: text("started_at").notNull(),
118
+ finishedAt: text("finished_at"),
119
+ }, (table) => [
120
+ index("bash_output_streams_activity_idx").on(table.activityId),
121
+ index("bash_output_streams_workspace_idx").on(table.workspaceId, table.startedAt),
122
+ ]);
123
+ export const bashOutputChunks = sqliteTable("bash_output_chunks", {
124
+ outputId: text("output_id")
125
+ .notNull()
126
+ .references(() => bashOutputStreams.id, { onDelete: "cascade" }),
127
+ sequence: integer("sequence").notNull(),
128
+ channel: text("channel").notNull(),
129
+ data: blob("data", { mode: "buffer" }).notNull(),
130
+ createdAt: text("created_at").notNull(),
131
+ }, (table) => [
132
+ primaryKey({ columns: [table.outputId, table.sequence] }),
133
+ index("bash_output_chunks_output_idx").on(table.outputId, table.sequence),
134
+ ]);
100
135
  export const localAgentSessions = sqliteTable("local_agent_sessions", {
101
136
  id: text("id").primaryKey(),
102
137
  workspaceId: text("workspace_id"),
@@ -5,6 +5,7 @@ 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
7
  import { ActivityAuditStore } from "../../activity/audit-store.js";
8
+ import { BashOutputStore } from "../../activity/bash-output-store.js";
8
9
  import { ActivityLifecycle } from "../../activity/lifecycle.js";
9
10
  import { loadConfig } from "../../config.js";
10
11
  import { createReviewCheckpointManager } from "../../review-checkpoints.js";
@@ -34,11 +35,12 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
34
35
  });
35
36
  const store = new SqliteWorkspaceStore(stateDir);
36
37
  const workspaces = new WorkspaceRegistry(config, store);
37
- const processSessions = new ProcessManager();
38
38
  const auditStore = new ActivityAuditStore(stateDir);
39
+ const bashOutputStore = new BashOutputStore(stateDir);
40
+ const processSessions = new ProcessManager({ outputAudit: bashOutputStore });
39
41
  const activityLifecycle = new ActivityLifecycle(auditStore);
40
42
  const codeIntelligence = new CodeIntelligenceManager(config, options.codeIntelligenceOptions);
41
- const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle);
43
+ const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle, bashOutputStore);
42
44
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
43
45
  const client = new Client({ name: "forgerelay-code-intelligence-test-client", version: "1.0.0" });
44
46
  await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
@@ -51,6 +53,7 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
51
53
  await server.close();
52
54
  await codeIntelligence.shutdown();
53
55
  processSessions.shutdown();
56
+ bashOutputStore.close();
54
57
  auditStore.close();
55
58
  store.close();
56
59
  };
@@ -209,6 +209,7 @@ export class ProcessManager {
209
209
  completedProcessTtlMs;
210
210
  maxStartYieldMs;
211
211
  monotonicNow;
212
+ outputAudit;
212
213
  nextProcessId = 1;
213
214
  constructor(options = {}) {
214
215
  this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS;
@@ -225,6 +226,7 @@ export class ProcessManager {
225
226
  ?? COMPLETED_PROCESS_TTL_MS;
226
227
  this.maxStartYieldMs = options.maxStartYieldMs ?? MAX_START_YIELD_MS;
227
228
  this.monotonicNow = options.monotonicNow ?? (() => performance.now());
229
+ this.outputAudit = options.outputAudit;
228
230
  }
229
231
  async start(input) {
230
232
  input.signal?.throwIfAborted();
@@ -241,6 +243,10 @@ export class ProcessManager {
241
243
  this.startPipe(processEntry, input);
242
244
  }
243
245
  catch (error) {
246
+ this.finishAudit(processEntry, {
247
+ timedOut: false,
248
+ error: error instanceof Error ? error.message : String(error),
249
+ });
244
250
  this.processes.delete(processEntry.id);
245
251
  throw error;
246
252
  }
@@ -349,8 +355,14 @@ export class ProcessManager {
349
355
  shutdown() {
350
356
  for (const processEntry of this.processes.values()) {
351
357
  this.clearProcessTimers(processEntry);
352
- if (processEntry.running)
358
+ if (processEntry.running) {
359
+ this.finishAudit(processEntry, {
360
+ signal: "shutdown",
361
+ timedOut: false,
362
+ error: "ForgeRelay shut down while the process was still running.",
363
+ });
353
364
  processEntry.process?.kill("SIGTERM");
365
+ }
354
366
  }
355
367
  this.processes.clear();
356
368
  this.completedByWorkspace.clear();
@@ -389,10 +401,25 @@ export class ProcessManager {
389
401
  const exitPromise = new Promise((resolve) => {
390
402
  resolveExit = resolve;
391
403
  });
404
+ const id = this.nextProcessId++;
405
+ const outputId = this.outputAudit && input.audit
406
+ ? this.outputAudit.begin({
407
+ activityId: input.audit.activityId,
408
+ turnId: input.audit.turnId,
409
+ ...(input.audit.conversationScopeId ? { conversationScopeId: input.audit.conversationScopeId } : {}),
410
+ processId: id,
411
+ workspaceId: input.workspaceId,
412
+ workspaceRoot: input.workspaceRoot ?? input.cwd,
413
+ command: input.command,
414
+ tty: input.tty === true,
415
+ })
416
+ : undefined;
392
417
  return {
393
- id: this.nextProcessId++,
418
+ id,
394
419
  workspaceId: input.workspaceId,
395
420
  command: input.command,
421
+ ...(outputId ? { outputId } : {}),
422
+ auditFinished: false,
396
423
  startedAtMonotonic: this.monotonicNow(),
397
424
  columns: terminalSize(input.columns, DEFAULT_COLUMNS),
398
425
  rows: terminalSize(input.rows, DEFAULT_ROWS),
@@ -426,9 +453,9 @@ export class ProcessManager {
426
453
  kill: (signal = "SIGTERM") => terminateProcessTree(child, signal, detached),
427
454
  resize: input.tty ? () => undefined : undefined,
428
455
  };
429
- child.stdout.on("data", (data) => this.append(processEntry, data.toString("utf8")));
430
- child.stderr.on("data", (data) => this.append(processEntry, data.toString("utf8")));
431
- child.on("error", (error) => this.append(processEntry, `${error.message}\n`));
456
+ child.stdout.on("data", (data) => this.append(processEntry, "stdout", data));
457
+ child.stderr.on("data", (data) => this.append(processEntry, "stderr", data));
458
+ child.on("error", (error) => this.append(processEntry, "process", `${error.message}\n`));
432
459
  child.on("close", (code, signal) => this.finish(processEntry, code ?? undefined, signal ?? undefined));
433
460
  }
434
461
  async startPty(processEntry, input) {
@@ -462,7 +489,7 @@ export class ProcessManager {
462
489
  kill: (signal) => pty.kill(signal),
463
490
  resize: (columns, rows) => pty.resize(columns, rows),
464
491
  };
465
- pty.onData((data) => this.append(processEntry, data));
492
+ pty.onData((data) => this.append(processEntry, "pty", data));
466
493
  pty.onExit(({ exitCode, signal }) => {
467
494
  this.finish(processEntry, exitCode, signal === 0 ? undefined : String(signal));
468
495
  });
@@ -475,6 +502,11 @@ export class ProcessManager {
475
502
  processEntry.signal = signal;
476
503
  processEntry.finishedAtMonotonic = this.monotonicNow();
477
504
  processEntry.process = undefined;
505
+ this.finishAudit(processEntry, {
506
+ ...(exitCode !== undefined ? { exitCode } : {}),
507
+ ...(signal ? { signal } : {}),
508
+ timedOut: processEntry.timedOut,
509
+ });
478
510
  if (processEntry.executionTimeoutTimer)
479
511
  clearTimeout(processEntry.executionTimeoutTimer);
480
512
  if (processEntry.forceKillTimer)
@@ -512,8 +544,17 @@ export class ProcessManager {
512
544
  processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), remainingMs);
513
545
  processEntry.cleanupTimer.unref();
514
546
  }
515
- append(processEntry, output) {
516
- processEntry.buffer.append(output);
547
+ append(processEntry, channel, output) {
548
+ if (processEntry.outputId && !processEntry.auditFinished) {
549
+ this.outputAudit?.append(processEntry.outputId, channel, output);
550
+ }
551
+ processEntry.buffer.append(typeof output === "string" ? output : Buffer.from(output).toString("utf8"));
552
+ }
553
+ finishAudit(processEntry, input) {
554
+ if (!processEntry.outputId || processEntry.auditFinished)
555
+ return;
556
+ processEntry.auditFinished = true;
557
+ this.outputAudit?.finish(processEntry.outputId, input);
517
558
  }
518
559
  consume(processEntry, maxOutputTokens) {
519
560
  const limit = boundedInteger(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000);
@@ -526,6 +567,7 @@ export class ProcessManager {
526
567
  return {
527
568
  processId,
528
569
  sessionId: processId,
570
+ ...(processEntry.outputId ? { outputId: processEntry.outputId } : {}),
529
571
  output: buffered.output,
530
572
  outputTruncated: processEntry.outputWasTruncated,
531
573
  running: processEntry.running,
package/dist/server.js CHANGED
@@ -16,7 +16,8 @@ import express from "express";
16
16
  import * as z from "zod/v4";
17
17
  import { applyPatch } from "./apply-patch.js";
18
18
  import { ActivityAuditStore } from "./activity/audit-store.js";
19
- import { ActivityLifecycle } from "./activity/lifecycle.js";
19
+ import { BashOutputStore } from "./activity/bash-output-store.js";
20
+ import { ActivityLifecycle, } from "./activity/lifecycle.js";
20
21
  import { buildCapabilityFingerprint } from "./capabilities.js";
21
22
  import { CapabilityError, createCapabilityRegistry, } from "./capability-registry.js";
22
23
  import { deletePath, renamePath } from "./file-mutations.js";
@@ -468,6 +469,24 @@ async function readWorkspaceAppResource(config, requestedUri, transportSessionId
468
469
  throw error;
469
470
  }
470
471
  }
472
+ const PROCESS_RESPONSE_OUTPUT_LINES = 10;
473
+ function compactProcessOutput(output) {
474
+ if (!output)
475
+ return { output: "", truncated: false };
476
+ const trailingNewline = output.endsWith("\n");
477
+ const body = trailingNewline ? output.slice(0, -1) : output;
478
+ const lines = body.split("\n");
479
+ if (lines.length <= PROCESS_RESPONSE_OUTPUT_LINES)
480
+ return { output, truncated: false };
481
+ const compact = lines.slice(-PROCESS_RESPONSE_OUTPUT_LINES).join("\n");
482
+ return {
483
+ output: trailingNewline ? `${compact}\n` : compact,
484
+ truncated: true,
485
+ };
486
+ }
487
+ function outputIdNotice(outputId) {
488
+ return outputId ? `Full output ID: ${outputId}.` : "";
489
+ }
471
490
  function processResult(snapshot) {
472
491
  const status = snapshot.running
473
492
  ? `Process running with process ID ${snapshot.processId}.`
@@ -476,7 +495,8 @@ function processResult(snapshot) {
476
495
  : snapshot.signal
477
496
  ? `Process exited after signal ${snapshot.signal}.`
478
497
  : `Process exited with code ${snapshot.exitCode ?? "unknown"}.`;
479
- return snapshot.output ? `${snapshot.output.replace(/\n$/, "")}\n${status}` : status;
498
+ const compact = compactProcessOutput(snapshot.output).output.replace(/\n$/, "");
499
+ return [compact, status, outputIdNotice(snapshot.outputId)].filter(Boolean).join("\n");
480
500
  }
481
501
  function completedProcessResult(snapshot) {
482
502
  const status = snapshot.timedOut
@@ -485,12 +505,14 @@ function completedProcessResult(snapshot) {
485
505
  ? `Background process ${snapshot.processId} exited after signal ${snapshot.signal}.`
486
506
  : `Background process ${snapshot.processId} exited with code ${snapshot.exitCode ?? "unknown"}.`;
487
507
  const command = `Command: ${snapshot.command}`;
488
- const output = snapshot.output ? `\n${snapshot.output.replace(/\n$/, "")}` : "";
489
- return `${status}\n${command}${output}`;
508
+ const output = compactProcessOutput(snapshot.output).output.replace(/\n$/, "");
509
+ return [status, command, output, outputIdNotice(snapshot.outputId)].filter(Boolean).join("\n");
490
510
  }
491
- function attachCompletedProcessNotices(processSessions, workspaceId, result) {
511
+ function attachCompletedProcessNotices(processSessions, workspaceId, result, onCompleted) {
492
512
  if (result instanceof Error) {
493
513
  const completed = processSessions.takeCompleted(workspaceId);
514
+ for (const snapshot of completed)
515
+ onCompleted?.(snapshot);
494
516
  if (completed.length > 0) {
495
517
  result.message = [
496
518
  result.message,
@@ -513,6 +535,8 @@ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
513
535
  : undefined
514
536
  : undefined;
515
537
  const completed = processSessions.takeCompleted(workspaceId, undefined, currentProcessId);
538
+ for (const snapshot of completed)
539
+ onCompleted?.(snapshot);
516
540
  if (completed.length === 0)
517
541
  return result;
518
542
  return {
@@ -527,6 +551,7 @@ function processOutputSchema() {
527
551
  return resultOutputSchema({
528
552
  processId: z.number().int().positive().optional().describe("Canonical process handle for bash(action=\"process\") or the active command adapter."),
529
553
  sessionId: z.number().int().positive().optional().describe("Deprecated alias of processId for compatibility."),
554
+ outputId: z.string().optional().describe("Stable local audit identifier for retrieving the complete original process output."),
530
555
  running: z.boolean(),
531
556
  exitCode: z.number().int().optional(),
532
557
  signal: z.string().optional(),
@@ -543,9 +568,10 @@ function readForgeRelayVersion() {
543
568
  return packageJson.version;
544
569
  }
545
570
  function processToolResponse(tool, workspaceId, snapshot, summary) {
571
+ const compact = compactProcessOutput(snapshot.output);
546
572
  const result = processResult(snapshot);
547
573
  const content = [textBlock(result)];
548
- const outputSummary = textSummary(snapshot.output ? [textBlock(snapshot.output)] : []);
574
+ const outputSummary = textSummary(compact.output ? [textBlock(compact.output)] : []);
549
575
  return {
550
576
  content,
551
577
  _meta: {
@@ -560,15 +586,111 @@ function processToolResponse(tool, workspaceId, snapshot, summary) {
560
586
  result,
561
587
  processId: snapshot.processId,
562
588
  sessionId: snapshot.sessionId,
589
+ outputId: snapshot.outputId,
563
590
  running: snapshot.running,
564
591
  exitCode: snapshot.exitCode,
565
592
  signal: snapshot.signal,
566
593
  timedOut: snapshot.timedOut,
567
594
  wallTimeMs: snapshot.wallTimeMs,
568
- outputTruncated: snapshot.outputTruncated,
595
+ outputTruncated: snapshot.outputTruncated || compact.truncated,
596
+ },
597
+ };
598
+ }
599
+ function durableOutputResult(record) {
600
+ const status = record.status === "running"
601
+ ? `Process ${record.processId} is still running.`
602
+ : record.timedOut
603
+ ? `Process ${record.processId} timed out and was terminated.`
604
+ : record.signal
605
+ ? `Process ${record.processId} exited after signal ${record.signal}.`
606
+ : `Process ${record.processId} exited with code ${record.exitCode ?? "unknown"}.`;
607
+ return [record.output.replace(/\n$/, ""), status, `Full output ID: ${record.outputId}.`]
608
+ .filter(Boolean)
609
+ .join("\n");
610
+ }
611
+ function durableOutputResponse(tool, workspaceId, record) {
612
+ const result = durableOutputResult(record);
613
+ const content = [textBlock(result)];
614
+ const finishedAt = record.finishedAt ? Date.parse(record.finishedAt) : Date.now();
615
+ const startedAt = Date.parse(record.startedAt);
616
+ return {
617
+ content,
618
+ _meta: {
619
+ tool,
620
+ card: {
621
+ workspaceId,
622
+ summary: textSummary(record.output ? [textBlock(record.output)] : []),
623
+ payload: { content },
624
+ },
625
+ },
626
+ structuredContent: {
627
+ result,
628
+ processId: record.processId,
629
+ sessionId: record.processId,
630
+ outputId: record.outputId,
631
+ running: record.status === "running",
632
+ exitCode: record.exitCode,
633
+ signal: record.signal,
634
+ timedOut: record.timedOut,
635
+ wallTimeMs: Math.max(0, Number.isFinite(finishedAt - startedAt) ? finishedAt - startedAt : 0),
636
+ outputTruncated: false,
569
637
  },
570
638
  };
571
639
  }
640
+ function markReturnedOutput(store, result) {
641
+ if (typeof result !== "object" || result === null)
642
+ return;
643
+ const structured = result.structuredContent;
644
+ if (typeof structured !== "object" || structured === null)
645
+ return;
646
+ const record = structured;
647
+ if (record.running === true && typeof record.outputId === "string") {
648
+ store.markReturned(record.outputId);
649
+ }
650
+ }
651
+ function readWorkspaceBashOutput(store, workspaceId, outputId) {
652
+ const record = store.read(outputId);
653
+ if (!record)
654
+ throw new Error(`Unknown Bash output: ${outputId}`);
655
+ if (record.workspaceId !== workspaceId) {
656
+ throw new Error(`Bash output ${outputId} does not belong to workspace ${workspaceId}.`);
657
+ }
658
+ return record;
659
+ }
660
+ function bashCompletionError(record) {
661
+ if (record.error)
662
+ return record.error;
663
+ if (record.timedOut)
664
+ return `Background process ${record.processId} timed out.`;
665
+ if (record.signal)
666
+ return `Background process ${record.processId} exited after signal ${record.signal}.`;
667
+ return `Background process ${record.processId} exited with code ${record.exitCode ?? "unknown"}.`;
668
+ }
669
+ function recordBashCompletion(lifecycle, store, outputId) {
670
+ if (!outputId)
671
+ return;
672
+ const completion = store.claimCompletion(outputId);
673
+ if (!completion)
674
+ return;
675
+ lifecycle.recordLinked({
676
+ sourceActivityId: completion.activityId,
677
+ tool: "bash_result",
678
+ request: {
679
+ processId: completion.processId,
680
+ outputId: completion.outputId,
681
+ },
682
+ result: {
683
+ processId: completion.processId,
684
+ outputId: completion.outputId,
685
+ exitCode: completion.exitCode,
686
+ signal: completion.signal,
687
+ timedOut: completion.timedOut,
688
+ },
689
+ outcome: completion.status === "failed"
690
+ ? { type: "failed", error: bashCompletionError(completion) }
691
+ : { type: "succeeded" },
692
+ });
693
+ }
572
694
  function workspaceHookInvocation(workspace) {
573
695
  return {
574
696
  workspaceId: workspace.id,
@@ -669,7 +791,7 @@ function runActivityTool(lifecycle, workspace, requestMeta, tool, request, opera
669
791
  function runActivityToolWithHooks(lifecycle, hooks, workspace, requestMeta, request, hookOptions) {
670
792
  return runActivityTool(lifecycle, workspace, requestMeta, hookOptions.tool, request, () => runToolWithHooks(hooks, hookOptions));
671
793
  }
672
- function registerProcessTools(server, config, workspaces, processSessions, hooks, activityLifecycle) {
794
+ function registerProcessTools(server, config, workspaces, processSessions, hooks, activityLifecycle, bashOutputStore) {
673
795
  if (config.toolMode === "codex") {
674
796
  registerAppTool(server, "exec_command", {
675
797
  title: "Execute command",
@@ -715,7 +837,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
715
837
  }, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens }, extra) => {
716
838
  const workspace = workspaces.getWorkspace(workspaceId);
717
839
  let undeliveredProcessId;
718
- return runActivityTool(activityLifecycle, workspace, extra._meta, "exec_command", { workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens }, async () => {
840
+ const activityResult = await runActivityTool(activityLifecycle, workspace, extra._meta, "exec_command", { workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens }, async (activityContext) => {
719
841
  try {
720
842
  const result = await runToolWithHooks(hooks, {
721
843
  signal: extra.signal,
@@ -739,6 +861,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
739
861
  maxOutputTokens,
740
862
  codexCi: true,
741
863
  signal: extra.signal,
864
+ audit: activityContext,
742
865
  });
743
866
  undeliveredProcessId = snapshot.running ? snapshot.processId : undefined;
744
867
  logToolCall(config, {
@@ -772,17 +895,20 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
772
895
  throw error;
773
896
  }
774
897
  }, processActivityOutcome);
898
+ markReturnedOutput(bashOutputStore, activityResult);
899
+ return activityResult;
775
900
  });
776
901
  }
777
902
  if (config.toolMode !== "codex")
778
903
  return;
779
904
  registerAppTool(server, "write_stdin", {
780
905
  title: "Write to process",
781
- description: "Poll or write characters to a running process returned by bash or exec_command. Omit chars or pass an empty string to poll. Waiting never kills the process; pass \\u0003 to explicitly send Ctrl-C.",
906
+ description: "Poll or write characters to a running process returned by exec_command, or retrieve complete durable process output by outputId. Omit chars or pass an empty string to poll. Waiting never kills the process; pass \\u0003 to explicitly send Ctrl-C.",
782
907
  inputSchema: {
783
908
  workspaceId: z.string().describe("Workspace identifier used to start the process."),
784
909
  processId: z.number().int().positive().optional().describe("Canonical process identifier returned by bash or exec_command."),
785
910
  sessionId: z.number().int().positive().optional().describe("Deprecated alias for processId. Retained for compatibility."),
911
+ outputId: z.string().optional().describe("Stable output identifier returned by exec_command. When supplied, retrieve the complete durable output instead of controlling a process."),
786
912
  chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."),
787
913
  columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."),
788
914
  rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."),
@@ -804,8 +930,21 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
804
930
  outputSchema: processOutputSchema(),
805
931
  ...toolWidgetDescriptorMeta(config, "shell"),
806
932
  annotations: SHELL_TOOL_ANNOTATIONS,
807
- }, async ({ workspaceId, processId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
933
+ }, async ({ workspaceId, processId, sessionId, outputId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
808
934
  const workspace = workspaces.getWorkspace(workspaceId);
935
+ if (outputId !== undefined) {
936
+ if (processId !== undefined || sessionId !== undefined || chars !== undefined || columns !== undefined ||
937
+ rows !== undefined || yieldTimeMs !== undefined || maxOutputTokens !== undefined) {
938
+ throw new Error("write_stdin outputId lookup cannot be combined with process control fields.");
939
+ }
940
+ return runToolWithHooks(hooks, {
941
+ signal: extra.signal,
942
+ tool: "write_stdin",
943
+ invocation: workspaceHookInvocation(workspace),
944
+ payload: { outputId },
945
+ operation: async () => durableOutputResponse("write_stdin", workspaceId, readWorkspaceBashOutput(bashOutputStore, workspaceId, outputId)),
946
+ });
947
+ }
809
948
  const resolvedProcessId = resolveProcessId(processId, sessionId);
810
949
  return runToolWithHooks(hooks, {
811
950
  signal: extra.signal,
@@ -838,20 +977,24 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
838
977
  success: snapshot.running || snapshot.exitCode === 0,
839
978
  durationMs: Math.round(performance.now() - startedAt),
840
979
  });
841
- return processToolResponse("write_stdin", workspaceId, snapshot, {
980
+ const response = processToolResponse("write_stdin", workspaceId, snapshot, {
842
981
  processId: resolvedProcessId,
843
982
  charactersWritten: chars?.length ?? 0,
844
983
  running: snapshot.running,
845
984
  exitCode: snapshot.exitCode,
846
985
  wallTimeMs: snapshot.wallTimeMs,
847
986
  });
987
+ if (!snapshot.running) {
988
+ recordBashCompletion(activityLifecycle, bashOutputStore, snapshot.outputId);
989
+ }
990
+ return response;
848
991
  },
849
992
  });
850
993
  });
851
994
  }
852
- export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle) {
995
+ export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle, bashOutputStore) {
853
996
  const toolDescriptions = buildToolDescriptions(config);
854
- const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result));
997
+ const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result, (snapshot) => recordBashCompletion(activityLifecycle, bashOutputStore, snapshot.outputId)));
855
998
  const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
856
999
  const artifactDownloadAvailable = config.artifactsEnabled && isArtifactDownloadSupportedPlatform();
857
1000
  const reviewChangesAvailable = config.widgets === "changes";
@@ -2045,9 +2188,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2045
2188
  .string()
2046
2189
  .describe("Workspace identifier returned by open_workspace."),
2047
2190
  action: z
2048
- .enum(["run", "process"])
2191
+ .enum(["run", "process", "output"])
2049
2192
  .optional()
2050
- .describe("Defaults to run. Use process with a returned processId to poll, interact, resize, or interrupt a running command."),
2193
+ .describe("Defaults to run. Use process with a returned processId to poll/interact, or output with outputId to retrieve complete durable output."),
2051
2194
  command: z
2052
2195
  .string()
2053
2196
  .optional()
@@ -2058,6 +2201,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2058
2201
  .positive()
2059
2202
  .optional()
2060
2203
  .describe("Process identifier returned by a previous bash action=run call. Required for action=process."),
2204
+ outputId: z
2205
+ .string()
2206
+ .optional()
2207
+ .describe("Stable output identifier returned by a Bash run. Required for action=output."),
2061
2208
  input: z
2062
2209
  .string()
2063
2210
  .optional()
@@ -2113,16 +2260,16 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2113
2260
  outputSchema: processOutputSchema(),
2114
2261
  ...toolWidgetDescriptorMeta(config, "shell"),
2115
2262
  annotations: SHELL_TOOL_ANNOTATIONS,
2116
- }, async ({ workspaceId, action = "run", command, processId, input, interrupt, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens, }, extra) => {
2263
+ }, async ({ workspaceId, action = "run", command, processId, outputId, input, interrupt, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens, }, extra) => {
2117
2264
  const workspace = workspaces.getWorkspace(workspaceId);
2118
2265
  if (action === "run") {
2119
2266
  if (!command)
2120
2267
  throw new Error("bash action=run requires command.");
2121
- if (processId !== undefined || input !== undefined || interrupt !== undefined) {
2122
- throw new Error("bash action=run does not accept processId, input, or interrupt.");
2268
+ if (processId !== undefined || outputId !== undefined || input !== undefined || interrupt !== undefined) {
2269
+ throw new Error("bash action=run does not accept processId, outputId, input, or interrupt.");
2123
2270
  }
2124
2271
  let undeliveredProcessId;
2125
- return runActivityTool(activityLifecycle, workspace, extra._meta, toolNames.shell, {
2272
+ const activityResult = await runActivityTool(activityLifecycle, workspace, extra._meta, toolNames.shell, {
2126
2273
  workspaceId,
2127
2274
  action,
2128
2275
  command,
@@ -2133,7 +2280,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2133
2280
  yieldTimeMs,
2134
2281
  timeoutMs,
2135
2282
  maxOutputTokens,
2136
- }, async () => {
2283
+ }, async (activityContext) => {
2137
2284
  try {
2138
2285
  const result = await runToolWithHooks(hooks, {
2139
2286
  signal: extra.signal,
@@ -2161,6 +2308,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2161
2308
  timeoutMs,
2162
2309
  maxOutputTokens,
2163
2310
  signal: extra.signal,
2311
+ audit: activityContext,
2164
2312
  });
2165
2313
  undeliveredProcessId = snapshot.running ? snapshot.processId : undefined;
2166
2314
  logToolCall(config, {
@@ -2198,7 +2346,27 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2198
2346
  throw error;
2199
2347
  }
2200
2348
  }, processActivityOutcome);
2349
+ markReturnedOutput(bashOutputStore, activityResult);
2350
+ return activityResult;
2201
2351
  }
2352
+ if (action === "output") {
2353
+ if (!outputId)
2354
+ throw new Error("bash action=output requires outputId.");
2355
+ if (command !== undefined || processId !== undefined || input !== undefined || interrupt !== undefined ||
2356
+ tty !== undefined || columns !== undefined || rows !== undefined || workingDirectory !== undefined ||
2357
+ yieldTimeMs !== undefined || timeoutMs !== undefined || maxOutputTokens !== undefined) {
2358
+ throw new Error("bash action=output accepts only workspaceId and outputId.");
2359
+ }
2360
+ return runToolWithHooks(hooks, {
2361
+ signal: extra.signal,
2362
+ tool: toolNames.shell,
2363
+ invocation: workspaceHookInvocation(workspace),
2364
+ payload: { action, outputId },
2365
+ operation: async () => durableOutputResponse(toolNames.shell, workspaceId, readWorkspaceBashOutput(bashOutputStore, workspaceId, outputId)),
2366
+ });
2367
+ }
2368
+ if (outputId !== undefined)
2369
+ throw new Error("bash action=process does not accept outputId.");
2202
2370
  if (command !== undefined || workingDirectory !== undefined || tty !== undefined || timeoutMs !== undefined) {
2203
2371
  throw new Error("bash action=process does not accept command, workingDirectory, tty, or timeoutMs.");
2204
2372
  }
@@ -2241,7 +2409,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2241
2409
  success: snapshot.running || snapshot.exitCode === 0,
2242
2410
  durationMs: Math.round(performance.now() - startedAt),
2243
2411
  });
2244
- return processToolResponse(toolNames.shell, workspaceId, snapshot, {
2412
+ const response = processToolResponse(toolNames.shell, workspaceId, snapshot, {
2245
2413
  action,
2246
2414
  processId,
2247
2415
  inputLength: input?.length ?? 0,
@@ -2250,11 +2418,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2250
2418
  exitCode: snapshot.exitCode,
2251
2419
  wallTimeMs: snapshot.wallTimeMs,
2252
2420
  });
2421
+ if (!snapshot.running) {
2422
+ recordBashCompletion(activityLifecycle, bashOutputStore, snapshot.outputId);
2423
+ }
2424
+ return response;
2253
2425
  },
2254
2426
  });
2255
2427
  });
2256
2428
  }
2257
- registerProcessTools(server, config, workspaces, processSessions, hooks, activityLifecycle);
2429
+ registerProcessTools(server, config, workspaces, processSessions, hooks, activityLifecycle, bashOutputStore);
2258
2430
  return server;
2259
2431
  }
2260
2432
  export function createServer(config = loadConfig(), options = {}) {
@@ -2282,8 +2454,9 @@ export function createServer(config = loadConfig(), options = {}) {
2282
2454
  const workspaces = new WorkspaceRegistry(config, workspaceStore);
2283
2455
  const activityAuditStore = new ActivityAuditStore(config.stateDir);
2284
2456
  const activityLifecycle = new ActivityLifecycle(activityAuditStore);
2457
+ const bashOutputStore = new BashOutputStore(config.stateDir);
2285
2458
  const reviewCheckpoints = createReviewCheckpointManager();
2286
- const processSessions = new ProcessManager();
2459
+ const processSessions = new ProcessManager({ outputAudit: bashOutputStore });
2287
2460
  const codeIntelligence = new CodeIntelligenceManager(config);
2288
2461
  const localAgentProviders = config.subagents
2289
2462
  ? getLocalAgentProviderAvailabilitySnapshot()
@@ -2468,7 +2641,7 @@ export function createServer(config = loadConfig(), options = {}) {
2468
2641
  });
2469
2642
  }
2470
2643
  };
2471
- const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle);
2644
+ const server = createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle, bashOutputStore);
2472
2645
  await server.connect(transport);
2473
2646
  }
2474
2647
  else {
@@ -2500,6 +2673,7 @@ export function createServer(config = loadConfig(), options = {}) {
2500
2673
  processSessions.shutdown();
2501
2674
  await codeIntelligence.shutdown();
2502
2675
  oauthProvider.close();
2676
+ bashOutputStore.close();
2503
2677
  activityAuditStore.close();
2504
2678
  workspaceStore.close?.();
2505
2679
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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/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",
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/bash-output-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",