@akira-tl/forgerelay 0.2.3 → 0.2.5

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.
@@ -79,6 +79,19 @@ export class SqliteWorkspaceStore {
79
79
  : query.where(and(...conditions)).all();
80
80
  return rows.map(rowToWorkspaceSession);
81
81
  }
82
+ deleteSession(id) {
83
+ this.database.db
84
+ .delete(workspaceSessions)
85
+ .where(eq(workspaceSessions.id, id))
86
+ .run();
87
+ }
88
+ listConversationBindings() {
89
+ return this.database.db
90
+ .select()
91
+ .from(workspaceConversationBindings)
92
+ .all()
93
+ .map(rowToWorkspaceConversationBinding);
94
+ }
82
95
  getConversationBinding(conversationScopeId, targetKey) {
83
96
  const row = this.database.db
84
97
  .select()
@@ -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,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
@@ -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.
@@ -318,8 +336,11 @@ forgerelay agents show <id>
318
336
  | `FORGERELAY_TRUST_PROXY` | `0` |
319
337
 
320
338
  `pretty` is the human-facing local console format. It uses terminal-aware color,
321
- short timestamps, workspace/session context, and compact operation results while
322
- keeping HTTP request records off by default. Shell command previews are enabled
339
+ short timestamps, workspace-first context, and compact operation results while
340
+ keeping HTTP request records off by default. Project names receive stable
341
+ per-project colors and logical `ws_...` identifiers remain visible; transient MCP
342
+ transport session IDs and normal session lifecycle events are shown only at
343
+ `debug` level. Shell command previews are enabled
323
344
  in this mode and truncated to 120 characters; set
324
345
  `FORGERELAY_LOG_SHELL_COMMANDS=0` when command arguments may contain secrets.
325
346
 
package/docs/debugging.md CHANGED
@@ -59,17 +59,45 @@ 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`;
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
- 9. a temporary Git repository with managed worktree creation, file modification, and `close_worktree`;
66
- 10. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;
67
- 11. deterministic local subagent error path,不联系任何模型 provider;
68
- 12. debug hook recorder 覆盖全部九个 Hooks v1 lifecycle events。
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, workspace resume/stale-session schema, and MCP App tool metadata;
63
+ 7. the full MCP App template chain: `resources/list`, `resources/templates/list`, current content-hashed `resources/read`, legacy/historical template compatibility reads, `text/html;profile=mcp-app`, CSP resource domains, and an HTTP fetch of the JavaScript asset referenced by the template;
64
+ 8. a real checkout workspace with `write`, `read`, `rename`, `delete`, foreground `bash` through `ProcessSessionManager`, and a deliberate failed `edit`;
65
+ 9. 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;
66
+ 10. a temporary Git repository with managed worktree creation, file modification, and `close_worktree`;
67
+ 11. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;
68
+ 12. deterministic local subagent error path,不联系任何模型 provider;
69
+ 13. debug hook recorder 覆盖全部九个 Hooks v1 lifecycle events。
69
70
 
70
71
  `curl` must be available on `PATH` for this acceptance command. Node and Git are
71
72
  already normal ForgeRelay development prerequisites.
72
73
 
74
+ ## Debug ChatGPT template loading
75
+
76
+ The normal debug runtime keeps widgets off so source-only server iteration does
77
+ not accidentally serve stale UI assets. To debug the MCP App path, build first
78
+ and enable widgets explicitly:
79
+
80
+ ```bash
81
+ npm run build
82
+ FORGERELAY_DEBUG_WIDGETS=full \
83
+ FORGERELAY_LOG_LEVEL=debug \
84
+ FORGERELAY_LOG_REQUESTS=1 \
85
+ FORGERELAY_LOG_ASSETS=1 \
86
+ npm run dev
87
+ ```
88
+
89
+ At `debug` level, MCP requests include the JSON-RPC method and a safe target for
90
+ `resources/read` and `tools/call`, while transport session IDs remain out of
91
+ normal `info` tool logs. Successful template callbacks also emit `app template`
92
+ entries identifying `current`, `legacy`, or `historical` compatibility reads. A
93
+ successful ChatGPT template load should produce a sequence containing
94
+ `resources/list`, `resources/read ui://...`, an `app template ... -> ok` entry,
95
+ and then an HTTP `GET /mcp-app-assets/...` request. If `resources/read` never
96
+ arrives, inspect the client/developer-mode connection. If it arrives and fails,
97
+ inspect the MCP resource registration/build artifacts. If it succeeds but the
98
+ asset request fails, inspect the public base URL, CSP, asset route, and browser
99
+ console.
100
+
73
101
  ## Debug configuration
74
102
 
75
103
  The checked-in debug configuration is:
package/docs/gotchas.md CHANGED
@@ -234,7 +234,28 @@ FORGERELAY_WIDGETS=full
234
234
  ```
235
235
 
236
236
  Use `FORGERELAY_WIDGETS=changes` for aggregate `show_changes`, or `off` to
237
- disable UI. Plain MCP clients may ignore ChatGPT Apps widget metadata.
237
+ disable UI. Plain MCP clients may ignore MCP App widget metadata.
238
+
239
+ If ChatGPT shows `Failed to fetch template`, first verify the server-side template
240
+ chain with:
241
+
242
+ ```bash
243
+ npm run build
244
+ npm run debug:accept
245
+ ```
246
+
247
+ The acceptance runner enables full widgets and checks that the tool advertises a
248
+ content-hashed `ui://forgerelay/workspace-app-<hash>.html` resource, that
249
+ `resources/read` returns `text/html;profile=mcp-app`, and that the referenced
250
+ JavaScript asset is reachable. ForgeRelay also keeps the legacy
251
+ `ui://forgerelay/workspace-app.html` pointer and historical
252
+ `workspace-app-*.html` pointers readable so an older ChatGPT metadata snapshot
253
+ can still fetch the current template while the connection is being refreshed.
254
+ For a live ChatGPT trace, run the debug server with
255
+ `FORGERELAY_DEBUG_WIDGETS=full`, `FORGERELAY_LOG_LEVEL=debug`,
256
+ `FORGERELAY_LOG_REQUESTS=1`, and `FORGERELAY_LOG_ASSETS=1`; then distinguish a
257
+ missing `resources/read` request from a template callback failure or a failed
258
+ `/mcp-app-assets/` fetch.
238
259
 
239
260
  ## Data retention
240
261