@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 +17 -0
- package/capabilities/shell-processes/GUIDE.md +2 -2
- package/dist/logger.js +16 -0
- package/dist/mcp-sessions.js +30 -0
- package/dist/oauth-provider.js +9 -0
- package/dist/process-sessions.js +98 -16
- package/dist/review-checkpoints.js +36 -1
- package/dist/server.js +85 -9
- package/dist/workspace-store.js +96 -19
- package/dist/workspaces.js +130 -82
- package/docs/chatgpt-coding-workflow.md +10 -4
- package/docs/configuration.md +18 -3
- package/docs/roadmap.md +11 -0
- package/package.json +2 -2
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]
|
package/dist/mcp-sessions.js
CHANGED
|
@@ -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);
|
package/dist/oauth-provider.js
CHANGED
|
@@ -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");
|
package/dist/process-sessions.js
CHANGED
|
@@ -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 =
|
|
11
|
-
const
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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 +=
|
|
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
|
|
109
|
-
this.head =
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1341
|
-
|
|
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(
|
|
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
|
|
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
|
|
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),
|
package/dist/workspace-store.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
69
|
+
if (!row)
|
|
70
|
+
return undefined;
|
|
71
|
+
return applySessionTouch(rowToWorkspaceSession(row), this.pendingSessionTouches.get(id));
|
|
51
72
|
}
|
|
52
73
|
touchSession(id) {
|
|
53
|
-
this.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
.
|
|
136
|
-
|
|
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.
|
|
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,
|
package/dist/workspaces.js
CHANGED
|
@@ -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
|
-
|
|
635
|
-
|
|
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
|
|
811
|
-
const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
`
|
|
163
|
-
|
|
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
|
|
package/docs/configuration.md
CHANGED
|
@@ -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.
|
|
352
|
-
|
|
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.
|
|
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",
|