@akira-tl/forgerelay 0.5.1 → 0.5.4

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,35 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.4] - 2026-08-15
8
+
9
+ ### Fixed
10
+
11
+ - Closed all Activity query SQLite handles before temporary-state cleanup so the backend acceptance suite also passes on Windows, where open database files cannot be unlinked.
12
+
13
+ ## [0.5.3] - 2026-08-15
14
+
15
+ ### Added
16
+
17
+ - Added durable Host Turn records and a stable Activity query projection backed by ForgeRelay's local SQLite audit history, with revision-based lightweight snapshots that remain queryable after ForgeRelay restarts.
18
+ - Added the production backend query contract for future MCP App UI: model-visible `activity_panel` establishes a Host Turn, while app-only `activity_snapshot`, `activity_detail`, and `activity_output` data sources expose summaries, selected lazy detail, and complete Bash output separately.
19
+
20
+ ### Changed
21
+
22
+ - Activity snapshots now use an explicit summary whitelist: read bodies, write/edit patches, full Bash commands/output, and capability-heavy payloads stay out of normal snapshots; rename/delete include complete path targets and are summary-complete without detail requests.
23
+ - Late Bash completion records use the current Host Turn for delivery while preserving the original Bash Activity's immutable returned history and Workspace audit snapshot.
24
+
25
+ ## [0.5.2] - 2026-08-15
26
+
27
+ ### Added
28
+
29
+ - 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.
30
+ - 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.
31
+
32
+ ### Changed
33
+
34
+ - 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.
35
+
7
36
  ## [0.5.1] - 2026-08-14
8
37
 
9
38
  ### Added
