@akira-tl/forgerelay 0.3.6 → 0.3.7

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,23 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.7] - 2026-08-10
8
+
9
+ ### Added
10
+
11
+ - Added debug-only runtime resource telemetry for RSS/V8 heap, MCP transport count, running/completed process counts, cached workspaces, and review checkpoint state so long-running deployments can identify which resource class is growing.
12
+
13
+ ### Changed
14
+
15
+ - Background `bash` completion state is now retained for at most five minutes, completed process handles are released immediately, completed notices are globally bounded, active processes have a global concurrency budget, and per-process retained output is smaller. High-output head/tail truncation no longer materializes whole strings as Unicode code-point arrays, sharply reducing transient heap growth and GC pressure.
16
+ - Abandoned MCP transport sessions and in-memory review checkpoint states now have hard capacity limits. Review state is also released when its logical workspace closes, while persisted Git checkpoint refs remain available for reconstruction.
17
+ - Workspace instruction discovery is now bounded and demand-driven: `open_workspace` scans only the workspace root and direct child directories, while deeper `AGENTS.md` / `CLAUDE.md` files are discovered along paths as the Agent first accesses them. Reads surface newly discovered instructions inline; mutation and shell calls stop before side effects and require a retry after newly discovered local instructions are applied.
18
+ - Workspace/session activity timestamps now use a small in-process write-behind cache. Hot `lastUsedAt` touches are coalesced and flushed to SQLite in one transaction at most every five minutes, with an explicit final flush during normal shutdown; semantic create/close/status writes remain immediate.
19
+
20
+ ### Fixed
21
+
22
+ - Expired, never-redeemed OAuth authorization codes are opportunistically evicted and cleared on provider shutdown instead of remaining in memory for the lifetime of the server.
23
+
7
24
  ## [0.3.6] - 2026-08-10
8
25
 
9
26
  ### Added
@@ -28,9 +28,9 @@ processId: <number>
28
28
 
29
29
  `action="run"` 与 `action="process"` 的参数不要混用。Process ownership 始终绑定原 `workspaceId`;未知或跨 workspace 的 `processId` 会被拒绝。
30
30
 
31
- 等待超时不会隐式 kill process。若没有必要立即等待,可以继续其他工作;进程完成后,ForgeRelay 会把 completion notice 一次性附加到同一 logical workspace 的后续 tool result
31
+ 等待超时不会隐式 kill process。若没有必要立即等待,可以继续其他工作;进程完成后,ForgeRelay 会把 completion notice 一次性附加到同一 logical workspace 的后续 tool result。未消费的 completed process notice 最多保留 5 分钟;进程退出时底层 ChildProcess/PTY handle 会立即释放。ForgeRelay 同时对 active process 与 completed notice 数量设置全局资源预算,达到 active process 上限时会拒绝启动新的命令,而不会擅自终止已有长任务。
32
32
 
33
- 不要因为暂时没有输出就重复启动相同长进程;先用返回的 `processId` poll
33
+ 不要因为暂时没有输出就重复启动相同长进程;先用返回的 `processId` poll。高输出命令会使用有界 head/tail buffer,不能把 ForgeRelay 当作无限历史日志存储。
34
34
 
35
35
  ## PTY / interactive commands
36
36
 
package/dist/logger.js CHANGED
@@ -113,6 +113,8 @@ function formatPrettyMessage(entry, options) {
113
113
  case "mcp_transport_session_close_failed":
114
114
  case "mcp_session_close_failed":
115
115
  return `transport session ${transportSessionPrefix(entry) ?? "unknown"} close -> ${style("red", "error", options)}`;
116
+ case "runtime_resources":
117
+ return formatRuntimeResources(entry);
116
118
  case "auth_denied":
117
119
  return `auth denied${entry.reason ? `: ${String(entry.reason)}` : ""}`;
118
120
  case "mcp_request_error":
@@ -212,6 +214,20 @@ function stableColorIndex(value) {
212
214
  }
213
215
  return (hash >>> 0) % WORKSPACE_PROJECT_COLORS.length;
214
216
  }
217
+ function formatRuntimeResources(entry) {
218
+ const rssMb = bytesToMegabytes(numberField(entry.rssBytes));
219
+ const heapUsedMb = bytesToMegabytes(numberField(entry.heapUsedBytes));
220
+ const heapTotalMb = bytesToMegabytes(numberField(entry.heapTotalBytes));
221
+ const transports = numberField(entry.mcpTransports) ?? 0;
222
+ const running = numberField(entry.processesRunning) ?? 0;
223
+ const completed = numberField(entry.processesCompleted) ?? 0;
224
+ const workspaces = numberField(entry.cachedWorkspaces) ?? 0;
225
+ const reviewStates = numberField(entry.reviewStates) ?? 0;
226
+ return `runtime rss=${rssMb}MB heap=${heapUsedMb}/${heapTotalMb}MB transports=${transports} processes=${running} running/${completed} completed workspaces=${workspaces} review=${reviewStates}`;
227
+ }
228
+ function bytesToMegabytes(value) {
229
+ return value === undefined ? 0 : Math.round(value / (1024 * 1024));
230
+ }
215
231
  function formatGenericMessage(entry) {
216
232
  const event = String(entry.event ?? "log");
217
233
  const detail = [entry.reason, entry.error]
@@ -1,8 +1,14 @@
1
1
  export class McpTransportRegistry {
2
2
  transports = new Map();
3
3
  now;
4
+ maxTransports;
4
5
  constructor(options = {}) {
5
6
  this.now = options.now ?? Date.now;
7
+ this.maxTransports = options.maxTransports ?? Number.POSITIVE_INFINITY;
8
+ if (this.maxTransports !== Number.POSITIVE_INFINITY &&
9
+ (!Number.isInteger(this.maxTransports) || this.maxTransports < 1)) {
10
+ throw new Error("MCP transport limit must be a positive integer.");
11
+ }
6
12
  }
7
13
  get size() {
8
14
  return this.transports.size;
@@ -12,6 +18,30 @@ export class McpTransportRegistry {
12
18
  transport,
13
19
  lastActivityAt: this.now(),
14
20
  });
21
+ const excess = [];
22
+ while (this.transports.size > this.maxTransports) {
23
+ let oldestTransportSessionId;
24
+ let oldestActivityAt = Number.POSITIVE_INFINITY;
25
+ for (const [candidateSessionId, entry] of this.transports) {
26
+ if (candidateSessionId === transportSessionId && this.transports.size > 1)
27
+ continue;
28
+ if (entry.lastActivityAt >= oldestActivityAt)
29
+ continue;
30
+ oldestTransportSessionId = candidateSessionId;
31
+ oldestActivityAt = entry.lastActivityAt;
32
+ }
33
+ if (!oldestTransportSessionId)
34
+ break;
35
+ const oldest = this.transports.get(oldestTransportSessionId);
36
+ this.transports.delete(oldestTransportSessionId);
37
+ if (oldest) {
38
+ excess.push({
39
+ transportSessionId: oldestTransportSessionId,
40
+ transport: oldest.transport,
41
+ });
42
+ }
43
+ }
44
+ return closeTransports(excess);
15
45
  }
16
46
  get(transportSessionId) {
17
47
  const entry = this.transports.get(transportSessionId);
@@ -116,6 +116,7 @@ export class SingleUserOAuthProvider {
116
116
  }));
117
117
  return;
118
118
  }
119
+ this.pruneExpiredAuthorizationCodes();
119
120
  const code = `code-${randomUUID()}`;
