@akira-tl/forgerelay 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/hooks.js +18 -4
- package/dist/mcp/server-instructions.js +10 -8
- package/dist/pi-tools.js +1 -9
- package/dist/process-sessions.js +56 -8
- package/dist/server.js +217 -136
- package/dist/workspace-store.js +13 -0
- package/dist/workspaces.js +275 -25
- package/docs/chatgpt-coding-workflow.md +18 -1
- package/docs/configuration.md +20 -2
- package/docs/debugging.md +2 -2
- package/docs/security.md +10 -0
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +12 -2
package/dist/workspaces.js
CHANGED
|
@@ -7,31 +7,116 @@ import { closeManagedWorktree, createManagedWorktree, resolveManagedWorktreeBase
|
|
|
7
7
|
import { AccessDeniedError, assertAllowedPath, isPathInsideRoot, resolveAllowedPath, } from "./roots.js";
|
|
8
8
|
import { loadWorkspaceSkills, markSkillActivated, resolveSkillReadPath, } from "./skills.js";
|
|
9
9
|
import { loadLocalAgentProfiles, } from "./local-agent-profiles.js";
|
|
10
|
+
const WORKSPACE_STALE_REMINDER_MS = 2 * 24 * 60 * 60 * 1_000;
|
|
11
|
+
const WORKSPACE_SESSION_IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
12
|
+
const WORKSPACE_GC_INTERVAL_MS = 60 * 60 * 1_000;
|
|
10
13
|
export class WorkspaceRegistry {
|
|
11
14
|
config;
|
|
12
15
|
store;
|
|
13
16
|
workspaces = new Map();
|
|
14
17
|
pendingOpens = new Map();
|
|
15
18
|
hooks;
|
|
19
|
+
lastWorkspaceGcAt = 0;
|
|
16
20
|
constructor(config, store) {
|
|
17
21
|
this.config = config;
|
|
18
22
|
this.store = store;
|
|
19
23
|
this.hooks = new HookRunner(config.hooks, config.logging);
|
|
24
|
+
this.pruneIdleWorkspaceSessions(new Set(), true);
|
|
20
25
|
}
|
|
21
26
|
async openWorkspace(input, openOptions = {}) {
|
|
27
|
+
this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
|
|
22
28
|
const workspaceInput = typeof input === "string" ? { path: input } : input;
|
|
29
|
+
if (workspaceInput.workspaceId) {
|
|
30
|
+
return this.resumeWorkspace(workspaceInput.workspaceId, openOptions.conversationScopeId);
|
|
31
|
+
}
|
|
32
|
+
if (!workspaceInput.path) {
|
|
33
|
+
throw new Error("open_workspace requires either path or workspaceId.");
|
|
34
|
+
}
|
|
23
35
|
const mode = workspaceInput.mode ?? "checkout";
|
|
24
36
|
if (mode === "worktree") {
|
|
25
37
|
return this.openReusableWorktree(workspaceInput, openOptions.conversationScopeId);
|
|
26
38
|
}
|
|
27
|
-
return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId);
|
|
39
|
+
return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, workspaceInput.newWorkspace ?? false);
|
|
40
|
+
}
|
|
41
|
+
async resumeWorkspace(workspaceId, conversationScopeId) {
|
|
42
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
43
|
+
const context = await this.reusedWorkspaceContext(workspace);
|
|
44
|
+
if (!conversationScopeId || !this.store) {
|
|
45
|
+
return { ...context, includeBootstrapContext: true };
|
|
46
|
+
}
|
|
47
|
+
const targetKeys = await this.workspaceTargetKeys(workspace);
|
|
48
|
+
const alreadyBound = targetKeys.some((targetKey) => this.store?.getConversationBinding(conversationScopeId, targetKey)?.workspaceSessionId === workspace.id);
|
|
49
|
+
for (const targetKey of targetKeys) {
|
|
50
|
+
this.store.setConversationBinding({
|
|
51
|
+
conversationScopeId,
|
|
52
|
+
targetKey,
|
|
53
|
+
workspaceSessionId: workspace.id,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return { ...context, includeBootstrapContext: !alreadyBound };
|
|
57
|
+
}
|
|
58
|
+
async listStaleWorkspaces(workspace) {
|
|
59
|
+
if (!this.store)
|
|
60
|
+
return [];
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
const workspaceRootKey = await canonicalPath(workspace.root);
|
|
63
|
+
const stale = [];
|
|
64
|
+
for (const session of this.store.listSessions({ status: "active", mode: workspace.mode })) {
|
|
65
|
+
if (session.id === workspace.id)
|
|
66
|
+
continue;
|
|
67
|
+
const lastUsedAt = Date.parse(session.lastUsedAt);
|
|
68
|
+
if (!Number.isFinite(lastUsedAt) || now - lastUsedAt < WORKSPACE_STALE_REMINDER_MS)
|
|
69
|
+
continue;
|
|
70
|
+
const root = await this.validSessionRoot(session);
|
|
71
|
+
if (!root || await canonicalPath(root) !== workspaceRootKey)
|
|
72
|
+
continue;
|
|
73
|
+
stale.push({
|
|
74
|
+
workspaceId: session.id,
|
|
75
|
+
root,
|
|
76
|
+
mode: session.mode,
|
|
77
|
+
lastUsedAt: session.lastUsedAt,
|
|
78
|
+
idleMs: now - lastUsedAt,
|
|
79
|
+
branch: session.branch,
|
|
80
|
+
targetBranch: session.targetBranch,
|
|
81
|
+
managed: session.managed,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return stale.sort((left, right) => left.lastUsedAt.localeCompare(right.lastUsedAt));
|
|
85
|
+
}
|
|
86
|
+
closeWorkspace(workspaceId) {
|
|
87
|
+
const workspace = this.getWorkspace(workspaceId);
|
|
88
|
+
if (workspace.mode === "worktree") {
|
|
89
|
+
const aliases = this.activeSessions("worktree")
|
|
90
|
+
.filter((session) => resolve(session.root) === resolve(workspace.root));
|
|
91
|
+
if (aliases.length <= 1) {
|
|
92
|
+
throw new Error(`Workspace ${workspaceId} is the last active handle for a worktree. Use close_worktree to finalize and remove the physical worktree, or keep this handle as its anchor.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (this.store) {
|
|
96
|
+
for (const binding of this.store.listConversationBindings()) {
|
|
97
|
+
if (binding.workspaceSessionId === workspaceId) {
|
|
98
|
+
this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
this.store.deleteSession(workspaceId);
|
|
102
|
+
}
|
|
103
|
+
this.workspaces.delete(workspaceId);
|
|
104
|
+
}
|
|
105
|
+
workspaceIdsForPhysicalWorkspace(workspace) {
|
|
106
|
+
const root = resolve(workspace.root);
|
|
107
|
+
return this.activeSessions(workspace.mode)
|
|
108
|
+
.filter((session) => resolve(session.root) === root)
|
|
109
|
+
.map((session) => session.id);
|
|
28
110
|
}
|
|
29
111
|
async listKnownWorktrees(workspace) {
|
|
30
112
|
const sourceRoot = workspace.mode === "worktree" ? workspace.sourceRoot : workspace.root;
|
|
31
113
|
if (!sourceRoot)
|
|
32
114
|
return [];
|
|
33
115
|
const sourceKey = await canonicalPath(sourceRoot);
|
|
34
|
-
const
|
|
116
|
+
const currentRootKey = workspace.mode === "worktree"
|
|
117
|
+
? await canonicalPath(workspace.root)
|
|
118
|
+
: undefined;
|
|
119
|
+
const resultsByRoot = new Map();
|
|
35
120
|
for (const session of this.activeSessions("worktree")) {
|
|
36
121
|
if (!session.sourceRoot)
|
|
37
122
|
continue;
|
|
@@ -40,7 +125,8 @@ export class WorkspaceRegistry {
|
|
|
40
125
|
const root = await this.validSessionRoot(session);
|
|
41
126
|
if (!root)
|
|
42
127
|
continue;
|
|
43
|
-
|
|
128
|
+
const rootKey = await canonicalPath(root);
|
|
129
|
+
const candidate = {
|
|
44
130
|
workspaceId: session.id,
|
|
45
131
|
path: root,
|
|
46
132
|
baseRef: session.baseRef ?? "HEAD",
|
|
@@ -48,10 +134,14 @@ export class WorkspaceRegistry {
|
|
|
48
134
|
branch: session.branch,
|
|
49
135
|
targetBranch: session.targetBranch,
|
|
50
136
|
managed: session.managed,
|
|
51
|
-
current:
|
|
52
|
-
}
|
|
137
|
+
current: rootKey === currentRootKey,
|
|
138
|
+
};
|
|
139
|
+
const existing = resultsByRoot.get(rootKey);
|
|
140
|
+
if (!existing || session.id === workspace.id) {
|
|
141
|
+
resultsByRoot.set(rootKey, candidate);
|
|
142
|
+
}
|
|
53
143
|
}
|
|
54
|
-
return
|
|
144
|
+
return [...resultsByRoot.values()];
|
|
55
145
|
}
|
|
56
146
|
async closeWorktree(workspaceId, commitMessage) {
|
|
57
147
|
const workspace = this.getWorkspace(workspaceId);
|
|
@@ -85,6 +175,9 @@ export class WorkspaceRegistry {
|
|
|
85
175
|
targetBranch: managedWorktree.targetBranch,
|
|
86
176
|
},
|
|
87
177
|
});
|
|
178
|
+
const aliasedWorkspaceIds = this.activeSessions("worktree")
|
|
179
|
+
.filter((session) => resolve(session.root) === resolve(workspace.root))
|
|
180
|
+
.map((session) => session.id);
|
|
88
181
|
const result = await closeManagedWorktree({
|
|
89
182
|
worktree: managedWorktree,
|
|
90
183
|
commitMessage,
|
|
@@ -106,52 +199,105 @@ export class WorkspaceRegistry {
|
|
|
106
199
|
cleanupWarning: result.cleanupWarning,
|
|
107
200
|
},
|
|
108
201
|
}));
|
|
109
|
-
|
|
110
|
-
|
|
202
|
+
for (const aliasedWorkspaceId of aliasedWorkspaceIds) {
|
|
203
|
+
this.store?.setSessionStatus(aliasedWorkspaceId, "closed");
|
|
204
|
+
this.workspaces.delete(aliasedWorkspaceId);
|
|
205
|
+
}
|
|
111
206
|
return { ...result, hookReports };
|
|
112
207
|
}
|
|
113
|
-
async openReusableCheckout(path, conversationScopeId) {
|
|
208
|
+
async openReusableCheckout(path, conversationScopeId, newWorkspace) {
|
|
114
209
|
const allowedPath = assertAllowedPath(path, this.config.allowedRoots);
|
|
115
210
|
const projectKey = await canonicalPath(allowedPath);
|
|
116
211
|
const targetKey = JSON.stringify(["checkout", projectKey, null]);
|
|
117
|
-
|
|
212
|
+
if (!newWorkspace) {
|
|
213
|
+
const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey);
|
|
214
|
+
if (boundContext)
|
|
215
|
+
return boundContext;
|
|
216
|
+
}
|
|
217
|
+
if (newWorkspace) {
|
|
218
|
+
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
|
|
219
|
+
const freshContext = reusableWorkspace
|
|
220
|
+
? await this.cloneWorkspaceContext(reusableWorkspace)
|
|
221
|
+
: await this.openCheckoutWorkspace(path);
|
|
222
|
+
return this.withConversationContext(freshContext, conversationScopeId, targetKey);
|
|
223
|
+
}
|
|
224
|
+
const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
|
|
225
|
+
const context = await this.openOnce(operationKey, async () => {
|
|
118
226
|
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
227
|
+
if (!reusableWorkspace)
|
|
228
|
+
return this.openCheckoutWorkspace(path);
|
|
229
|
+
return conversationScopeId && this.store
|
|
230
|
+
? this.cloneWorkspaceContext(reusableWorkspace)
|
|
231
|
+
: this.reusedWorkspaceContext(reusableWorkspace);
|
|
122
232
|
});
|
|
123
233
|
return this.withConversationContext(context, conversationScopeId, targetKey);
|
|
124
234
|
}
|
|
125
235
|
async openReusableWorktree(input, conversationScopeId) {
|
|
126
|
-
const
|
|
236
|
+
const path = input.path;
|
|
237
|
+
if (!path)
|
|
238
|
+
throw new Error("Worktree mode requires path.");
|
|
239
|
+
const managedPath = this.tryManagedWorktreePath(path);
|
|
127
240
|
if (managedPath) {
|
|
128
241
|
const worktreeKey = await canonicalPath(managedPath);
|
|
129
242
|
const targetKey = JSON.stringify(["worktree-path", worktreeKey]);
|
|
130
|
-
|
|
243
|
+
if (!input.newWorkspace) {
|
|
244
|
+
const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey);
|
|
245
|
+
if (boundContext)
|
|
246
|
+
return boundContext;
|
|
247
|
+
}
|
|
248
|
+
if (input.newWorkspace) {
|
|
131
249
|
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(worktreeKey, "worktree");
|
|
132
250
|
if (!reusableWorkspace) {
|
|
133
251
|
throw new Error(`Managed worktree is not registered as an active ForgeRelay workspace: ${managedPath}. Open the source project in worktree mode to create or recover a managed worktree first.`);
|
|
134
252
|
}
|
|
135
|
-
return this.
|
|
253
|
+
return this.withConversationContext(await this.cloneWorkspaceContext(reusableWorkspace), conversationScopeId, targetKey);
|
|
254
|
+
}
|
|
255
|
+
const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
|
|
256
|
+
const context = await this.openOnce(operationKey, async () => {
|
|
257
|
+
const reusableWorkspace = await this.findReusableWorkspaceByDirectory(worktreeKey, "worktree");
|
|
258
|
+
if (!reusableWorkspace) {
|
|
259
|
+
throw new Error(`Managed worktree is not registered as an active ForgeRelay workspace: ${managedPath}. Open the source project in worktree mode to create or recover a managed worktree first.`);
|
|
260
|
+
}
|
|
261
|
+
return conversationScopeId && this.store
|
|
262
|
+
? this.cloneWorkspaceContext(reusableWorkspace)
|
|
263
|
+
: this.reusedWorkspaceContext(reusableWorkspace);
|
|
136
264
|
});
|
|
137
265
|
return this.withConversationContext(context, conversationScopeId, targetKey);
|
|
138
266
|
}
|
|
139
267
|
const resolvedBase = await resolveManagedWorktreeBase({
|
|
140
|
-
sourcePath:
|
|
268
|
+
sourcePath: path,
|
|
141
269
|
baseRef: input.baseRef,
|
|
142
270
|
config: this.config,
|
|
143
271
|
});
|
|
144
272
|
const sourceKey = await canonicalPath(resolvedBase.sourceRoot);
|
|
145
273
|
const targetKey = JSON.stringify(["worktree", sourceKey, resolvedBase.targetBranch]);
|
|
146
274
|
if (input.newWorktree) {
|
|
147
|
-
const context = await this.openWorktreeWorkspace(
|
|
275
|
+
const context = await this.openWorktreeWorkspace(path, input.baseRef);
|
|
148
276
|
return this.withConversationContext(context, conversationScopeId, targetKey);
|
|
149
277
|
}
|
|
150
|
-
|
|
278
|
+
if (!input.newWorkspace) {
|
|
279
|
+
const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session) => session.mode === "worktree" &&
|
|
280
|
+
session.sourceRoot !== undefined &&
|
|
281
|
+
await canonicalPath(session.sourceRoot) === sourceKey &&
|
|
282
|
+
session.targetBranch === resolvedBase.targetBranch);
|
|
283
|
+
if (boundContext)
|
|
284
|
+
return boundContext;
|
|
285
|
+
}
|
|
286
|
+
if (input.newWorkspace) {
|
|
287
|
+
const reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
|
|
288
|
+
const freshContext = reusableWorkspace
|
|
289
|
+
? await this.cloneWorkspaceContext(reusableWorkspace)
|
|
290
|
+
: await this.openWorktreeWorkspace(path, input.baseRef);
|
|
291
|
+
return this.withConversationContext(freshContext, conversationScopeId, targetKey);
|
|
292
|
+
}
|
|
293
|
+
const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
|
|
294
|
+
const context = await this.openOnce(operationKey, async () => {
|
|
151
295
|
const reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
296
|
+
if (!reusableWorkspace)
|
|
297
|
+
return this.openWorktreeWorkspace(path, input.baseRef);
|
|
298
|
+
return conversationScopeId && this.store
|
|
299
|
+
? this.cloneWorkspaceContext(reusableWorkspace)
|
|
300
|
+
: this.reusedWorkspaceContext(reusableWorkspace);
|
|
155
301
|
});
|
|
156
302
|
return this.withConversationContext(context, conversationScopeId, targetKey);
|
|
157
303
|
}
|
|
@@ -172,6 +318,49 @@ export class WorkspaceRegistry {
|
|
|
172
318
|
}
|
|
173
319
|
}
|
|
174
320
|
}
|
|
321
|
+
async workspaceTargetKeys(workspace) {
|
|
322
|
+
if (workspace.mode === "checkout") {
|
|
323
|
+
return [JSON.stringify(["checkout", await canonicalPath(workspace.root), null])];
|
|
324
|
+
}
|
|
325
|
+
const keys = [JSON.stringify(["worktree-path", await canonicalPath(workspace.root)])];
|
|
326
|
+
if (workspace.sourceRoot && workspace.worktree?.targetBranch) {
|
|
327
|
+
keys.push(JSON.stringify([
|
|
328
|
+
"worktree",
|
|
329
|
+
await canonicalPath(workspace.sourceRoot),
|
|
330
|
+
workspace.worktree.targetBranch,
|
|
331
|
+
]));
|
|
332
|
+
}
|
|
333
|
+
return keys;
|
|
334
|
+
}
|
|
335
|
+
conversationOpenKey(targetKey, conversationScopeId) {
|
|
336
|
+
return conversationScopeId && this.store
|
|
337
|
+
? JSON.stringify(["conversation", conversationScopeId, targetKey])
|
|
338
|
+
: targetKey;
|
|
339
|
+
}
|
|
340
|
+
async boundConversationContext(conversationScopeId, targetKey, mode, matches) {
|
|
341
|
+
if (!conversationScopeId || !this.store)
|
|
342
|
+
return undefined;
|
|
343
|
+
const binding = this.store.getConversationBinding(conversationScopeId, targetKey);
|
|
344
|
+
if (!binding)
|
|
345
|
+
return undefined;
|
|
346
|
+
const session = this.store.getSession(binding.workspaceSessionId);
|
|
347
|
+
if (!session || session.status !== "active" || session.mode !== mode) {
|
|
348
|
+
this.store.deleteConversationBinding(conversationScopeId, targetKey);
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
const root = await this.validSessionRoot(session);
|
|
352
|
+
if (!root) {
|
|
353
|
+
this.store.deleteConversationBinding(conversationScopeId, targetKey);
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
if (!await matches(session, root)) {
|
|
357
|
+
this.store.deleteConversationBinding(conversationScopeId, targetKey);
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
const context = await this.reusedWorkspaceContext(this.getWorkspace(session.id));
|
|
361
|
+
this.store.touchConversationBinding(conversationScopeId, targetKey);
|
|
362
|
+
return { ...context, includeBootstrapContext: false };
|
|
363
|
+
}
|
|
175
364
|
withConversationContext(context, conversationScopeId, targetKey) {
|
|
176
365
|
if (!conversationScopeId || !this.store) {
|
|
177
366
|
return { ...context, includeBootstrapContext: true };
|
|
@@ -188,6 +377,49 @@ export class WorkspaceRegistry {
|
|
|
188
377
|
});
|
|
189
378
|
return { ...context, includeBootstrapContext: true };
|
|
190
379
|
}
|
|
380
|
+
pruneIdleWorkspaceSessions(protectedWorkspaceIds, force = false) {
|
|
381
|
+
if (!this.store)
|
|
382
|
+
return;
|
|
383
|
+
const now = Date.now();
|
|
384
|
+
if (!force && now - this.lastWorkspaceGcAt < WORKSPACE_GC_INTERVAL_MS)
|
|
385
|
+
return;
|
|
386
|
+
this.lastWorkspaceGcAt = now;
|
|
387
|
+
const activeSessions = this.store.listSessions({ status: "active" });
|
|
388
|
+
const sessionsById = new Map(activeSessions.map((session) => [session.id, session]));
|
|
389
|
+
const isIdle = (session) => {
|
|
390
|
+
const lastUsedAt = Date.parse(session.lastUsedAt);
|
|
391
|
+
return Number.isFinite(lastUsedAt) && now - lastUsedAt >= WORKSPACE_SESSION_IDLE_TTL_MS;
|
|
392
|
+
};
|
|
393
|
+
for (const binding of this.store.listConversationBindings()) {
|
|
394
|
+
const session = sessionsById.get(binding.workspaceSessionId);
|
|
395
|
+
if (!session) {
|
|
396
|
+
this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const boundWorkspaceIds = new Set(this.store.listConversationBindings().map((binding) => binding.workspaceSessionId));
|
|
400
|
+
const worktreeAnchors = new Map();
|
|
401
|
+
for (const session of activeSessions) {
|
|
402
|
+
if (session.mode !== "worktree")
|
|
403
|
+
continue;
|
|
404
|
+
const key = resolve(session.root);
|
|
405
|
+
const current = worktreeAnchors.get(key);
|
|
406
|
+
if (!current || current.lastUsedAt < session.lastUsedAt) {
|
|
407
|
+
worktreeAnchors.set(key, session);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const session of activeSessions) {
|
|
411
|
+
if (!isIdle(session))
|
|
412
|
+
continue;
|
|
413
|
+
if (protectedWorkspaceIds.has(session.id) || boundWorkspaceIds.has(session.id))
|
|
414
|
+
continue;
|
|
415
|
+
if (session.mode === "worktree" &&
|
|
416
|
+
worktreeAnchors.get(resolve(session.root))?.id === session.id) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
this.store.deleteSession(session.id);
|
|
420
|
+
this.workspaces.delete(session.id);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
191
423
|
async findReusableWorkspaceByDirectory(directoryKey, mode) {
|
|
192
424
|
for (const session of this.activeSessions(mode)) {
|
|
193
425
|
const root = await this.validSessionRoot(session);
|
|
@@ -195,7 +427,7 @@ export class WorkspaceRegistry {
|
|
|
195
427
|
continue;
|
|
196
428
|
if (await canonicalPath(root) !== directoryKey)
|
|
197
429
|
continue;
|
|
198
|
-
return this.
|
|
430
|
+
return this.workspaceFromSession(session, false);
|
|
199
431
|
}
|
|
200
432
|
return undefined;
|
|
201
433
|
}
|
|
@@ -208,7 +440,7 @@ export class WorkspaceRegistry {
|
|
|
208
440
|
const root = await this.validSessionRoot(session);
|
|
209
441
|
if (!root)
|
|
210
442
|
continue;
|
|
211
|
-
return this.
|
|
443
|
+
return this.workspaceFromSession(session, false);
|
|
212
444
|
}
|
|
213
445
|
return undefined;
|
|
214
446
|
}
|
|
@@ -257,6 +489,14 @@ export class WorkspaceRegistry {
|
|
|
257
489
|
throw error;
|
|
258
490
|
}
|
|
259
491
|
}
|
|
492
|
+
async cloneWorkspaceContext(workspace) {
|
|
493
|
+
return this.createWorkspaceContext({
|
|
494
|
+
root: workspace.root,
|
|
495
|
+
mode: workspace.mode,
|
|
496
|
+
sourceRoot: workspace.sourceRoot,
|
|
497
|
+
worktree: workspace.worktree ? { ...workspace.worktree } : undefined,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
260
500
|
async reusedWorkspaceContext(workspace) {
|
|
261
501
|
workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root);
|
|
262
502
|
const agentsFiles = await this.loadInitialAgentsFiles(workspace.root);
|
|
@@ -280,6 +520,15 @@ export class WorkspaceRegistry {
|
|
|
280
520
|
if (!session || session.status !== "active") {
|
|
281
521
|
throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
|
|
282
522
|
}
|
|
523
|
+
return this.workspaceFromSession(session, true);
|
|
524
|
+
}
|
|
525
|
+
workspaceFromSession(session, touch) {
|
|
526
|
+
const existing = this.workspaces.get(session.id);
|
|
527
|
+
if (existing) {
|
|
528
|
+
if (touch)
|
|
529
|
+
this.store?.touchSession(session.id);
|
|
530
|
+
return existing;
|
|
531
|
+
}
|
|
283
532
|
const root = this.assertWorkspaceRootAllowed(session.root, session.mode, session.sourceRoot);
|
|
284
533
|
const restoredWorkspace = {
|
|
285
534
|
id: session.id,
|
|
@@ -302,7 +551,8 @@ export class WorkspaceRegistry {
|
|
|
302
551
|
agentProfiles: [],
|
|
303
552
|
activatedSkillDirs: new Set(),
|
|
304
553
|
};
|
|
305
|
-
|
|
554
|
+
if (touch)
|
|
555
|
+
this.store?.touchSession(session.id);
|
|
306
556
|
this.workspaces.set(restoredWorkspace.id, restoredWorkspace);
|
|
307
557
|
return restoredWorkspace;
|
|
308
558
|
}
|
|
@@ -153,18 +153,25 @@ Default `FORGERELAY_TOOL_MODE=minimal` exposes:
|
|
|
153
153
|
|
|
154
154
|
```text
|
|
155
155
|
open_workspace
|
|
156
|
+
close_workspace
|
|
156
157
|
read
|
|
157
158
|
write
|
|
158
159
|
edit
|
|
159
160
|
rename
|
|
160
161
|
delete
|
|
161
162
|
bash
|
|
163
|
+
write_stdin
|
|
162
164
|
close_worktree
|
|
163
165
|
```
|
|
164
166
|
|
|
165
167
|
The exact lifecycle tools available depend on the active server configuration.
|
|
166
168
|
In minimal mode, normal shell inspection commands such as `rg`, `find`, and `ls`
|
|
167
|
-
can be used rather than dedicated MCP search tools.
|
|
169
|
+
can be used rather than dedicated MCP search tools. `bash` waits in the foreground
|
|
170
|
+
for at most 300 seconds. If the command is still running, ForgeRelay returns a
|
|
171
|
+
process `sessionId` without killing it. The Agent can use `write_stdin` to poll,
|
|
172
|
+
wait again, interact, or explicitly send Ctrl-C, or continue other work; once the
|
|
173
|
+
command finishes, its completion is attached to a later tool result using the
|
|
174
|
+
same workspace ID.
|
|
168
175
|
|
|
169
176
|
`FORGERELAY_TOOL_MODE=full` adds dedicated search/directory tools.
|
|
170
177
|
|
|
@@ -172,6 +179,16 @@ Experimental `FORGERELAY_TOOL_MODE=codex` provides a smaller Codex-shaped
|
|
|
172
179
|
surface including direct `rename`/`delete` path mutations alongside `apply_patch`,
|
|
173
180
|
`exec_command`, and `write_stdin`.
|
|
174
181
|
|
|
182
|
+
Workspace IDs are logical conversation handles rather than physical-directory
|
|
183
|
+
identities. The same conversation keeps a stable ID for a project, while another
|
|
184
|
+
conversation normally receives a different ID pointing at the same checkout or
|
|
185
|
+
worktree. `open_workspace` can explicitly resume a known `workspaceId`, and a
|
|
186
|
+
fresh logical ID is created only when the user asks for one. When a project has
|
|
187
|
+
other logical workspaces idle for more than two days, `open_workspace` reports
|
|
188
|
+
all of them so the user can choose to resume or clean them up. `close_workspace`
|
|
189
|
+
releases only the logical handle; the last handle for a physical worktree cannot
|
|
190
|
+
be released that way and must be finalized with `close_worktree`.
|
|
191
|
+
|
|
175
192
|
Shell commands are allowed to modify ordinary project files when that is a
|
|
176
193
|
natural part of the user's requested development task; ForgeRelay does not apply
|
|
177
194
|
a blanket ban to package managers, generators, formatters, or similar commands
|
package/docs/configuration.md
CHANGED
|
@@ -115,9 +115,9 @@ MCP clients discover metadata from:
|
|
|
115
115
|
|
|
116
116
|
| Value | Behavior |
|
|
117
117
|
| --- | --- |
|
|
118
|
-
| `minimal` | Default. Exposes `open_workspace`, `read`, `write`, `edit`, `rename`, `delete`, and `
|
|
118
|
+
| `minimal` | Default. Exposes `open_workspace`, `close_workspace`, `read`, `write`, `edit`, `rename`, `delete`, `bash`, `write_stdin`, and `close_worktree`. |
|
|
119
119
|
| `full` | Adds dedicated `grep`, `glob`, and `ls` tools. |
|
|
120
|
-
| `codex` | Experimental Codex-shaped tool surface using `open_workspace`, `read`, `rename`, `delete`, `apply_patch`, `exec_command`, and `
|
|
120
|
+
| `codex` | Experimental Codex-shaped tool surface using `open_workspace`, `close_workspace`, `read`, `rename`, `delete`, `apply_patch`, `exec_command`, `write_stdin`, and `close_worktree`. |
|
|
121
121
|
|
|
122
122
|
`FORGERELAY_MINIMAL_TOOLS` remains a compatibility-style boolean alias when the
|
|
123
123
|
explicit tool mode is unset. The corresponding legacy `DEVSPACE_*` names are
|
|
@@ -126,6 +126,24 @@ also accepted.
|
|
|
126
126
|
Codex-mode commands run without a PTY by default. `tty: true` enables interactive
|
|
127
127
|
programs when the optional `node-pty` dependency is available.
|
|
128
128
|
|
|
129
|
+
Logical workspace IDs are conversation-scoped handles. Reopening the same project
|
|
130
|
+
from the same conversation keeps its ID stable; a different conversation normally
|
|
131
|
+
receives a different ID for the same physical checkout/worktree. Pass
|
|
132
|
+
`workspaceId` to `open_workspace` to explicitly resume an existing handle in the
|
|
133
|
+
current conversation. `newWorkspace: true` allocates a new logical handle without
|
|
134
|
+
creating another checkout or Git worktree and should be used only on explicit user
|
|
135
|
+
request. Sessions idle for more than two days are returned in `staleWorkspaces` so
|
|
136
|
+
the user can choose whether to resume or release them. `close_workspace` removes a
|
|
137
|
+
logical handle without deleting checkout files; it refuses to remove the last
|
|
138
|
+
handle anchoring a physical worktree.
|
|
139
|
+
|
|
140
|
+
`bash` has no execution-timeout input. It waits in the foreground for at most 300
|
|
141
|
+
seconds; if the process is still alive, the result contains `running: true` and a
|
|
142
|
+
`sessionId`. `write_stdin` can poll or interact with that session for up to another
|
|
143
|
+
300 seconds per call. ForgeRelay does not kill a process merely because a wait
|
|
144
|
+
window expires. Completed background processes are delivered once with a later
|
|
145
|
+
tool result for the same logical workspace ID.
|
|
146
|
+
|
|
129
147
|
## Widgets
|
|
130
148
|
|
|
131
149
|
`FORGERELAY_WIDGETS` controls ChatGPT Apps-compatible UI attachments.
|
package/docs/debugging.md
CHANGED
|
@@ -59,8 +59,8 @@ The acceptance checks:
|
|
|
59
59
|
3. unauthenticated `/mcp` rejection;
|
|
60
60
|
4. dynamic OAuth client registration, PKCE Owner-password approval, and access-token exchange;
|
|
61
61
|
5. MCP `initialize`, including package/server version consistency and the shell mutation safety contract;
|
|
62
|
-
6. `tools/list` for the full debug tool surface, including the non-blanket `bash` mutation policy;
|
|
63
|
-
7. a real checkout workspace with `write`, `read`, `rename`, `delete`, `bash`, and a deliberate failed `edit`;
|
|
62
|
+
6. `tools/list` for the full debug tool surface, including `close_workspace`, `write_stdin`, the non-blanket `bash` mutation policy, no kill-timeout input, the 300-second foreground-wait contract, and workspace resume/stale-session schema;
|
|
63
|
+
7. a real checkout workspace with `write`, `read`, `rename`, `delete`, foreground `bash` through `ProcessSessionManager`, and a deliberate failed `edit`;
|
|
64
64
|
8. OS temp-directory `write` → `read` → `edit` → `rename` → `delete` over the same real MCP session, plus rejection of an arbitrary path outside the workspace/temp roots;
|
|
65
65
|
9. a temporary Git repository with managed worktree creation, file modification, and `close_worktree`;
|
|
66
66
|
10. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;
|
package/docs/security.md
CHANGED
|
@@ -122,6 +122,16 @@ The security model is therefore based on:
|
|
|
122
122
|
|
|
123
123
|
Do not describe ForgeRelay as a sandboxed coding environment.
|
|
124
124
|
|
|
125
|
+
Shell execution has a 300-second foreground wait ceiling, not a 300-second
|
|
126
|
+
process lifetime. When `bash` is still running after that window, ForgeRelay
|
|
127
|
+
returns a process `sessionId` and leaves the process alive. `write_stdin` can poll,
|
|
128
|
+
wait, interact, or explicitly interrupt it. An asynchronously completed process
|
|
129
|
+
is reported on a later tool result for the same logical workspace ID, including
|
|
130
|
+
error-result paths, and is never broadcast to another workspace ID. Explicitly
|
|
131
|
+
resuming the same workspace ID in another conversation intentionally transfers
|
|
132
|
+
that completion scope as well. Hook handlers keep their separate bounded timeout
|
|
133
|
+
policy because they are lifecycle gates rather than user-command execution.
|
|
134
|
+
|
|
125
135
|
## Lifecycle hooks
|
|
126
136
|
|
|
127
137
|
Hook command 是本地代码执行,使用与 ForgeRelay 相同的操作系统用户权限并继承进程环境。
|
package/package.json
CHANGED
package/scripts/debug/accept.mjs
CHANGED
|
@@ -108,13 +108,22 @@ try {
|
|
|
108
108
|
params: {},
|
|
109
109
|
}).message.result.tools;
|
|
110
110
|
const toolNames = tools.map((tool) => tool.name);
|
|
111
|
-
for (const expected of ["open_workspace", "close_worktree", "read", "write", "edit", "rename", "delete", "grep", "glob", "ls", "bash"]) {
|
|
111
|
+
for (const expected of ["open_workspace", "close_workspace", "close_worktree", "read", "write", "edit", "rename", "delete", "grep", "glob", "ls", "bash", "write_stdin"]) {
|
|
112
112
|
assert.ok(toolNames.includes(expected), `missing debug tool ${expected}`);
|
|
113
113
|
}
|
|
114
114
|
const bashTool = tools.find((tool) => tool.name === "bash");
|
|
115
115
|
assert.match(bashTool?.description ?? "", /may modify ordinary project files/);
|
|
116
116
|
assert.match(bashTool?.description ?? "", /\/etc\/sudoers/);
|
|
117
117
|
assert.doesNotMatch(bashTool?.description ?? "", /Do not use bash to create, move, rename, or delete project files/);
|
|
118
|
+
assert.equal(bashTool?.inputSchema?.properties?.timeout, undefined);
|
|
119
|
+
assert.match(bashTool?.description ?? "", /waits up to 300 seconds/);
|
|
120
|
+
assert.match(bashTool?.description ?? "", /write_stdin/);
|
|
121
|
+
const writeStdinTool = tools.find((tool) => tool.name === "write_stdin");
|
|
122
|
+
assert.equal(writeStdinTool?.inputSchema?.properties?.yieldTimeMs?.maximum, 300000);
|
|
123
|
+
const openWorkspaceTool = tools.find((tool) => tool.name === "open_workspace");
|
|
124
|
+
assert.ok(openWorkspaceTool?.inputSchema?.properties?.workspaceId);
|
|
125
|
+
assert.ok(openWorkspaceTool?.inputSchema?.properties?.newWorkspace);
|
|
126
|
+
assert.ok(openWorkspaceTool?.outputSchema?.properties?.staleWorkspaces);
|
|
118
127
|
pass("MCP tools/list", `${toolNames.length} tools: ${toolNames.join(", ")}`);
|
|
119
128
|
|
|
120
129
|
const opened = callTool(oauth.accessToken, sessionId, 3, "open_workspace", {
|
|
@@ -145,7 +154,8 @@ try {
|
|
|
145
154
|
command: "printf debug-bash-ok",
|
|
146
155
|
});
|
|
147
156
|
assert.match(shell.structuredContent.result, /debug-bash-ok/);
|
|
148
|
-
|
|
157
|
+
assert.equal(shell.structuredContent.running, false);
|
|
158
|
+
pass("bash", "foreground command completed through ProcessSessionManager");
|
|
149
159
|
|
|
150
160
|
const failedEdit = callTool(oauth.accessToken, sessionId, 7, "edit", {
|
|
151
161
|
workspaceId,
|