@@ -47,6 +47,24 @@ export class ActivityAuditStore {
47
47
  listEvents(activityId) {
48
48
  return this.readRows(activityId).map(rowToEvent);
49
49
  }
50
+ listActivitiesByTurn(turnId) {
51
+ const rows = this.database.sqlite.prepare(`select activity_id from activity_audit_events
52
+ where event_type = 'started' and turn_id = ?
53
+ order by rowid asc`).all(turnId);
54
+ return rows.flatMap(({ activity_id }) => {
55
+ const activity = this.getActivity(activity_id);
56
+ return activity ? [activity] : [];
57
+ });
58
+ }
59
+ turnRevision(turnId) {
60
+ const row = this.database.sqlite.prepare(`select count(*) as revision
61
+ from activity_audit_events
62
+ where activity_id in (
63
+ select activity_id from activity_audit_events
64
+ where event_type = 'started' and turn_id = ?
65
+ )`).get(turnId);
66
+ return row.revision;
67
+ }
50
68
  getActivity(activityId) {
51
69
  const events = this.listEvents(activityId);
52
70
  const started = events[0];
@@ -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,46 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { openDatabase } from "../db/client.js";
3
+ export class HostTurnStore {
4
+ database;
5
+ now;
6
+ nextTurnId;
7
+ constructor(stateDir, options = {}) {
8
+ this.database = openDatabase(stateDir);
9
+ this.now = options.now ?? (() => new Date());
10
+ this.nextTurnId = options.turnId ?? (() => `turn_${randomUUID().replaceAll("-", "")}`);
11
+ }
12
+ begin(conversationScopeId) {
13
+ const turnId = this.nextTurnId();
14
+ const createdAt = this.now().toISOString();
15
+ this.database.sqlite.prepare(`insert into activity_host_turns (turn_id, conversation_scope_id, created_at)
16
+ values (?, ?, ?)`).run(turnId, conversationScopeId ?? null, createdAt);
17
+ return {
18
+ turnId,
19
+ ...(conversationScopeId ? { conversationScopeId } : {}),
20
+ createdAt,
21
+ };
22
+ }
23
+ get(turnId) {
24
+ const row = this.database.sqlite.prepare("select * from activity_host_turns where turn_id = ?").get(turnId);
25
+ return row ? rowToTurn(row) : undefined;
26
+ }
27
+ current(conversationScopeId) {
28
+ if (!conversationScopeId)
29
+ return undefined;
30
+ const row = this.database.sqlite.prepare(`select * from activity_host_turns
31
+ where conversation_scope_id = ?
32
+ order by rowid desc
33
+ limit 1`).get(conversationScopeId);
34
+ return row ? rowToTurn(row) : undefined;
35
+ }
36
+ close() {
37
+ this.database.close();
38
+ }
39
+ }
40
+ function rowToTurn(row) {
41
+ return {
42
+ turnId: row.turn_id,
43
+ ...(row.conversation_scope_id ? { conversationScopeId: row.conversation_scope_id } : {}),
44
+ createdAt: row.created_at,
45
+ };
46
+ }
@@ -4,14 +4,50 @@ export class ActivityLifecycle {
4
4
  auditStore;
5
5
  activityId;
6
6
  turnId;
7
+ turnIdForConversation;
7
8
  constructor(auditStore, options = {}) {
8
9
  this.auditStore = auditStore;
9
10
  this.activityId = options.activityId ?? newActivityId;
10
11
  this.turnId = options.turnId ?? newTurnId;
12
+ this.turnIdForConversation = options.turnIdForConversation;
13
+ }
14
+ record(options) {
15
+ const context = this.start(options);
16
+ this.finish(context.activityId, options.result, options.outcome);
17
+ return context;
18
+ }
19
+ recordLinked(options) {
20
+ const source = this.auditStore.getActivity(options.sourceActivityId);
21
+ if (!source)
22
+ throw new Error(`Unknown source Activity: ${options.sourceActivityId}`);
23
+ const { sourceActivityId: _sourceActivityId, ...record } = options;
24
+ return this.record({
25
+ ...record,
26
+ ...(source.conversationScopeId ? { conversationScopeId: source.conversationScopeId } : {}),
27
+ workspace: source.workspace,
28
+ });
11
29
  }
12
30
  async run(options) {
31
+ const executionContext = this.start(options);
32
+ const activityId = executionContext.activityId;
33
+ try {
34
+ const result = await options.operation(executionContext);
35
+ this.finish(activityId, result, options.outcome?.(result) ?? { type: "succeeded" });
36
+ return result;
37
+ }
38
+ catch (error) {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ this.auditStore.append(error instanceof HookExecutionError && error.event === "BeforeTool"
41
+ ? { type: "blocked", activityId, error: message }
42
+ : { type: "failed", activityId, error: message });
43
+ throw error;
44
+ }
45
+ }
46
+ start(options) {
13
47
  const activityId = options.activityId ?? this.activityId();
14
- const turnId = options.turnId ?? this.turnId();
48
+ const turnId = options.turnId
49
+ ?? this.turnIdForConversation?.(options.conversationScopeId)
50
+ ?? this.turnId();
15
51
  const request = normalizeAuditValue(options.request);
16
52
  this.auditStore.append({
17
53
  type: "started",
@@ -22,36 +58,31 @@ export class ActivityLifecycle {
22
58
  workspace: options.workspace,
23
59
  ...(request !== undefined ? { request } : {}),
24
60
  });
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;
61
+ return {
62
+ activityId,
63
+ turnId,
64
+ ...(options.conversationScopeId ? { conversationScopeId: options.conversationScopeId } : {}),
65
+ };
66
+ }
67
+ finish(activityId, result, outcome) {
68
+ const normalizedResult = normalizeAuditValue(result);
69
+ switch (outcome.type) {
70
+ case "succeeded":
71
+ case "returned":
72
+ this.auditStore.append({
73
+ type: outcome.type,
74
+ activityId,
75
+ ...(normalizedResult !== undefined ? { result: normalizedResult } : {}),
76
+ });
77
+ break;
78
+ case "failed":
79
+ this.auditStore.append({
80
+ type: "failed",
81
+ activityId,
82
+ ...(normalizedResult !== undefined ? { result: normalizedResult } : {}),
83
+ error: outcome.error,
84
+ });
85
+ break;
55
86
  }
56
87
  }
57
88
  }
@@ -0,0 +1,129 @@
1
+ import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
2
+ import * as z from "zod/v4";
3
+ import { openAiConversationScopeId } from "../request-meta.js";
4
+ const READ_ONLY_ANNOTATIONS = {
5
+ readOnlyHint: true,
6
+ destructiveHint: false,
7
+ idempotentHint: true,
8
+ openWorldHint: false,
9
+ };
10
+ const activitySummarySchema = z.object({
11
+ activityId: z.string(),
12
+ tool: z.string(),
13
+ kind: z.string(),
14
+ status: z.enum(["working", "done", "error"]),
15
+ state: z.enum(["executing", "returned", "done", "failed", "blocked"]),
16
+ title: z.string(),
17
+ target: z.string(),
18
+ detailAvailable: z.boolean(),
19
+ workspaceId: z.string().optional(),
20
+ processId: z.number().int().positive().optional(),
21
+ outputId: z.string().optional(),
22
+ commandLength: z.number().int().nonnegative().optional(),
23
+ bashPhase: z.enum(["executing", "returned", "done", "error"]).optional(),
24
+ startedAt: z.string(),
25
+ finishedAt: z.string().optional(),
26
+ durationMs: z.number().nonnegative().optional(),
27
+ });
28
+ const snapshotOutputSchema = {
29
+ turnId: z.string(),
30
+ revision: z.number().int().nonnegative(),
31
+ changed: z.boolean(),
32
+ state: z.enum(["working", "done", "error"]),
33
+ activities: z.array(activitySummarySchema),
34
+ };
35
+ export function registerActivityQueryTools(server, queries) {
36
+ registerAppTool(server, "activity_panel", {
37
+ title: "Begin Activity Panel",
38
+ description: "Begin one ForgeRelay Host Turn lifecycle for subsequent project work. This orchestration call does not read or modify project files. Call it once before the first ForgeRelay work operation in a Host Turn that performs project work.",
39
+ inputSchema: {},
40
+ outputSchema: snapshotOutputSchema,
41
+ _meta: { ui: { visibility: ["model", "app"] } },
42
+ annotations: {
43
+ ...READ_ONLY_ANNOTATIONS,
44
+ idempotentHint: false,
45
+ },
46
+ }, async (_input, extra) => {
47
+ const snapshot = queries.beginTurn(openAiConversationScopeId(extra._meta));
48
+ return {
49
+ content: [{
50
+ type: "text",
51
+ text: `Started ForgeRelay Activity Panel Host Turn ${snapshot.turnId}.`,
52
+ }],
53
+ structuredContent: { ...snapshot },
54
+ };
55
+ });
56
+ registerAppTool(server, "activity_snapshot", {
57
+ title: "Read Activity snapshot",
58
+ description: "App-only data source for lightweight Activity summaries in one durable Host Turn.",
59
+ inputSchema: {
60
+ turnId: z.string(),
61
+ knownRevision: z.number().int().nonnegative().optional(),
62
+ },
63
+ outputSchema: snapshotOutputSchema,
64
+ _meta: { ui: { visibility: ["app"] } },
65
+ annotations: READ_ONLY_ANNOTATIONS,
66
+ }, async ({ turnId, knownRevision }) => {
67
+ const snapshot = queries.snapshot(turnId, knownRevision);
68
+ return {
69
+ content: [{
70
+ type: "text",
71
+ text: snapshot.changed
72
+ ? `Activity snapshot ${turnId} revision ${snapshot.revision}.`
73
+ : `Activity snapshot ${turnId} unchanged at revision ${snapshot.revision}.`,
74
+ }],
75
+ structuredContent: { ...snapshot },
76
+ };
77
+ });
78
+ registerAppTool(server, "activity_detail", {
79
+ title: "Read Activity detail",
80
+ description: "App-only lazy data source for one selected expandable Activity.",
81
+ inputSchema: {
82
+ turnId: z.string(),
83
+ activityId: z.string(),
84
+ },
85
+ outputSchema: {
86
+ activity: activitySummarySchema,
87
+ request: z.unknown().optional(),
88
+ result: z.unknown().optional(),
89
+ error: z.string().optional(),
90
+ },
91
+ _meta: { ui: { visibility: ["app"] } },
92
+ annotations: READ_ONLY_ANNOTATIONS,
93
+ }, async ({ turnId, activityId }) => {
94
+ const detail = queries.detail(turnId, activityId);
95
+ return {
96
+ content: [{ type: "text", text: `Activity detail ${activityId}.` }],
97
+ structuredContent: { ...detail },
98
+ };
99
+ });
100
+ registerAppTool(server, "activity_output", {
101
+ title: "Read Bash output",
102
+ description: "App-only lazy data source for complete durable Bash command/output by stable outputId.",
103
+ inputSchema: {
104
+ turnId: z.string(),
105
+ outputId: z.string(),
106
+ },
107
+ outputSchema: {
108
+ outputId: z.string(),
109
+ activityId: z.string(),
110
+ processId: z.number().int().positive(),
111
+ command: z.string(),
112
+ output: z.string(),
113
+ status: z.enum(["running", "done", "failed"]),
114
+ exitCode: z.number().int().optional(),
115
+ signal: z.string().optional(),
116
+ timedOut: z.boolean(),
117
+ startedAt: z.string(),
118
+ finishedAt: z.string().optional(),
119
+ },
120
+ _meta: { ui: { visibility: ["app"] } },
121
+ annotations: READ_ONLY_ANNOTATIONS,
122
+ }, async ({ turnId, outputId }) => {
123
+ const output = queries.bashOutput(turnId, outputId);
124
+ return {
125
+ content: [{ type: "text", text: `Bash output ${outputId}.` }],
126
+ structuredContent: { ...output },
127
+ };
128
+ });
129
+ }
@@ -0,0 +1 @@
1
+ export {};