@akira-tl/forgerelay 0.5.0 → 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,27 @@ 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
+
18
+ ## [0.5.1] - 2026-08-14
19
+
20
+ ### Added
21
+
22
+ - 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.
23
+
24
+ ### Fixed
25
+
26
+ - 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.
27
+
7
28
  ## [0.5.0] - 2026-08-14
8
29
 
9
30
  ### Added
@@ -66,6 +66,11 @@ export class ActivityAuditStore {
66
66
  result = event.result;
67
67
  error = undefined;
68
68
  break;
69
+ case "returned":
70
+ state = "returned";
71
+ result = event.result;
72
+ error = undefined;
73
+ break;
69
74
  case "failed":
70
75
  state = "failed";
71
76
  result = event.result;
@@ -177,6 +182,12 @@ function rowToEvent(row) {
177
182
  type: "succeeded",
178
183
  result: parseJson(row.result_json),
179
184
  };
185
+ case "returned":
186
+ return {
187
+ ...base,
188
+ type: "returned",
189
+ result: parseJson(row.result_json),
190
+ };
180
191
  case "failed":
181
192
  if (!row.error)
182
193
  throw new Error(`Activity audit failed event ${row.id} is missing an error.`);
@@ -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
+ }
@@ -0,0 +1,138 @@
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
+ 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
+ }
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) {
45
+ const activityId = options.activityId ?? this.activityId();
46
+ const turnId = options.turnId ?? this.turnId();
47
+ const request = normalizeAuditValue(options.request);
48
+ this.auditStore.append({
49
+ type: "started",
50
+ activityId,
51
+ turnId,
52
+ ...(options.conversationScopeId ? { conversationScopeId: options.conversationScopeId } : {}),
53
+ tool: options.tool,
54
+ workspace: options.workspace,
55
+ ...(request !== undefined ? { request } : {}),
56
+ });
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;
82
+ }
83
+ }
84
+ }
85
+ function newActivityId() {
86
+ return `act_${randomUUID().replaceAll("-", "")}`;
87
+ }
88
+ function newTurnId() {
89
+ return `turn_${randomUUID().replaceAll("-", "")}`;
90
+ }
91
+ export function normalizeAuditValue(value) {
92
+ return normalizeAuditValueInternal(value, new WeakSet());
93
+ }
94
+ function normalizeAuditValueInternal(value, seen) {
95
+ if (value === undefined || typeof value === "function" || typeof value === "symbol")
96
+ return undefined;
97
+ if (value === null || typeof value === "string" || typeof value === "boolean")
98
+ return value;
99
+ if (typeof value === "number")
100
+ return Number.isFinite(value) ? value : String(value);
101
+ if (typeof value === "bigint")
102
+ return value.toString();
103
+ if (value instanceof Date)
104
+ return value.toISOString();
105
+ if (value instanceof Error) {
106
+ return {
107
+ name: value.name,
108
+ message: value.message,
109
+ };
110
+ }
111
+ if (value instanceof Uint8Array) {
112
+ return {
113
+ type: "bytes",
114
+ encoding: "base64",
115
+ data: Buffer.from(value).toString("base64"),
116
+ };
117
+ }
118
+ if (Array.isArray(value)) {
119
+ return value.map((entry) => normalizeAuditValueInternal(entry, seen) ?? null);
120
+ }
121
+ if (typeof value !== "object")
122
+ return String(value);
123
+ if (seen.has(value))
124
+ return "[Circular]";
125
+ seen.add(value);
126
+ try {
127
+ const normalized = {};
128
+ for (const [key, entry] of Object.entries(value)) {
129
+ const next = normalizeAuditValueInternal(entry, seen);
130
+ if (next !== undefined)
131
+ normalized[key] = next;
132
+ }
133
+ return normalized;
134
+ }
135
+ finally {
136
+ seen.delete(value);
137
+ }
138
+ }
@@ -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"),
@@ -4,6 +4,9 @@ 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 { BashOutputStore } from "../../activity/bash-output-store.js";
9
+ import { ActivityLifecycle } from "../../activity/lifecycle.js";
7
10
  import { loadConfig } from "../../config.js";
8
11
  import { createReviewCheckpointManager } from "../../review-checkpoints.js";
9
12
  import { ProcessManager } from "../../process-sessions.js";
@@ -32,9 +35,12 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
32
35
  });
33
36
  const store = new SqliteWorkspaceStore(stateDir);
34
37
  const workspaces = new WorkspaceRegistry(config, store);
35
- const processSessions = new ProcessManager();
38
+ const auditStore = new ActivityAuditStore(stateDir);
39
+ const bashOutputStore = new BashOutputStore(stateDir);
40
+ const processSessions = new ProcessManager({ outputAudit: bashOutputStore });
41
+ const activityLifecycle = new ActivityLifecycle(auditStore);
36
42
  const codeIntelligence = new CodeIntelligenceManager(config, options.codeIntelligenceOptions);
37
- const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence);
43
+ const server = createMcpServer(config, workspaces, createReviewCheckpointManager(), processSessions, [], [], codeIntelligence, activityLifecycle, bashOutputStore);
38
44
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
39
45
  const client = new Client({ name: "forgerelay-code-intelligence-test-client", version: "1.0.0" });
40
46
  await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
@@ -47,6 +53,8 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
47
53
  await server.close();
48
54
  await codeIntelligence.shutdown();
49
55
  processSessions.shutdown();
56
+ bashOutputStore.close();
57
+ auditStore.close();
50
58
  store.close();
51
59
  };
52
60
  t.after(async () => {
@@ -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,