120
121
  this.codes.set(code, {
121
122
  clientId: client.client_id,
@@ -177,9 +178,17 @@ export class SingleUserOAuthProvider {
177
178
  this.oauthStore.deleteRefreshToken(hashed);
178
179
  }
179
180
  close() {
181
+ this.codes.clear();
180
182
  this.oauthStore.close();
181
183
  }
184
+ pruneExpiredAuthorizationCodes(nowMs = Date.now()) {
185
+ for (const [code, record] of this.codes) {
186
+ if (record.expiresAtMs < nowMs)
187
+ this.codes.delete(code);
188
+ }
189
+ }
182
190
  validCodeRecord(client, authorizationCode) {
191
+ this.pruneExpiredAuthorizationCodes();
183
192
  const record = this.codes.get(authorizationCode);
184
193
  if (!record || record.clientId !== client.client_id || record.expiresAtMs < Date.now()) {
185
194
  throw new InvalidGrantError("Invalid authorization code");
@@ -7,8 +7,10 @@ const MAX_START_YIELD_MS = 300_000;
7
7
  const MAX_COMMAND_YIELD_MS = 300_000;
8
8
  const MAX_POLL_YIELD_MS = 300_000;
9
9
  const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
10
- const DEFAULT_BUFFER_CHARACTERS = 1_000_000;
11
- const COMPLETED_PROCESS_TTL_MS = 24 * 60 * 60 * 1_000;
10
+ const DEFAULT_BUFFER_CHARACTERS = 256_000;
11
+ const DEFAULT_MAX_ACTIVE_PROCESSES = 64;
12
+ const DEFAULT_MAX_COMPLETED_PROCESSES = 128;
13
+ const COMPLETED_PROCESS_TTL_MS = 5 * 60 * 1_000;
12
14
  const DEFAULT_COLUMNS = 80;
13
15
  const DEFAULT_ROWS = 24;
14
16
  function boundedInteger(value, fallback, maximum) {
@@ -56,21 +58,55 @@ function processEnvironment(input) {
56
58
  };
57
59
  }
58
60
  function codePointLength(value) {
59
- return Array.from(value).length;
60
- }
61
- function sliceCodePoints(value, start, end) {
62
- return Array.from(value).slice(start, end).join("");
61
+ let characters = 0;
62
+ for (let index = 0; index < value.length; index += 1) {
63
+ const codeUnit = value.charCodeAt(index);
64
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff &&
65
+ index + 1 < value.length) {
66
+ const nextCodeUnit = value.charCodeAt(index + 1);
67
+ if (nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff)
68
+ index += 1;
69
+ }
70
+ characters += 1;
71
+ }
72
+ return characters;
63
73
  }
64
74
  function takeHead(value, count) {
65
75
  if (count <= 0)
66
76
  return "";
67
- return sliceCodePoints(value, 0, count);
77
+ let index = 0;
78
+ let characters = 0;
79
+ while (index < value.length && characters < count) {
80
+ const codeUnit = value.charCodeAt(index);
81
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff &&
82
+ index + 1 < value.length) {
83
+ const nextCodeUnit = value.charCodeAt(index + 1);
84
+ index += nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff ? 2 : 1;
85
+ }
86
+ else {
87
+ index += 1;
88
+ }
89
+ characters += 1;
90
+ }
91
+ return value.slice(0, index);
68
92
  }
69
93
  function takeTail(value, count) {
70
94
  if (count <= 0)
71
95
  return "";
72
- const characters = Array.from(value);
73
- return characters.slice(Math.max(0, characters.length - count)).join("");
96
+ let index = value.length;
97
+ let characters = 0;
98
+ while (index > 0 && characters < count) {
99
+ index -= 1;
100
+ const codeUnit = value.charCodeAt(index);
101
+ if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff &&
102
+ index > 0) {
103
+ const previousCodeUnit = value.charCodeAt(index - 1);
104
+ if (previousCodeUnit >= 0xd800 && previousCodeUnit <= 0xdbff)
105
+ index -= 1;
106
+ }
107
+ characters += 1;
108
+ }
109
+ return value.slice(index);
74
110
  }
75
111
  function splitBudget(maxCharacters) {
76
112
  return {
@@ -97,20 +133,29 @@ export class HeadTailBuffer {
97
133
  append(output) {
98
134
  if (!output)
99
135
  return;
136
+ const outputCharacters = codePointLength(output);
100
137
  const previousTotal = this.totalCharacters;
101
- this.totalCharacters += codePointLength(output);
138
+ this.totalCharacters += outputCharacters;
102
139
  if (this.totalCharacters <= this.maxCharacters) {
103
140
  this.head += output;
104
141
  return;
105
142
  }
106
143
  const budget = splitBudget(this.maxCharacters);
107
144
  if (previousTotal <= this.maxCharacters) {
108
- const fullOutput = this.head + output;
109
- this.head = takeHead(fullOutput, budget.head);
110
- this.tail = takeTail(fullOutput, budget.tail);
145
+ const previousHead = this.head;
146
+ this.head = previousTotal >= budget.head
147
+ ? takeHead(previousHead, budget.head)
148
+ : previousHead + takeHead(output, budget.head - previousTotal);
149
+ this.tail = outputCharacters >= budget.tail
150
+ ? takeTail(output, budget.tail)
151
+ : takeTail(previousHead, budget.tail - outputCharacters) + output;
152
+ return;
153
+ }
154
+ if (outputCharacters >= budget.tail) {
155
+ this.tail = takeTail(output, budget.tail);
111
156
  return;
112
157
  }
113
- this.tail = takeTail(this.tail + output, budget.tail);
158
+ this.tail = takeTail(this.tail, budget.tail - outputCharacters) + output;
114
159
  }
115
160
  hasOutput() {
116
161
  return this.totalCharacters > 0;
@@ -145,13 +190,24 @@ function truncateOutput(output, maxCharacters) {
145
190
  export class ProcessManager {
146
191
  processes = new Map();
147
192
  completedByWorkspace = new Map();
193
+ completedProcessIds = [];
148
194
  maxBufferCharacters;
195
+ maxActiveProcesses;
196
+ maxCompletedProcesses;
149
197
  completedProcessTtlMs;
150
198
  maxStartYieldMs;
151
199
  monotonicNow;
152
200
  nextProcessId = 1;
153
201
  constructor(options = {}) {
154
202
  this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS;
203
+ this.maxActiveProcesses = options.maxActiveProcesses ?? DEFAULT_MAX_ACTIVE_PROCESSES;
204
+ if (!Number.isInteger(this.maxActiveProcesses) || this.maxActiveProcesses < 1) {
205
+ throw new Error("Active process limit must be a positive integer.");
206
+ }
207
+ this.maxCompletedProcesses = options.maxCompletedProcesses ?? DEFAULT_MAX_COMPLETED_PROCESSES;
208
+ if (!Number.isInteger(this.maxCompletedProcesses) || this.maxCompletedProcesses < 1) {
209
+ throw new Error("Completed process limit must be a positive integer.");
210
+ }
155
211
  this.completedProcessTtlMs = options.completedProcessTtlMs
156
212
  ?? options.completedSessionTtlMs
157
213
  ?? COMPLETED_PROCESS_TTL_MS;
@@ -159,6 +215,9 @@ export class ProcessManager {
159
215
  this.monotonicNow = options.monotonicNow ?? (() => performance.now());
160
216
  }
161
217
  async start(input) {
218
+ if (this.stats().running >= this.maxActiveProcesses) {
219
+ throw new Error(`Active process limit reached (${this.maxActiveProcesses}). Poll, interrupt, or wait for an existing process before starting another.`);
220
+ }
162
221
  const processEntry = this.createProcess(input);
163
222
  this.processes.set(processEntry.id, processEntry);
164
223
  try {
@@ -214,6 +273,17 @@ export class ProcessManager {
214
273
  activeWorkspaceIds() {
215
274
  return new Set([...this.processes.values()].map((processEntry) => processEntry.workspaceId));
216
275
  }
276
+ stats() {
277
+ let running = 0;
278
+ let completed = 0;
279
+ for (const processEntry of this.processes.values()) {
280
+ if (processEntry.running)
281
+ running += 1;
282
+ else
283
+ completed += 1;
284
+ }
285
+ return { total: this.processes.size, running, completed };
286
+ }
217
287
  takeCompleted(workspaceId, maxOutputTokens, excludeProcessId) {
218
288
  const processIds = this.completedByWorkspace.get(workspaceId) ?? [];
219
289
  if (processIds.length === 0)
@@ -250,6 +320,7 @@ export class ProcessManager {
250
320
  }
251
321
  this.processes.clear();
252
322
  this.completedByWorkspace.clear();
323
+ this.completedProcessIds.length = 0;
253
324
  }
254
325
  async waitForExit(processEntry, yieldTimeMs) {
255
326
  let timer;
@@ -352,16 +423,24 @@ export class ProcessManager {
352
423
  processEntry.running = false;
353
424
  processEntry.exitCode = exitCode;
354
425
  processEntry.signal = signal;
426
+ processEntry.process = undefined;
355
427
  processEntry.resolveExit();
428
+ processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
429
+ processEntry.cleanupTimer.unref();
356
430
  if (processEntry.background) {
357
431
  const completed = this.completedByWorkspace.get(processEntry.workspaceId) ?? [];
358
432
  if (!completed.includes(processEntry.id)) {
359
433
  completed.push(processEntry.id);
360
434
  this.completedByWorkspace.set(processEntry.workspaceId, completed);
435
+ this.completedProcessIds.push(processEntry.id);
436
+ }
437
+ while (this.completedProcessIds.length > this.maxCompletedProcesses) {
438
+ const oldestProcessId = this.completedProcessIds[0];
439
+ if (oldestProcessId === undefined)
440
+ break;
441
+ this.removeProcess(oldestProcessId);
361
442
  }
362
443
  }
363
- processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
364
- processEntry.cleanupTimer.unref();
365
444
  }
366
445
  append(processEntry, output) {
367
446
  processEntry.buffer.append(output);
@@ -396,6 +475,9 @@ export class ProcessManager {
396
475
  if (processEntry?.cleanupTimer)
397
476
  clearTimeout(processEntry.cleanupTimer);
398
477
  this.processes.delete(processId);
478
+ const completedIndex = this.completedProcessIds.indexOf(processId);
479
+ if (completedIndex >= 0)
480
+ this.completedProcessIds.splice(completedIndex, 1);
399
481
  if (!processEntry)
400
482
  return;
401
483
  const completed = this.completedByWorkspace.get(processEntry.workspaceId);
@@ -3,26 +3,60 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { git, getGitEligibility, safeWorkspaceRefSegment } from "./git.js";
5
5
  const REVIEW_REF_PREFIX = "refs/devspace/review";
6
- export function createReviewCheckpointManager() {
6
+ const DEFAULT_MAX_REVIEW_WORKSPACE_STATES = 128;
7
+ export function createReviewCheckpointManager(options = {}) {
8
+ const maxWorkspaceStates = options.maxWorkspaceStates ?? DEFAULT_MAX_REVIEW_WORKSPACE_STATES;
9
+ if (!Number.isInteger(maxWorkspaceStates) || maxWorkspaceStates < 1) {
10
+ throw new Error("Review checkpoint workspace-state limit must be a positive integer.");
11
+ }
7
12
  const states = new Map();
8
13
  const initializations = new Map();
14
+ const touchState = (workspaceId) => {
15
+ const state = states.get(workspaceId);
16
+ if (!state)
17
+ return;
18
+ states.delete(workspaceId);
19
+ states.set(workspaceId, state);
20
+ };
21
+ const trimStates = () => {
22
+ while (states.size > maxWorkspaceStates) {
23
+ const oldestWorkspaceId = states.keys().next().value;
24
+ if (!oldestWorkspaceId)
25
+ break;
26
+ states.delete(oldestWorkspaceId);
27
+ }
28
+ };
9
29
  return {
30
+ get stateCount() {
31
+ return states.size;
32
+ },
33
+ async releaseWorkspace(workspaceId) {
34
+ const pending = initializations.get(workspaceId);
35
+ if (pending)
36
+ await pending;
37
+ states.delete(workspaceId);
38
+ },
10
39
  async initializeWorkspace({ workspaceId, root }) {
11
40
  const existingState = states.get(workspaceId);
12
41
  assertWorkspaceRoot(existingState, workspaceId, root);
13
42
  if (existingState?.root === root && existingState.gitRoot !== undefined) {
43
+ touchState(workspaceId);
14
44
  return;
15
45
  }
16
46
  const pending = initializations.get(workspaceId);
17
47
  if (pending) {
18
48
  await pending;
19
49
  assertWorkspaceRoot(states.get(workspaceId), workspaceId, root);
50
+ touchState(workspaceId);
51
+ trimStates();
20
52
  return;
21
53
  }
22
54
  const initialize = initializeWorkspaceState(states, workspaceId, root);
23
55
  initializations.set(workspaceId, initialize);
24
56
  try {
25
57
  await initialize;
58
+ touchState(workspaceId);
59
+ trimStates();
26
60
  }
27
61
  finally {
28
62
  if (initializations.get(workspaceId) === initialize) {
@@ -38,6 +72,7 @@ export function createReviewCheckpointManager() {
38
72
  state = states.get(workspaceId);
39
73
  }
40
74
  assertWorkspaceRoot(state, workspaceId, root);
75
+ touchState(workspaceId);
41
76
  if (!state?.gitRoot) {
42
77
  throw new Error(state?.diagnostic ?? "review.changes requires a Git workspace in this version.");
43
78
  }
package/dist/server.js CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { access, realpath } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
+ import { resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
7
8
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
@@ -42,6 +43,7 @@ import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvail
42
43
  // transport. Bound stale transport-session retention so abandoned transports do
43
44
  // not accumulate for the life of the process.
44
45
  const MCP_TRANSPORT_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
46
+ const MAX_MCP_TRANSPORT_SESSIONS = 64;
45
47
  const FORGERELAY_VERSION = readForgeRelayVersion();
46
48
  const MCP_TRANSPORT_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
47
49
  const WRITE_TOOL_ANNOTATIONS = {
@@ -92,6 +94,30 @@ function workspaceLogContext(workspace, _transportSessionId) {
92
94
  workspace: workspaceLogLabel(workspace.root, workspace.id),
93
95
  };
94
96
  }
97
+ function formatDiscoveredWorkspaceInstructions(files, workspaceRoot) {
98
+ return [
99
+ "Workspace instructions discovered for this path. Apply them to follow-up work under their directories:",
100
+ ...files.flatMap((file) => [
101
+ `--- ${formatAgentsPath(file.path, workspaceRoot)} ---`,
102
+ file.content.trimEnd(),
103
+ ]),
104
+ ].join("\n");
105
+ }
106
+ async function assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, paths) {
107
+ const discovered = new Map();
108
+ for (const path of paths) {
109
+ const absolutePath = resolve(workspace.root, path);
110
+ for (const file of await workspaces.discoverPathInstructions(workspace, absolutePath)) {
111
+ discovered.set(file.path, file);
112
+ }
113
+ }
114
+ if (discovered.size === 0)
115
+ return;
116
+ throw new Error([
117
+ formatDiscoveredWorkspaceInstructions([...discovered.values()], workspace.root),
118
+ "Apply these instructions, then retry this tool call. No mutation or command was executed.",
119
+ ].join("\n"));
120
+ }
95
121
  function formatVisibleAgent(agent) {
96
122
  const model = agent.model ? `, model ${agent.model}` : "";
97
123
  const thinking = agent.thinking ? `, thinking ${agent.thinking}` : "";
@@ -608,6 +634,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
608
634
  operation: async () => {
609
635
  const startedAt = performance.now();
610
636
  const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
637
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
611
638
  const snapshot = await processSessions.start({
612
639
  workspaceId,
613
640
  command: cmd,
@@ -1337,14 +1364,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1337
1364
  if (!commitMessage) {
1338
1365
  throw new Error(`Managed-worktree-backed workspace ${workspaceId} requires commitMessage when closing.`);
1339
1366
  }
1340
- const busyWorkspaceIds = workspaces
1341
- .workspaceIdsForPhysicalWorkspace(workspace)
1367
+ const physicalWorkspaceIds = workspaces.workspaceIdsForPhysicalWorkspace(workspace);
1368
+ const busyWorkspaceIds = physicalWorkspaceIds
1342
1369
  .filter((id) => processSessions.activeWorkspaceIds().has(id));
1343
1370
  if (busyWorkspaceIds.length > 0) {
1344
1371
  throw new Error(`Cannot close this worktree-backed workspace while logical workspace processes are still running or awaiting completion delivery: ${busyWorkspaceIds.join(", ")}.`);
1345
1372
  }
1346
1373
  const startedAt = performance.now();
1347
1374
  const closed = await workspaces.closeWorktree(workspaceId, commitMessage);
1375
+ await Promise.all(physicalWorkspaceIds.map((id) => reviewCheckpoints.releaseWorkspace(id)));
1348
1376
  const result = [
1349
1377
  `Closed managed-worktree-backed workspace ${workspaceId}.`,
1350
1378
  `Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
@@ -1384,6 +1412,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1384
1412
  throw new Error(`Workspace ${workspaceId} still owns a running process or an unconsumed process completion. Poll or consume it before closing this workspace.`);
1385
1413
  }
1386
1414
  workspaces.closeWorkspace(workspaceId);
1415
+ await reviewCheckpoints.releaseWorkspace(workspaceId);
1387
1416
  const result = `Closed checkout-backed workspace ${workspaceId}. Physical project files were not removed.`;
1388
1417
  return {
1389
1418
  content: [textBlock(result)],
@@ -1417,7 +1446,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1417
1446
  .optional()
1418
1447
  .describe("Maximum number of lines to read."),
1419
1448
  },
1420
- outputSchema: resultOutputSchema(),
1449
+ outputSchema: resultOutputSchema({
1450
+ agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
1451
+ }),
1421
1452
  ...toolWidgetDescriptorMeta(config, "read"),
1422
1453
  annotations: { readOnlyHint: true },
1423
1454
  }, async ({ workspaceId, ...input }, extra) => {
@@ -1430,6 +1461,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1430
1461
  operation: async () => {
1431
1462
  const startedAt = performance.now();
1432
1463
  const readPath = workspaces.resolveReadPath(workspace, input.path);
1464
+ const discoveredInstructions = (await workspaces.discoverPathInstructions(workspace, readPath.absolutePath)).filter((file) => file.path !== readPath.absolutePath);
1433
1465
  const response = await readFileTool({ ...input, path: readPath.absolutePath }, {
1434
1466
  cwd: workspace.root,
1435
1467
  root: workspace.root,
@@ -1444,6 +1476,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1444
1476
  return response;
1445
1477
  }
1446
1478
  workspaces.markReadPathLoaded(workspace, readPath);
1479
+ const discoveredInstructionContent = discoveredInstructions.length > 0
1480
+ ? textBlock(formatDiscoveredWorkspaceInstructions(discoveredInstructions, workspace.root))
1481
+ : undefined;
1482
+ const content = discoveredInstructionContent
1483
+ ? [discoveredInstructionContent, ...response.content]
1484
+ : response.content;
1447
1485
  const summary = {
1448
1486
  ...textSummary(response.content),
1449
1487
  offset: input.offset ?? 1,
@@ -1458,6 +1496,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1458
1496
  });
1459
1497
  return {
1460
1498
  ...response,
1499
+ content,
1461
1500
  _meta: {
1462
1501
  tool: toolNames.read,
1463
1502
  card: {
@@ -1468,7 +1507,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1468
1507
  },
1469
1508
  },
1470
1509
  structuredContent: {
1471
- result: contentText(response.content),
1510
+ result: contentText(content),
1511
+ ...(discoveredInstructions.length > 0
1512
+ ? {
1513
+ agentsFiles: discoveredInstructions.map((file) => ({
1514
+ path: formatAgentsPath(file.path, workspace.root),
1515
+ content: file.content,
1516
+ })),
1517
+ }
1518
+ : {}),
1472
1519
  },
1473
1520
  };
1474
1521
  },
@@ -1500,6 +1547,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1500
1547
  changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
1501
1548
  operation: async () => {
1502
1549
  const startedAt = performance.now();
1550
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [input.path]);
1503
1551
  const response = await writeFileTool(input, {
1504
1552
  cwd: workspace.root,
1505
1553
  root: workspace.root,
@@ -1582,6 +1630,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1582
1630
  changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
1583
1631
  operation: async () => {
1584
1632
  const startedAt = performance.now();
1633
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [input.path]);
1585
1634
  const response = await editFileTool(input, {
1586
1635
  cwd: workspace.root,
1587
1636
  root: workspace.root,
@@ -1657,6 +1706,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1657
1706
  operation: async () => {
1658
1707
  const startedAt = performance.now();
1659
1708
  try {
1709
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [path, newPath]);
1660
1710
  await renamePath({ path, newPath }, {
1661
1711
  cwd: workspace.root,
1662
1712
  allowedRoots: workspaces.fileToolRoots(workspace),
@@ -1728,6 +1778,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1728
1778
  operation: async () => {
1729
1779
  const startedAt = performance.now();
1730
1780
  try {
1781
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [path]);
1731
1782
  const deleted = await deletePath({ path, recursive }, {
1732
1783
  cwd: workspace.root,
1733
1784
  allowedRoots: workspaces.fileToolRoots(workspace),
@@ -1938,6 +1989,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1938
1989
  operation: async () => {
1939
1990
  const startedAt = performance.now();
1940
1991
  const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
1992
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
1941
1993
  const snapshot = await processSessions.start({
1942
1994
  workspaceId,
1943
1995
  command,
@@ -2041,7 +2093,9 @@ export function createServer(config = loadConfig(), options = {}) {
2041
2093
  host: config.host,
2042
2094
  ...(allowedHosts ? { allowedHosts } : {}),
2043
2095
  });
2044
- const transports = new McpTransportRegistry();
2096
+ const transports = new McpTransportRegistry({
2097
+ maxTransports: MAX_MCP_TRANSPORT_SESSIONS,
2098
+ });
2045
2099
  const mcpUrl = new URL("/mcp", config.publicBaseUrl);
2046
2100
  const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
2047
2101
  const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir);
@@ -2071,7 +2125,7 @@ export function createServer(config = loadConfig(), options = {}) {
2071
2125
  continue;
2072
2126
  }
2073
2127
  closedCount += 1;
2074
- if (reason === "idle_timeout") {
2128
+ if (reason !== "server_shutdown") {
2075
2129
  logEvent(config.logging, "debug", "mcp_transport_session_closed", {
2076
2130
  reason,
2077
2131
  transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
@@ -2085,12 +2139,31 @@ export function createServer(config = loadConfig(), options = {}) {
2085
2139
  });
2086
2140
  }
2087
2141
  };
2142
+ const logRuntimeResources = () => {
2143
+ const memory = process.memoryUsage();
2144
+ const processStats = processSessions.stats();
2145
+ logEvent(config.logging, "debug", "runtime_resources", {
2146
+ rssBytes: memory.rss,
2147
+ heapUsedBytes: memory.heapUsed,
2148
+ heapTotalBytes: memory.heapTotal,
2149
+ externalBytes: memory.external,
2150
+ arrayBuffersBytes: memory.arrayBuffers,
2151
+ mcpTransports: transports.size,
2152
+ processesTotal: processStats.total,
2153
+ processesRunning: processStats.running,
2154
+ processesCompleted: processStats.completed,
2155
+ cachedWorkspaces: workspaces.cachedWorkspaceCount,
2156
+ reviewStates: reviewCheckpoints.stateCount,
2157
+ });
2158
+ };
2088
2159
  const transportCleanupTimer = setInterval(() => {
2089
2160
  void transports
2090
2161
  .closeIdle(MCP_TRANSPORT_IDLE_TIMEOUT_MS)
2091
- .then((results) => logTransportCloseResults("idle_timeout", results));
2162
+ .then((results) => logTransportCloseResults("idle_timeout", results))
2163
+ .finally(logRuntimeResources);
2092
2164
  }, MCP_TRANSPORT_CLEANUP_INTERVAL_MS);
2093
2165
  transportCleanupTimer.unref();
2166
+ logRuntimeResources();
2094
2167
  if (config.logging.trustProxy) {
2095
2168
  app.set("trust proxy", 1);
2096
2169
  }
@@ -2182,8 +2255,11 @@ export function createServer(config = loadConfig(), options = {}) {
2182
2255
  transport = new StreamableHTTPServerTransport({
2183
2256
  sessionIdGenerator: () => randomUUID(),
2184
2257
  onsessioninitialized: (newTransportSessionId) => {
2185
- if (transport)
2186
- transports.register(newTransportSessionId, transport);
2258
+ if (transport) {
2259
+ void transports
2260
+ .register(newTransportSessionId, transport)
2261
+ .then((results) => logTransportCloseResults("capacity_limit", results));
2262
+ }
2187
2263
  logEvent(config.logging, "debug", "mcp_transport_session_created", {
2188
2264
  requestId,
2189
2265
  transportSessionIdPrefix: transportSessionIdPrefix(newTransportSessionId),
@@ -1,13 +1,32 @@
1
1
  import { and, desc, eq } from "drizzle-orm";
2
2
  import { openDatabase } from "./db/client.js";
3
3
  import { workspaceContextDeliveries, workspaceConversationBindings, workspaceSessions, } from "./db/schema.js";
4
+ const DEFAULT_TOUCH_FLUSH_INTERVAL_MS = 5 * 60 * 1_000;
4
5
  export class SqliteWorkspaceStore {
5
6
  database;
6
- constructor(stateDir) {
7
+ now;
8
+ touchFlushTimer;
9
+ pendingSessionTouches = new Map();
10
+ pendingConversationTouches = new Map();
11
+ constructor(stateDir, options = {}) {
7
12
  this.database = openDatabase(stateDir);
13
+ this.now = options.now ?? (() => new Date());
14
+ const touchFlushIntervalMs = options.touchFlushIntervalMs ?? DEFAULT_TOUCH_FLUSH_INTERVAL_MS;
15
+ if (!Number.isInteger(touchFlushIntervalMs) || touchFlushIntervalMs < 1) {
16
+ throw new Error("Workspace touch flush interval must be a positive integer.");
17
+ }
18
+ this.touchFlushTimer = setInterval(() => {
19
+ try {
20
+ this.flushTouches();
21
+ }
22
+ catch (error) {
23
+ console.warn(`ForgeRelay workspace touch flush failed: ${errorMessage(error)}`);
24
+ }
25
+ }, touchFlushIntervalMs);
26
+ this.touchFlushTimer.unref();
8
27
  }
9
28
  createSession(input) {
10
- const now = new Date().toISOString();
29
+ const now = this.now().toISOString();
11
30
  const session = {
12
31
  id: input.id,
13
32
  root: input.root,
@@ -47,19 +66,18 @@ export class SqliteWorkspaceStore {
47
66
  .from(workspaceSessions)
48
67
  .where(eq(workspaceSessions.id, id))
49
68
  .get();
50
- return row ? rowToWorkspaceSession(row) : undefined;
69
+ if (!row)
70
+ return undefined;
71
+ return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(id));
51
72
  }
52
73
  touchSession(id) {
53
- this.database.db
54
- .update(workspaceSessions)
55
- .set({ lastUsedAt: new Date().toISOString() })
56
- .where(eq(workspaceSessions.id, id))
57
- .run();
74
+ this.pendingSessionTouches.set(id, this.now().toISOString());
58
75
  }
59
76
  setSessionStatus(id, status) {
77
+ this.pendingSessionTouches.delete(id);
60
78
  this.database.db
61
79
  .update(workspaceSessions)
62
- .set({ status, lastUsedAt: new Date().toISOString() })
80
+ .set({ status, lastUsedAt: this.now().toISOString() })
63
81
  .where(eq(workspaceSessions.id, id))
64
82
  .run();
65
83
  }
@@ -77,9 +95,13 @@ export class SqliteWorkspaceStore {
77
95
  : conditions.length === 1
78
96
  ? query.where(conditions[0]).all()
79
97
  : query.where(and(...conditions)).all();
80
- return rows.map(rowToWorkspaceSession);
98
+ return rows
99
+ .map(rowToWorkspaceSession)
100
+ .map((session) => applySessionTouch(session, this.pendingSessionTouches.get(session.id)))
101
+ .sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
81
102
  }
82
103
  deleteSession(id) {
104
+ this.pendingSessionTouches.delete(id);
83
105
  this.database.db
84
106
  .delete(workspaceSessions)
85
107
  .where(eq(workspaceSessions.id, id))
@@ -90,7 +112,8 @@ export class SqliteWorkspaceStore {
90
112
  .select()
91
113
  .from(workspaceConversationBindings)
92
114
  .all()
93
- .map(rowToWorkspaceConversationBinding);
115
+ .map(rowToWorkspaceConversationBinding)
116
+ .map((binding) => applyConversationTouch(binding, this.pendingConversationTouches.get(conversationTouchKey(binding.conversationScopeId, binding.targetKey))?.lastUsedAt));
94
117
  }
95
118
  getConversationBinding(conversationScopeId, targetKey) {
96
119
  const row = this.database.db
@@ -98,10 +121,13 @@ export class SqliteWorkspaceStore {
98
121
  .from(workspaceConversationBindings)
99
122
  .where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
100
123
  .get();
101
- return row ? rowToWorkspaceConversationBinding(row) : undefined;
124
+ if (!row)
125
+ return undefined;
126
+ const binding = rowToWorkspaceConversationBinding(row);
127
+ return applyConversationTouch(binding, this.pendingConversationTouches.get(conversationTouchKey(conversationScopeId, targetKey))?.lastUsedAt);
102
128
  }
103
129
  setConversationBinding(input) {
104
- const now = new Date().toISOString();
130
+ const now = this.now().toISOString();
105
131
  const row = this.database.db
106
132
  .insert(workspaceConversationBindings)
107
133
  .values({
@@ -126,16 +152,18 @@ export class SqliteWorkspaceStore {
126
152
  if (!row) {
127
153
  throw new Error("Conversation workspace binding upsert returned no row.");
128
154
  }
155
+ this.pendingConversationTouches.delete(conversationTouchKey(input.conversationScopeId, input.targetKey));
129
156
  return rowToWorkspaceConversationBinding(row);
130
157
  }
131
158
  touchConversationBinding(conversationScopeId, targetKey) {
132
- this.database.db
133
- .update(workspaceConversationBindings)
134
- .set({ lastUsedAt: new Date().toISOString() })
135
- .where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
136
- .run();
159
+ this.pendingConversationTouches.set(conversationTouchKey(conversationScopeId, targetKey), {
160
+ conversationScopeId,
161
+ targetKey,
162
+ lastUsedAt: this.now().toISOString(),
163
+ });
137
164
  }
138
165
  deleteConversationBinding(conversationScopeId, targetKey) {
166
+ this.pendingConversationTouches.delete(conversationTouchKey(conversationScopeId, targetKey));
139
167
  this.database.db
140
168
  .delete(workspaceConversationBindings)
141
169
  .where(and(eq(workspaceConversationBindings.conversationScopeId, conversationScopeId), eq(workspaceConversationBindings.targetKey, targetKey)))
@@ -184,13 +212,62 @@ export class SqliteWorkspaceStore {
184
212
  .where(and(eq(workspaceContextDeliveries.conversationScopeId, conversationScopeId), eq(workspaceContextDeliveries.targetKey, targetKey)))
185
213
  .run();
186
214
  }
215
+ get pendingTouchCount() {
216
+ return this.pendingSessionTouches.size + this.pendingConversationTouches.size;
217
+ }
218
+ flushTouches() {
219
+ if (this.pendingTouchCount === 0)
220
+ return;
221
+ const sessionTouches = [...this.pendingSessionTouches.entries()];
222
+ const conversationTouches = [...this.pendingConversationTouches.values()];
223
+ const updateSession = this.database.sqlite.prepare("UPDATE workspace_sessions SET last_used_at = ? WHERE id = ?");
224
+ const updateConversation = this.database.sqlite.prepare("UPDATE workspace_conversation_bindings SET last_used_at = ? WHERE conversation_scope_id = ? AND target_key = ?");
225
+ const flush = this.database.sqlite.transaction(() => {
226
+ for (const [workspaceId, lastUsedAt] of sessionTouches) {
227
+ updateSession.run(lastUsedAt, workspaceId);
228
+ }
229
+ for (const touch of conversationTouches) {
230
+ updateConversation.run(touch.lastUsedAt, touch.conversationScopeId, touch.targetKey);
231
+ }
232
+ });
233
+ flush();
234
+ for (const [workspaceId, lastUsedAt] of sessionTouches) {
235
+ if (this.pendingSessionTouches.get(workspaceId) === lastUsedAt) {
236
+ this.pendingSessionTouches.delete(workspaceId);
237
+ }
238
+ }
239
+ for (const touch of conversationTouches) {
240
+ const key = conversationTouchKey(touch.conversationScopeId, touch.targetKey);
241
+ if (this.pendingConversationTouches.get(key)?.lastUsedAt === touch.lastUsedAt) {
242
+ this.pendingConversationTouches.delete(key);
243
+ }
244
+ }
245
+ }
187
246
  close() {
188
- this.database.close();
247
+ clearInterval(this.touchFlushTimer);
248
+ try {
249
+ this.flushTouches();
250
+ }
251
+ finally {
252
+ this.database.close();
253
+ }
189
254
  }
190
255
  }
191
256
  export function createWorkspaceStore(stateDir) {
192
257
  return new SqliteWorkspaceStore(stateDir);
193
258
  }
259
+ function applySessionTouch(session, lastUsedAt) {
260
+ return lastUsedAt ? { ...session, lastUsedAt } : session;
261
+ }
262
+ function applyConversationTouch(binding, lastUsedAt) {
263
+ return lastUsedAt ? { ...binding, lastUsedAt } : binding;
264
+ }
265
+ function conversationTouchKey(conversationScopeId, targetKey) {
266
+ return JSON.stringify([conversationScopeId, targetKey]);
267
+ }
268
+ function errorMessage(error) {
269
+ return error instanceof Error ? error.message : String(error);
270
+ }
194
271
  function rowToWorkspaceSession(row) {
195
272
  return {
196
273
  id: row.id,
@@ -11,6 +11,7 @@ import { loadLocalAgentProfiles, } from "./local-agent-profiles.js";
11
11
  const WORKSPACE_STALE_REMINDER_MS = 2 * 24 * 60 * 60 * 1_000;
12
12
  const WORKSPACE_SESSION_IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
13
13
  const WORKSPACE_GC_INTERVAL_MS = 60 * 60 * 1_000;
14
+ const INITIAL_INSTRUCTION_DISCOVERY_DEPTH = 1;
14
15
  export class WorkspaceRegistry {
15
16
  config;
16
17
  store;
@@ -24,6 +25,9 @@ export class WorkspaceRegistry {
24
25
  this.hooks = new HookRunner(config.hooks, config.logging);
25
26
  this.pruneIdleWorkspaceSessions(new Set(), true);
26
27
  }
28
+ get cachedWorkspaceCount() {
29
+ return this.workspaces.size;
30
+ }
27
31
  async openWorkspace(input, openOptions = {}) {
28
32
  this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
29
33
  const workspaceInput = typeof input === "string" ? { path: input } : input;
@@ -631,8 +635,11 @@ export class WorkspaceRegistry {
631
635
  Object.assign(workspace, this.loadSkillsForWorkspace(workspace.root));
632
636
  workspace.capabilityGuides = loadCapabilityGuides(this.config);
633
637
  workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root);
634
- const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
635
- const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
638
+ workspace.scannedInstructionDirs.clear();
639
+ workspace.knownInstructionPathsByDir.clear();
640
+ workspace.loadedInstructionRealPaths.clear();
641
+ const agentsFiles = await this.loadInitialAgentsFiles(workspace);
642
+ const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace, agentsFiles);
636
643
  const contextFingerprint = bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles);
637
644
  return {
638
645
  workspace,
@@ -686,6 +693,9 @@ export class WorkspaceRegistry {
686
693
  agentProfiles: [],
687
694
  activatedSkillDirs: new Set(),
688
695
  activatedCapabilityGuideDirs: new Set(),
696
+ scannedInstructionDirs: new Set(),
697
+ knownInstructionPathsByDir: new Map(),
698
+ loadedInstructionRealPaths: new Set(),
689
699
  };
690
700
  if (touch)
691
701
  this.store?.touchSession(session.id);
@@ -782,6 +792,9 @@ export class WorkspaceRegistry {
782
792
  agentProfiles: await loadLocalAgentProfiles(this.config, input.root),
783
793
  activatedSkillDirs: new Set(),
784
794
  activatedCapabilityGuideDirs: new Set(),
795
+ scannedInstructionDirs: new Set(),
796
+ knownInstructionPathsByDir: new Map(),
797
+ loadedInstructionRealPaths: new Set(),
785
798
  };
786
799
  this.store?.createSession({
787
800
  id: workspace.id,
@@ -807,8 +820,8 @@ export class WorkspaceRegistry {
807
820
  targetBranch: workspace.worktree?.targetBranch,
808
821
  },
809
822
  });
810
- const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
811
- const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles);
823
+ const agentsFiles = await this.loadInitialAgentsFiles(workspace);
824
+ const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace, agentsFiles);
812
825
  const contextFingerprint = bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFiles);
813
826
  return {
814
827
  workspace,
@@ -837,11 +850,9 @@ export class WorkspaceRegistry {
837
850
  }
838
851
  return assertAllowedPath(root, this.config.allowedRoots);
839
852
  }
840
- async loadInitialAgentsFiles(root) {
841
- const resolvedRoot = (await tryRealpath(root)) ?? root;
853
+ async loadInitialAgentsFiles(workspace) {
842
854
  const systemInstructionsPath = resolve(this.config.systemInstructionsPath);
843
855
  const loadedFiles = [];
844
- const loadedRealPaths = new Set();
845
856
  const systemInstructions = await readSystemInstructions(systemInstructionsPath);
846
857
  const systemInstructionsRealPath = await tryRealpath(systemInstructionsPath);
847
858
  if (systemInstructions !== undefined) {
@@ -849,52 +860,125 @@ export class WorkspaceRegistry {
849
860
  path: systemInstructionsPath,
850
861
  content: systemInstructions,
851
862
  });
852
- if (systemInstructionsRealPath)
853
- loadedRealPaths.add(systemInstructionsRealPath);
854
- }
855
- for (const fileName of CONTEXT_FILE_NAMES) {
856
- const path = join(root, fileName);
857
- const content = await readResolvedProjectContextFile(path, resolvedRoot);
858
- if (content === undefined)
859
- continue;
860
- const realPath = await tryRealpath(path);
861
- if (realPath && loadedRealPaths.has(realPath))
862
- continue;
863
- loadedFiles.push({
864
- path,
865
- content,
866
- });
867
- if (realPath)
868
- loadedRealPaths.add(realPath);
863
+ if (systemInstructionsRealPath) {
864
+ workspace.loadedInstructionRealPaths.add(systemInstructionsRealPath);
865
+ }
869
866
  }
867
+ await this.discoverInstructionTree(workspace, workspace.root, INITIAL_INSTRUCTION_DISCOVERY_DEPTH);
868
+ loadedFiles.push(...await this.loadKnownInstructionsInDirectory(workspace, workspace.root));
870
869
  return loadedFiles;
871
870
  }
872
- async findAvailableAgentsFiles(root, loadedFiles) {
871
+ async findAvailableAgentsFiles(workspace, loadedFiles) {
873
872
  const loadedPaths = new Set(loadedFiles.map((file) => resolve(file.path)));
874
- const loadedRealPaths = new Set();
875
- for (const file of loadedFiles) {
876
- const realPath = await tryRealpath(file.path);
877
- if (realPath)
878
- loadedRealPaths.add(realPath);
879
- }
880
873
  const discovered = [];
881
- const agentDir = resolve(this.config.agentDir);
882
- await walkWorkspace(root, async (path, entry) => {
883
- if (isPathInsideRoot(path, agentDir))
884
- return;
885
- if (!entry.isFile())
886
- return;
887
- if (!CONTEXT_FILE_NAMES.has(entry.name))
888
- return;
889
- if (loadedPaths.has(path))
890
- return;
891
- const realPath = await tryRealpath(path);
892
- if (realPath && loadedRealPaths.has(realPath))
893
- return;
894
- discovered.push({ path });
895
- });
874
+ for (const paths of workspace.knownInstructionPathsByDir.values()) {
875
+ for (const path of paths) {
876
+ if (loadedPaths.has(path))
877
+ continue;
878
+ const realPath = await tryRealpath(path);
879
+ if (realPath && workspace.loadedInstructionRealPaths.has(realPath))
880
+ continue;
881
+ discovered.push({ path });
882
+ }
883
+ }
896
884
  return discovered.sort((a, b) => a.path.localeCompare(b.path));
897
885
  }
886
+ async discoverPathInstructions(workspace, inputPath) {
887
+ const absolutePath = resolve(inputPath);
888
+ if (!isPathInsideRoot(absolutePath, workspace.root))
889
+ return [];
890
+ const targetDirectory = dirname(absolutePath);
891
+ const relationship = relative(workspace.root, targetDirectory);
892
+ if (relationship === ".." ||
893
+ relationship.startsWith(`..${sep}`) ||
894
+ resolve(targetDirectory) === resolve(this.config.agentDir) ||
895
+ isPathInsideRoot(targetDirectory, resolve(this.config.agentDir))) {
896
+ return [];
897
+ }
898
+ const directories = [resolve(workspace.root)];
899
+ if (relationship) {
900
+ let current = resolve(workspace.root);
901
+ for (const segment of relationship.split(sep).filter(Boolean)) {
902
+ if (SKIPPED_CONTEXT_DIRS.has(segment))
903
+ break;
904
+ current = join(current, segment);
905
+ directories.push(current);
906
+ }
907
+ }
908
+ const loaded = [];
909
+ for (const directory of directories) {
910
+ await this.discoverInstructionTree(workspace, directory, 0);
911
+ loaded.push(...await this.loadKnownInstructionsInDirectory(workspace, directory));
912
+ }
913
+ return loaded;
914
+ }
915
+ async discoverInstructionTree(workspace, directory, remainingDepth) {
916
+ const resolvedDirectory = resolve(directory);
917
+ if (workspace.scannedInstructionDirs.has(resolvedDirectory))
918
+ return;
919
+ workspace.scannedInstructionDirs.add(resolvedDirectory);
920
+ if (resolvedDirectory !== resolve(workspace.root) &&
921
+ isPathInsideRoot(resolvedDirectory, resolve(this.config.agentDir))) {
922
+ return;
923
+ }
924
+ let entries;
925
+ try {
926
+ entries = await opendir(resolvedDirectory);
927
+ }
928
+ catch {
929
+ return;
930
+ }
931
+ const instructionPaths = [];
932
+ const childDirectories = [];
933
+ for await (const entry of entries) {
934
+ const path = join(resolvedDirectory, entry.name);
935
+ if (entry.isFile() && CONTEXT_FILE_NAMES.has(entry.name)) {
936
+ instructionPaths.push(path);
937
+ continue;
938
+ }
939
+ if (remainingDepth > 0 &&
940
+ entry.isDirectory() &&
941
+ !SKIPPED_CONTEXT_DIRS.has(entry.name)) {
942
+ childDirectories.push(path);
943
+ }
944
+ }
945
+ workspace.knownInstructionPathsByDir.set(resolvedDirectory, instructionPaths.sort((left, right) => left.localeCompare(right)));
946
+ if (remainingDepth <= 0)
947
+ return;
948
+ for (const childDirectory of childDirectories) {
949
+ await this.discoverInstructionTree(workspace, childDirectory, remainingDepth - 1);
950
+ }
951
+ }
952
+ async loadKnownInstructionsInDirectory(workspace, directory) {
953
+ const resolvedDirectory = resolve(directory);
954
+ const paths = workspace.knownInstructionPathsByDir.get(resolvedDirectory) ?? [];
955
+ const loaded = [];
956
+ const resolvedRoot = (await tryRealpath(workspace.root)) ?? resolve(workspace.root);
957
+ const realDirectory = (await tryRealpath(resolvedDirectory)) ?? resolvedDirectory;
958
+ for (const path of paths) {
959
+ const realPath = await tryRealpath(path);
960
+ if (!realPath)
961
+ continue;
962
+ if (!isPathInsideRoot(realPath, resolvedRoot))
963
+ continue;
964
+ if (dirname(realPath) !== realDirectory)
965
+ continue;
966
+ if (workspace.loadedInstructionRealPaths.has(realPath))
967
+ continue;
968
+ let content;
969
+ try {
970
+ content = await readFile(realPath, "utf8");
971
+ }
972
+ catch (error) {
973
+ if (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR"))
974
+ continue;
975
+ throw error;
976
+ }
977
+ workspace.loadedInstructionRealPaths.add(realPath);
978
+ loaded.push({ path, content });
979
+ }
980
+ return loaded;
981
+ }
898
982
  }
899
983
  function resolveBootstrapContextVisibility(mode, contextAlreadyDelivered) {
900
984
  if (mode === "full")
@@ -998,9 +1082,6 @@ export function formatAgentsPath(path, workspaceRoot) {
998
1082
  }
999
1083
  return relationship.split(sep).join("/");
1000
1084
  }
1001
- function isProjectRootInstructionPath(path, root) {
1002
- return isPathInsideRoot(path, root) && dirname(path) === root;
1003
- }
1004
1085
  async function readSystemInstructions(path) {
1005
1086
  try {
1006
1087
  return await readFile(path, "utf8");
@@ -1012,20 +1093,6 @@ async function readSystemInstructions(path) {
1012
1093
  throw error;
1013
1094
  }
1014
1095
  }
1015
- async function readResolvedProjectContextFile(path, root) {
1016
- try {
1017
- const resolvedPath = await realpath(path);
1018
- if (!isProjectRootInstructionPath(resolvedPath, root))
1019
- return undefined;
1020
- return await readFile(resolvedPath, "utf8");
1021
- }
1022
- catch (error) {
1023
- if (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
1024
- return undefined;
1025
- }
1026
- throw error;
1027
- }
1028
- }
1029
1096
  async function tryRealpath(path) {
1030
1097
  try {
1031
1098
  return await realpath(path);
@@ -1034,25 +1101,6 @@ async function tryRealpath(path) {
1034
1101
  return undefined;
1035
1102
  }
1036
1103
  }
1037
- async function walkWorkspace(directory, visit) {
1038
- let entries;
1039
- try {
1040
- entries = await opendir(directory);
1041
- }
1042
- catch {
1043
- return;
1044
- }
1045
- for await (const entry of entries) {
1046
- const path = join(directory, entry.name);
1047
- if (entry.isDirectory()) {
1048
- if (!SKIPPED_CONTEXT_DIRS.has(entry.name)) {
1049
- await walkWorkspace(path, visit);
1050
- }
1051
- continue;
1052
- }
1053
- await visit(path, entry);
1054
- }
1055
- }
1056
1104
  function isErrnoException(error) {
1057
1105
  return error instanceof Error && "code" in error;
1058
1106
  }
@@ -157,10 +157,16 @@ CLAUDE.md
157
157
  CLAUDE.MD
158
158
  ```
159
159
 
160
- Nested project instruction files are returned as available paths rather than all
161
- being injected eagerly. Read the relevant nested file before working under that path.
162
- `FORGERELAY_AGENT_DIR` is not an instruction source; it remains only a compatibility
163
- skill-discovery path.
160
+ To keep broad workspaces such as `~` fast, initial nested-instruction discovery is
161
+ bounded to direct child directories instead of recursively walking the whole tree.
162
+ Deeper `AGENTS.md` / `CLAUDE.md` files are discovered lazily along a path the first
163
+ time the Agent accesses it, and already-scanned directories are cached for the life
164
+ of that workspace handle. A `read` result carries any newly discovered local
165
+ instructions before the requested file content. Side-effecting file tools and shell
166
+ commands discover instructions before execution; if new local instructions are
167
+ found, ForgeRelay returns them and requires the Agent to retry, so the side effect
168
+ does not occur before the relevant instructions are known. `FORGERELAY_AGENT_DIR`
169
+ is not an instruction source; it remains only a compatibility skill-discovery path.
164
170
 
165
171
  ## MCP capability loading
166
172
 
@@ -193,13 +193,24 @@ files. For a managed-worktree-backed workspace, `close_workspace` requires
193
193
  `commitMessage` and runs the existing safe worktree finalize lifecycle: close Hooks,
194
194
  commit when needed, fast-forward-only integration, cleanup, and alias invalidation.
195
195
 
196
+ Hot workspace/session activity timestamps are coalesced in memory and flushed to the
197
+ SQLite state database in a transaction at most every five minutes; normal shutdown
198
+ performs a final explicit flush. Reads within the running ForgeRelay process see the
199
+ latest in-memory timestamps immediately. Workspace creation, close/status changes,
200
+ context-delivery checkpoints, and other semantic state transitions remain immediate
201
+ persistent writes. A hard process crash may therefore lose only the most recent
202
+ activity timestamp window, not the existence or closed/open state of a workspace.
203
+
196
204
  Regular `bash` has no execution-timeout input. `action="run"` (the default) waits
197
205
  in the foreground for at most 300 seconds; if the process is still alive, the
198
206
  result contains `running: true` and a canonical `processId`. Reuse the same `bash`
199
207
  with `action="process"` to poll/wait, send `input`, resize a PTY, or set
200
208
  `interrupt:true`; each wait can be up to 300 seconds. ForgeRelay does not kill a
201
209
  process merely because a wait window expires. Completed background processes are
202
- delivered once with a later tool result for the same logical workspace ID.
210
+ delivered once with a later tool result for the same logical workspace ID. An
211
+ unconsumed completed-process notice is retained for at most five minutes, and
212
+ ForgeRelay also bounds active and completed process counts so repeated background
213
+ commands cannot grow server memory without limit.
203
214
 
204
215
  Codex mode retains `write_stdin` only as an experimental compatibility adapter;
205
216
  regular Agent workflows should use the single `bash` process lifecycle.
@@ -348,8 +359,12 @@ Arrays or empty values are not accepted. Symbolic links are followed, so the
348
359
  runtime entry may point at a canonical source elsewhere on disk.
349
360
 
350
361
  Project-root `AGENTS.md` / `CLAUDE.md` files remain project context and are
351
- loaded separately. `FORGERELAY_AGENT_DIR` does not select a global instruction
352
- file; it remains a compatibility path for Agent Skills.
362
+ loaded separately. Initial nested-instruction discovery checks only direct child
363
+ directories; deeper instruction files are discovered lazily when a workspace path
364
+ is first accessed. Reads surface newly discovered instructions inline, while
365
+ side-effecting file/shell operations stop before execution and require a retry if
366
+ that access discovers new local instructions. `FORGERELAY_AGENT_DIR` does not
367
+ select a global instruction file; it remains a compatibility path for Agent Skills.
353
368
 
354
369
  ## Skills and subagents
355
370
 
package/docs/roadmap.md CHANGED
@@ -183,6 +183,17 @@ capability
183
183
  - inventory 区分持久化 `status` 与派生 `state`:`status="active"` 表示尚未显式关闭,`state` 再区分 active、stale、invalid 与 closed;missing root 或外部删除的 managed worktree 可以保持可诊断的 active record,同时显示为 invalid;
184
184
  - inventory 查看本身不刷新 workspace `lastUsedAt`,支持过滤与分页,并继续让现有 `close_workspace` 承担用户确认后的实际清理/finalize lifecycle。
185
185
 
186
+ ### 0.3.7 — 资源生命周期与 Workspace I/O 性能补丁
187
+
188
+ 0.3.7 处理长时间运行实例在高请求量、高输出 `bash`、大量 logical workspace 与宽根目录下的资源放大问题,不改变 canonical 9-tool surface:
189
+
190
+ - completed background process 的 Agent 可消费状态最多保留 5 分钟;底层 ChildProcess/PTY handle 在退出时立即释放,completed notice 数量与 active process 数量都有硬上限,并缩小单 process 输出驻留预算;
191
+ - 高输出 head/tail buffer 保留 Unicode code-point 语义,但不再通过 `Array.from(整段输出)` 构造巨型临时数组,降低 V8 heap 扩容与 GC 压力;
192
+ - MCP transport registry 与 review checkpoint state 加入容量边界;正常 transport close/workspace close 仍立即释放,异常遗弃对象不能再无限累积;OAuth 过期 authorization code 也会主动淘汰;
193
+ - `open_workspace` 的 instruction discovery 首轮只检查 root 与直接子目录,不再递归整棵 workspace。更深层 `AGENTS.md` / `CLAUDE.md` 在 Agent 首次访问对应路径时沿祖先目录惰性发现,并缓存已扫描目录;read 可直接携带新发现指令,write/edit/rename/delete/bash 等副作用调用则在执行前返回指令并要求重试;
194
+ - Workspace SQLite 继续作为本地持久化真源,不引入 Redis/PostgreSQL/Docker。高频 session/conversation `lastUsedAt` touch 进入内存 write-behind cache,最多每 5 分钟事务批量 flush,normal shutdown 再显式 flush;create/close/status 等语义性状态仍同步持久化;
195
+ - debug runtime telemetry 定期报告 RSS/heap、transport、process、workspace cache 与 review state 数量,为后续真实实例资源趋势提供可观测性。
196
+
186
197
  必要安全语义始终留在 Core tool interface、Capability contract 或自动 Hook report 中;渐进式披露不能成为隐藏权限、隐式 autonomous workflow 或绕过 allowed roots/auth 的机制。`rename` 继续作为文件和目录 move/rename 的统一 primitive。
187
198
 
188
199
  ## 0.4 — LSP code intelligence v1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
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",
@@ -42,7 +42,7 @@
42
42
  "debug:accept": "node scripts/debug/accept.mjs",
43
43
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
44
44
  "start": "node dist/cli.js serve",
45
- "test": "tsx src/config.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/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
45
+ "test": "tsx src/config.test.ts && tsx src/logger.test.ts && tsx src/proxy-trust.test.ts && tsx src/mcp-app-template.test.ts && tsx src/hooks.test.ts && tsx src/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/file-mutations.test.ts && tsx src/skills.test.ts && tsx src/workspace-store.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
46
46
  "typecheck": "tsc -p tsconfig.json --noEmit",
47
47
  "release:check": "node scripts/release-version.mjs check",
48
48
  "release:tag-check": "node scripts/release-version.mjs tag",