@akira-tl/forgerelay 0.2.2 → 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.
@@ -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 results = [];
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
- results.push({
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: session.id === workspace.id,
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 results;
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
- this.store?.setSessionStatus(workspace.id, "closed");
110
- this.workspaces.delete(workspace.id);
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
- const context = await this.openOnce(targetKey, async () => {
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
- return reusableWorkspace
120
- ? this.reusedWorkspaceContext(reusableWorkspace)
121
- : this.openCheckoutWorkspace(path);
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 managedPath = this.tryManagedWorktreePath(input.path);
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
- const context = await this.openOnce(targetKey, async () => {
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.reusedWorkspaceContext(reusableWorkspace);
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: input.path,
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(input.path, input.baseRef);
275
+ const context = await this.openWorktreeWorkspace(path, input.baseRef);
148
276
  return this.withConversationContext(context, conversationScopeId, targetKey);
149
277
  }
150
- const context = await this.openOnce(targetKey, async () => {
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
- return reusableWorkspace
153
- ? this.reusedWorkspaceContext(reusableWorkspace)
154
- : this.openWorktreeWorkspace(input.path, input.baseRef);
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.getWorkspace(session.id);
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.getWorkspace(session.id);
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
- this.store?.touchSession(workspaceId);
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,24 @@ 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
+
192
+ Shell commands are allowed to modify ordinary project files when that is a
193
+ natural part of the user's requested development task; ForgeRelay does not apply
194
+ a blanket ban to package managers, generators, formatters, or similar commands
195
+ that write files. The Agent contract still prohibits shell mutation of
196
+ security- or privilege-sensitive operating-system files and credential material,
197
+ and requires an explicit user request before changing configuration files
198
+ through `bash` or `exec_command`.
199
+
175
200
  ## Change review UI
176
201
 
177
202
  By default `FORGERELAY_WIDGETS=full` attaches ChatGPT Apps-compatible UI to the
@@ -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 `bash`. |
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 `write_stdin`. |
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
@@ -58,9 +58,9 @@ The acceptance checks:
58
58
  2. OAuth protected-resource and authorization-server discovery;
59
59
  3. unauthenticated `/mcp` rejection;
60
60
  4. dynamic OAuth client registration, PKCE Owner-password approval, and access-token exchange;
61
- 5. MCP `initialize`, including package/server version consistency;
62
- 6. `tools/list` for the full debug tool surface;
63
- 7. a real checkout workspace with `write`, `read`, `rename`, `delete`, `bash`, and a deliberate failed `edit`;
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 `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
@@ -97,6 +97,21 @@ repositories, local services, and other resources outside a narrow workspace
97
97
  sandbox, and MCP currently does not provide a clean universal interaction model
98
98
  for dynamically crossing such a boundary.
99
99
 
100
+ The Agent-facing shell contract therefore does not ban every command that can
101
+ change files. Commands may modify ordinary project files when that is a natural
102
+ part of the user's requested development task, including package managers,
103
+ generators, formatters, and similar tooling. The contract does prohibit shell
104
+ mutation of security- or privilege-sensitive operating-system files and
105
+ credential material such as `/etc/sudoers`, `/etc/passwd`, `/etc/shadow`, PAM or
106
+ authentication policy, SSH private keys, and equivalent privileged targets.
107
+ Configuration files may be changed through shell only when the user's request
108
+ explicitly calls for that configuration change rather than merely making it a
109
+ convenient implementation detail.
110
+
111
+ This is an Agent execution policy, not an OS-level sandbox or command parser.
112
+ Operators that need a stronger project-specific runtime gate can use blocking
113
+ `BeforeTool` Hooks around `bash` or `exec_command`.
114
+
100
115
  The security model is therefore based on:
101
116
 
102
117
  - strong authentication;
@@ -107,6 +122,16 @@ The security model is therefore based on:
107
122
 
108
123
  Do not describe ForgeRelay as a sandboxed coding environment.
109
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
+
110
135
  ## Lifecycle hooks
111
136
 
112
137
  Hook command 是本地代码执行,使用与 ForgeRelay 相同的操作系统用户权限并继承进程环境。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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",
@@ -83,6 +83,11 @@ try {
83
83
  assert.equal(initialized.message.result.serverInfo.name, "forgerelay");
84
84
  assert.equal(initialized.message.result.serverInfo.title, "ForgeRelay");
85
85
  assert.equal(initialized.message.result.serverInfo.version, packageJson.version);
86
+ const serverInstructions = initialized.message.result.instructions ?? "";
87
+ assert.match(serverInstructions, /Shell commands may modify ordinary project files/);
88
+ assert.match(serverInstructions, /\/etc\/sudoers/);
89
+ assert.match(serverInstructions, /configuration files through shell only when the user's request explicitly calls for that configuration change/);
90
+ assert.doesNotMatch(serverInstructions, /Do not create or modify files with bash/);
86
91
  pass(
87
92
  "MCP initialize",
88
93
  JSON.stringify(initialized.message.result.serverInfo),
@@ -103,9 +108,22 @@ try {
103
108
  params: {},
104
109
  }).message.result.tools;
105
110
  const toolNames = tools.map((tool) => tool.name);
106
- 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"]) {
107
112
  assert.ok(toolNames.includes(expected), `missing debug tool ${expected}`);
108
113
  }
114
+ const bashTool = tools.find((tool) => tool.name === "bash");
115
+ assert.match(bashTool?.description ?? "", /may modify ordinary project files/);
116
+ assert.match(bashTool?.description ?? "", /\/etc\/sudoers/);
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);
109
127
  pass("MCP tools/list", `${toolNames.length} tools: ${toolNames.join(", ")}`);
110
128
 
111
129
  const opened = callTool(oauth.accessToken, sessionId, 3, "open_workspace", {
@@ -136,7 +154,8 @@ try {
136
154
  command: "printf debug-bash-ok",
137
155
  });
138
156
  assert.match(shell.structuredContent.result, /debug-bash-ok/);
139
- pass("bash", "debug-bash-ok");
157
+ assert.equal(shell.structuredContent.running, false);
158
+ pass("bash", "foreground command completed through ProcessSessionManager");
140
159
 
141
160
  const failedEdit = callTool(oauth.accessToken, sessionId, 7, "edit", {
142
161
  workspaceId,