@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 +11 -0
- package/dist/activity/bash-output-store.js +127 -0
- package/dist/activity/lifecycle.js +57 -30
- package/dist/activity/process-output-audit.js +1 -0
- package/dist/db/migrations.js +48 -0
- package/dist/db/schema.js +36 -1
- package/dist/lsp/test-support/server-fixture.js +5 -2
- package/dist/process-sessions.js +50 -8
- package/dist/server.js +199 -25
- package/package.json +2 -2
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 {};
|
package/dist/db/migrations.js
CHANGED
|
@@ -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
|
};
|
package/dist/process-sessions.js
CHANGED
|
@@ -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
|
|
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,
|
|
430
|
-
child.stderr.on("data", (data) => this.append(processEntry,
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
489
|
-
return
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|