@akira-tl/forgerelay 0.7.4 → 0.8.1

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.
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
2
3
  import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
4
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
@@ -23,6 +24,7 @@ export class WorkspaceRegistry {
23
24
  this.config = config;
24
25
  this.store = store;
25
26
  this.hooks = new HookRunner(config.hooks, config.logging);
27
+ this.foldLegacyWorkspaceSessions();
26
28
  this.pruneIdleWorkspaceSessions(new Set(), true);
27
29
  }
28
30
  get cachedWorkspaceCount() {
@@ -42,7 +44,7 @@ export class WorkspaceRegistry {
42
44
  if (mode === "worktree") {
43
45
  return this.openReusableWorktree(workspaceInput, openOptions.conversationScopeId);
44
46
  }
45
- return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, workspaceInput.newWorkspace ?? false, bootstrapContext);
47
+ return this.openReusableCheckout(workspaceInput.path, openOptions.conversationScopeId, bootstrapContext);
46
48
  }
47
49
  async listWorkspaces(input = {}, openOptions = {}) {
48
50
  this.pruneIdleWorkspaceSessions(openOptions.protectedWorkspaceIds ?? new Set());
@@ -69,6 +71,9 @@ export class WorkspaceRegistry {
69
71
  .filter((binding) => binding.conversationScopeId === openOptions.conversationScopeId)
70
72
  .map((binding) => binding.workspaceSessionId)
71
73
  : []);
74
+ const workspaceIdFilter = input.workspaceId
75
+ ? this.store?.getSession(input.workspaceId)?.id ?? input.workspaceId
76
+ : undefined;
72
77
  const rootKey = input.root
73
78
  ? await canonicalPath(assertAllowedPath(input.root, [...this.config.allowedRoots, this.config.worktreeRoot]))
74
79
  : undefined;
@@ -108,7 +113,7 @@ export class WorkspaceRegistry {
108
113
  const entry = entries[index];
109
114
  if (!session || !entry)
110
115
  continue;
111
- if (input.workspaceId && entry.workspaceId !== input.workspaceId)
116
+ if (workspaceIdFilter && entry.workspaceId !== workspaceIdFilter)
112
117
  continue;
113
118
  if (input.status && entry.status !== input.status)
114
119
  continue;
@@ -147,7 +152,7 @@ export class WorkspaceRegistry {
147
152
  };
148
153
  }
149
154
  async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
150
- const workspace = this.getWorkspace(workspaceId);
155
+ const workspace = this.workspaceForOpen(workspaceId);
151
156
  const context = await this.reusedWorkspaceContext(workspace);
152
157
  if (!conversationScopeId || !this.store) {
153
158
  return {
@@ -205,22 +210,24 @@ export class WorkspaceRegistry {
205
210
  }
206
211
  closeWorkspace(workspaceId) {
207
212
  const workspace = this.getWorkspace(workspaceId);
213
+ const canonicalWorkspaceId = workspace.id;
208
214
  if (workspace.mode === "worktree") {
209
- const aliases = this.activeSessions("worktree")
210
- .filter((session) => resolve(session.root) === resolve(workspace.root));
211
- if (aliases.length <= 1) {
212
- 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.`);
213
- }
215
+ throw new Error(`Workspace ${canonicalWorkspaceId} is backed by a managed worktree. Use close_workspace with its managed-worktree finalize lifecycle.`);
214
216
  }
215
217
  if (this.store) {
216
- for (const binding of this.store.listConversationBindings()) {
217
- if (binding.workspaceSessionId === workspaceId) {
218
- this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
219
- }
220
- }
221
- this.store.deleteSession(workspaceId);
218
+ this.deleteConversationBindingsForWorkspace(canonicalWorkspaceId);
219
+ this.store.setSessionStatus(canonicalWorkspaceId, "closed");
222
220
  }
223
- this.workspaces.delete(workspaceId);
221
+ this.workspaces.delete(canonicalWorkspaceId);
222
+ }
223
+ deleteWorkspace(workspaceId) {
224
+ const session = this.getWorkspaceSession(workspaceId);
225
+ if (session.mode !== "checkout") {
226
+ throw new Error(`Workspace ${session.id} is not a checkout Workspace. Delete semantics for ${session.mode} Workspaces are handled by their own lifecycle.`);
227
+ }
228
+ this.deleteConversationBindingsForWorkspace(session.id);
229
+ this.store?.deleteSession(session.id);
230
+ this.workspaces.delete(session.id);
224
231
  }
225
232
  workspaceIdsForPhysicalWorkspace(workspace) {
226
233
  const root = resolve(workspace.root);
@@ -325,30 +332,18 @@ export class WorkspaceRegistry {
325
332
  }
326
333
  return { ...result, hookReports };
327
334
  }
328
- async openReusableCheckout(path, conversationScopeId, newWorkspace, bootstrapContext) {
335
+ async openReusableCheckout(path, conversationScopeId, bootstrapContext) {
329
336
  const allowedPath = assertAllowedPath(path, this.config.allowedRoots);
330
337
  const projectKey = await canonicalPath(allowedPath);
331
338
  const targetKey = JSON.stringify(["checkout", projectKey, null]);
332
- if (!newWorkspace) {
333
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey, bootstrapContext);
334
- if (boundContext)
335
- return boundContext;
336
- }
337
- if (newWorkspace) {
338
- const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
339
- const freshContext = reusableWorkspace
340
- ? await this.cloneWorkspaceContext(reusableWorkspace)
341
- : await this.openCheckoutWorkspace(path);
342
- return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
343
- }
344
- const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
345
- const context = await this.openOnce(operationKey, async () => {
346
- const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
339
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "checkout", async (session, root) => session.mode === "checkout" && await canonicalPath(root) === projectKey, bootstrapContext);
340
+ if (boundContext)
341
+ return boundContext;
342
+ const context = await this.openOnce(targetKey, async () => {
343
+ const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout", true);
347
344
  if (!reusableWorkspace)
348
345
  return this.openCheckoutWorkspace(path);
349
- return conversationScopeId && this.store
350
- ? this.cloneWorkspaceContext(reusableWorkspace)
351
- : this.reusedWorkspaceContext(reusableWorkspace);
346
+ return this.reusedWorkspaceContext(reusableWorkspace);
352
347
  });
353
348
  return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
354
349
  }
@@ -361,27 +356,15 @@ export class WorkspaceRegistry {
361
356
  if (managedPath) {
362
357
  const worktreeKey = await canonicalPath(managedPath);
363
358
  const targetKey = JSON.stringify(["worktree-path", worktreeKey]);
364
- if (!input.newWorkspace) {
365
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey, bootstrapContext);
366
- if (boundContext)
367
- return boundContext;
368
- }
369
- if (input.newWorkspace) {
370
- const reusableWorkspace = await this.findReusableWorkspaceByDirectory(worktreeKey, "worktree");
371
- if (!reusableWorkspace) {
372
- 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.`);
373
- }
374
- return this.withConversationContext(await this.cloneWorkspaceContext(reusableWorkspace), conversationScopeId, targetKey, bootstrapContext);
375
- }
376
- const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
377
- const context = await this.openOnce(operationKey, async () => {
359
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session, root) => session.mode === "worktree" && await canonicalPath(root) === worktreeKey, bootstrapContext);
360
+ if (boundContext)
361
+ return boundContext;
362
+ const context = await this.openOnce(targetKey, async () => {
378
363
  const reusableWorkspace = await this.findReusableWorkspaceByDirectory(worktreeKey, "worktree");
379
364
  if (!reusableWorkspace) {
380
365
  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.`);
381
366
  }
382
- return conversationScopeId && this.store
383
- ? this.cloneWorkspaceContext(reusableWorkspace)
384
- : this.reusedWorkspaceContext(reusableWorkspace);
367
+ return this.reusedWorkspaceContext(reusableWorkspace);
385
368
  });
386
369
  return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
387
370
  }
@@ -396,29 +379,17 @@ export class WorkspaceRegistry {
396
379
  const context = await this.openWorktreeWorkspace(path, input.baseRef);
397
380
  return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
398
381
  }
399
- if (!input.newWorkspace) {
400
- const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session) => session.mode === "worktree" &&
401
- session.sourceRoot !== undefined &&
402
- await canonicalPath(session.sourceRoot) === sourceKey &&
403
- session.targetBranch === resolvedBase.targetBranch, bootstrapContext);
404
- if (boundContext)
405
- return boundContext;
406
- }
407
- if (input.newWorkspace) {
408
- const reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
409
- const freshContext = reusableWorkspace
410
- ? await this.cloneWorkspaceContext(reusableWorkspace)
411
- : await this.openWorktreeWorkspace(path, input.baseRef);
412
- return this.withConversationContext(freshContext, conversationScopeId, targetKey, bootstrapContext);
413
- }
414
- const operationKey = this.conversationOpenKey(targetKey, conversationScopeId);
415
- const context = await this.openOnce(operationKey, async () => {
382
+ const boundContext = await this.boundConversationContext(conversationScopeId, targetKey, "worktree", async (session) => session.mode === "worktree" &&
383
+ session.sourceRoot !== undefined &&
384
+ await canonicalPath(session.sourceRoot) === sourceKey &&
385
+ session.targetBranch === resolvedBase.targetBranch, bootstrapContext);
386
+ if (boundContext)
387
+ return boundContext;
388
+ const context = await this.openOnce(targetKey, async () => {
416
389
  const reusableWorkspace = await this.findReusableWorktreeBySource(sourceKey, resolvedBase.targetBranch);
417
390
  if (!reusableWorkspace)
418
391
  return this.openWorktreeWorkspace(path, input.baseRef);
419
- return conversationScopeId && this.store
420
- ? this.cloneWorkspaceContext(reusableWorkspace)
421
- : this.reusedWorkspaceContext(reusableWorkspace);
392
+ return this.reusedWorkspaceContext(reusableWorkspace);
422
393
  });
423
394
  return this.withConversationContext(context, conversationScopeId, targetKey, bootstrapContext);
424
395
  }
@@ -453,11 +424,6 @@ export class WorkspaceRegistry {
453
424
  }
454
425
  return keys;
455
426
  }
456
- conversationOpenKey(targetKey, conversationScopeId) {
457
- return conversationScopeId && this.store
458
- ? JSON.stringify(["conversation", conversationScopeId, targetKey])
459
- : targetKey;
460
- }
461
427
  async boundConversationContext(conversationScopeId, targetKey, mode, matches, bootstrapContext) {
462
428
  if (!conversationScopeId || !this.store)
463
429
  return undefined;
@@ -550,17 +516,62 @@ export class WorkspaceRegistry {
550
516
  worktreeAnchors.get(resolve(session.root))?.id === session.id) {
551
517
  continue;
552
518
  }
553
- this.store.deleteSession(session.id);
554
519
  this.workspaces.delete(session.id);
555
520
  }
556
521
  }
557
- async findReusableWorkspaceByDirectory(directoryKey, mode) {
558
- for (const session of this.activeSessions(mode)) {
522
+ foldLegacyWorkspaceSessions() {
523
+ if (!this.store)
524
+ return;
525
+ const groups = new Map();
526
+ for (const session of this.store.listSessions()) {
527
+ const targetPath = canonicalPersistedWorkspacePath(session.root);
528
+ const key = JSON.stringify([session.mode, targetPath]);
529
+ const group = groups.get(key) ?? [];
530
+ group.push(session);
531
+ groups.set(key, group);
532
+ }
533
+ for (const sessions of groups.values()) {
534
+ if (sessions.length < 2)
535
+ continue;
536
+ const ordered = [...sessions].sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id));
537
+ const canonical = ordered[0];
538
+ if (!canonical)
539
+ continue;
540
+ const createdAt = ordered.reduce((earliest, session) => session.createdAt < earliest ? session.createdAt : earliest, canonical.createdAt);
541
+ const lastUsedAt = ordered.reduce((latest, session) => session.lastUsedAt > latest ? session.lastUsedAt : latest, canonical.lastUsedAt);
542
+ const status = ordered.some((session) => session.status === "active")
543
+ ? "active"
544
+ : canonical.status;
545
+ this.store.foldSessions({
546
+ canonicalId: canonical.id,
547
+ aliasIds: ordered.slice(1).map((session) => session.id),
548
+ createdAt,
549
+ lastUsedAt,
550
+ status,
551
+ });
552
+ }
553
+ }
554
+ async findReusableWorkspaceByDirectory(directoryKey, mode, reopenClosedCheckout = false) {
555
+ const sessions = this.store
556
+ ? this.store.listSessions({ mode })
557
+ : this.activeSessions(mode);
558
+ for (const session of sessions) {
559
+ const reusable = session.status === "active" ||
560
+ (reopenClosedCheckout && mode === "checkout" && session.status === "closed");
561
+ if (!reusable)
562
+ continue;
559
563
  const root = await this.validSessionRoot(session);
560
564
  if (!root)
561
565
  continue;
562
566
  if (await canonicalPath(root) !== directoryKey)
563
567
  continue;
568
+ if (session.status === "closed") {
569
+ this.store?.setSessionStatus(session.id, "active");
570
+ const reopened = this.store?.getSession(session.id);
571
+ if (!reopened)
572
+ continue;
573
+ return this.workspaceFromSession(reopened, false);
574
+ }
564
575
  return this.workspaceFromSession(session, false);
565
576
  }
566
577
  return undefined;
@@ -623,14 +634,6 @@ export class WorkspaceRegistry {
623
634
  throw error;
624
635
  }
625
636
  }
626
- async cloneWorkspaceContext(workspace) {
627
- return this.createWorkspaceContext({
628
- root: workspace.root,
629
- mode: workspace.mode,
630
- sourceRoot: workspace.sourceRoot,
631
- worktree: workspace.worktree ? { ...workspace.worktree } : undefined,
632
- });
633
- }
634
637
  async reusedWorkspaceContext(workspace) {
635
638
  Object.assign(workspace, this.loadSkillsForWorkspace(workspace.root));
636
639
  workspace.capabilityGuides = loadCapabilityGuides(this.config);
@@ -651,6 +654,50 @@ export class WorkspaceRegistry {
651
654
  includeBootstrapContext: true,
652
655
  };
653
656
  }
657
+ workspaceForOpen(workspaceId) {
658
+ const session = this.store?.getSession(workspaceId);
659
+ if (session?.status === "closed" && session.mode === "checkout") {
660
+ this.store?.setSessionStatus(session.id, "active");
661
+ const reopened = this.store?.getSession(session.id);
662
+ if (!reopened) {
663
+ throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
664
+ }
665
+ return this.workspaceFromSession(reopened, true);
666
+ }
667
+ return this.getWorkspace(workspaceId);
668
+ }
669
+ getWorkspaceSession(workspaceId) {
670
+ const session = this.store?.getSession(workspaceId);
671
+ if (session)
672
+ return session;
673
+ const workspace = this.workspaces.get(workspaceId);
674
+ if (!workspace) {
675
+ throw new Error(`Unknown workspaceId: ${workspaceId}. Call open_workspace first.`);
676
+ }
677
+ return {
678
+ id: workspace.id,
679
+ root: workspace.root,
680
+ status: "active",
681
+ mode: workspace.mode,
682
+ sourceRoot: workspace.sourceRoot,
683
+ baseRef: workspace.worktree?.baseRef,
684
+ baseSha: workspace.worktree?.baseSha,
685
+ branch: workspace.worktree?.branch,
686
+ targetBranch: workspace.worktree?.targetBranch,
687
+ managed: workspace.worktree?.managed ?? false,
688
+ createdAt: "",
689
+ lastUsedAt: "",
690
+ };
691
+ }
692
+ deleteConversationBindingsForWorkspace(workspaceId) {
693
+ if (!this.store)
694
+ return;
695
+ for (const binding of this.store.listConversationBindings()) {
696
+ if (binding.workspaceSessionId === workspaceId) {
697
+ this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
698
+ }
699
+ }
700
+ }
654
701
  getWorkspace(workspaceId) {
655
702
  const workspace = this.workspaces.get(workspaceId);
656
703
  if (workspace) {
@@ -1036,6 +1083,25 @@ function bootstrapContextFingerprint(workspace, agentsFiles, availableAgentsFile
1036
1083
  };
1037
1084
  return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
1038
1085
  }
1086
+ function canonicalPersistedWorkspacePath(path) {
1087
+ const missingSegments = [];
1088
+ let candidate = path;
1089
+ while (true) {
1090
+ try {
1091
+ return resolve(realpathSync(candidate), ...missingSegments.slice().reverse());
1092
+ }
1093
+ catch (error) {
1094
+ if (!isErrnoException(error) || (error.code !== "ENOENT" && error.code !== "ENOTDIR")) {
1095
+ return resolve(path);
1096
+ }
1097
+ const parent = dirname(candidate);
1098
+ if (parent === candidate)
1099
+ return resolve(path);
1100
+ missingSegments.push(basename(candidate));
1101
+ candidate = parent;
1102
+ }
1103
+ }
1104
+ }
1039
1105
  async function canonicalPath(path) {
1040
1106
  const missingSegments = [];
1041
1107
  let candidate = path;
@@ -9,14 +9,17 @@ checkout.
9
9
  `open_workspace` returns a `workspaceId`. Continue using that ID for later tools
10
10
  in the same directory.
11
11
 
12
- `workspaceId` is a logical conversation handle, not the physical-directory
13
- identity. Reopening the same checkout in the same conversation keeps that
14
- logical ID stable. A different conversation normally receives a different
15
- `workspaceId` even when it points at the same checkout or worktree; pass an
16
- existing ID explicitly when the user wants to resume that logical workspace.
17
-
18
- A Git worktree directory is a separate physical workspace target from its source
19
- checkout, and each conversation can still have its own logical handle for it.
12
+ `workspaceId` identifies a persistent ForgeRelay Workspace rather than a
13
+ conversation-scoped handle. A canonical checkout path maps to one checkout
14
+ Workspace, and a managed worktree path maps to one managed-worktree Workspace.
15
+ Different Host conversations may bind to and reuse the same `workspaceId`; pass a
16
+ known ID explicitly when the user wants to resume that Workspace. Historical
17
+ duplicate IDs created by older ForgeRelay versions are accepted during migration
18
+ and resolve to the canonical Workspace identity.
19
+
20
+ A Git worktree directory is a separate physical Workspace target from its source
21
+ checkout. Separate parallel identities require separate managed worktrees rather
22
+ than multiple logical handles pointing at one physical target.
20
23
 
21
24
  ### Bootstrap context and workspace inventory
22
25
 
@@ -28,12 +31,11 @@ open_workspace(path="~/project")
28
31
 
29
32
  The default `context="auto"` keeps the first useful bootstrap while avoiding
30
33
  replay. ForgeRelay tracks delivered context by conversation plus canonical
31
- workspace target and a content fingerprint, not by logical `workspaceId`. If the
32
- same conversation opens or resumes another logical handle for the same physical
33
- project and the fingerprint is unchanged, the response keeps only lightweight
34
- workspace metadata. If loaded instruction contents or relevant Skill, Capability
35
- guide, profile, diagnostic, or nested-instruction metadata changes, the next
36
- automatic open returns the refreshed bootstrap.
34
+ Workspace target and a content fingerprint, independently from persistent
35
+ Workspace identity. Different conversations may reuse the same `workspaceId` while
36
+ each receives the current bootstrap once. If loaded instruction contents or
37
+ relevant Skill, Capability guide, profile, diagnostic, or nested-instruction
38
+ metadata changes, the next automatic open returns the refreshed bootstrap.
37
39
 
38
40
  Two explicit controls are available for exceptional cases:
39
41
 
@@ -42,15 +44,14 @@ open_workspace(workspaceId="ws_...", context="full")
42
44
  open_workspace(workspaceId="ws_...", context="none")
43
45
  ```
44
46
 
45
- `full` forces a bootstrap refresh. `none` opens/resumes the workspace without
47
+ `full` forces a bootstrap refresh. `none` opens/resumes the Workspace without
46
48
  returning the full project context and does not record the current fingerprint as
47
- already delivered. Context-delivery state is independent from logical-workspace
48
- selection, so closing or switching one handle does not make the conversation
49
- forget unchanged project context it already received.
49
+ already delivered. Context-delivery state remains conversation-scoped and does not
50
+ change the persistent Workspace identity.
50
51
 
51
- Do not enumerate workspace state on every normal open. When the user wants to
52
- continue an earlier task, choose among logical workspaces, or clean up accumulated
53
- state, use the same Core tool in inventory mode:
52
+ Do not enumerate Workspace state on every normal open. When the user wants to
53
+ continue earlier work, inspect known Workspaces, or clean up accumulated state, use
54
+ the same Core tool in inventory mode:
54
55
 
55
56
  ```text
56
57
  open_workspace(action="list")
@@ -62,7 +63,7 @@ Inventory is paginated, defaults to 50 entries, and caps each page at 100. It ca
62
63
  filter by `workspaceId`, persisted `status`, derived `state`, `mode`, canonical
63
64
  root/source root, or stale-only state. Entries include a compact label such as
64
65
  `project/ws_...`, checkout/worktree backing metadata, timestamps, idle duration,
65
- root validity, and whether that logical workspace is currently selected by this
66
+ root validity, and whether that Workspace is currently selected by this
66
67
  conversation. Listing is observational and does not refresh `lastUsedAt`.
67
68
 
68
69
  Treat persisted status and derived state separately. `status="active"` means the
@@ -278,18 +279,24 @@ surface, including direct `rename`/`delete` path mutations alongside `apply_patc
278
279
  unified move/rename primitive for both files and directories; ForgeRelay does not
279
280
  expose a separate `move` tool.
280
281
 
281
- Workspace IDs are logical conversation handles rather than physical-directory
282
- identities. The same conversation keeps a stable ID for a project, while another
283
- conversation normally receives a different ID pointing at the same checkout or
284
- worktree. `open_workspace` can explicitly resume a known `workspaceId`, and a
285
- fresh logical ID is created only when the user asks for one. The normal open path
286
- may still include `staleWorkspaces` as a passive reminder for same-target handles
287
- idle for more than two days; use `open_workspace(action="list")` for complete,
288
- filtered inventory when continuation or cleanup actually requires it.
289
- `close_workspace` is the single public close operation: checkout-backed workspaces
290
- release the logical handle, while managed-worktree-backed workspaces require
291
- `commitMessage` and run the safe commit / fast-forward-only integration / cleanup
292
- lifecycle.
282
+ Workspace IDs are persistent ForgeRelay identities. The same canonical checkout
283
+ or managed worktree is reused across conversations; older duplicate IDs remain
284
+ compatibility aliases that resolve to the canonical Workspace. `newWorkspace` is
285
+ deprecated and no longer allocates a second identity for the same physical target;
286
+ use `newWorktree=true` when genuinely separate Git isolation is required. The
287
+ normal open path may still include `staleWorkspaces` for an idle persistent
288
+ Workspace; use `open_workspace(action="list")` for complete, filtered inventory
289
+ when continuation or cleanup actually requires it.
290
+
291
+ For checkout-backed Workspaces, `close_workspace` defaults to `action="close"` and
292
+ preserves the Workspace identity for later reopen. A closed Workspace stays visible
293
+ through `open_workspace(action="list")`, while ordinary execution tools reject it
294
+ until `open_workspace` reactivates the same ID by path or by `workspaceId`.
295
+ `close_workspace(action="delete")` is the explicit permanent checkout cleanup path:
296
+ it removes ForgeRelay-owned Workspace state but never deletes or mutates project
297
+ files. Managed-worktree close still requires `commitMessage` and runs the safe commit /
298
+ fast-forward-only integration / cleanup lifecycle; managed-worktree, Composite, and
299
+ relayed delete semantics are completed by their later lifecycle stages.
293
300
 
294
301
  Shell commands are allowed to modify ordinary project files when that is a
295
302
  natural part of the user's requested development task; ForgeRelay does not apply
@@ -286,24 +286,27 @@ is no separate `move` MCP tool.
286
286
  Codex-mode commands run without a PTY by default. `tty: true` enables interactive
287
287
  programs when the optional `node-pty` dependency is available.
288
288
 
289
- Logical workspace IDs are conversation-scoped handles. Reopening the same project
290
- from the same conversation keeps its ID stable; a different conversation normally
291
- receives a different ID for the same physical checkout/worktree. Pass
292
- `workspaceId` to `open_workspace` to explicitly resume an existing handle in the
293
- current conversation. `newWorkspace: true` allocates a new logical handle without
294
- creating another checkout or Git worktree and should be used only on explicit user
295
- request.
296
-
297
- Bootstrap delivery is tracked separately from the selected logical workspace.
289
+ Workspace IDs identify persistent ForgeRelay Workspaces rather than individual
290
+ conversation handles. A canonical checkout path maps to one checkout Workspace, and
291
+ a managed worktree path maps to one managed-worktree Workspace; different Host
292
+ conversations can bind to and reuse the same `workspaceId`. Pass `workspaceId` to
293
+ `open_workspace` to resume that known Workspace explicitly. Historical duplicate
294
+ IDs created by older ForgeRelay versions remain accepted during migration and
295
+ resolve to the canonical Workspace. `newWorkspace: true` is retained only as a
296
+ deprecated compatibility input and no longer allocates another identity for the
297
+ same physical target; use `newWorktree: true` for genuinely separate Git isolation.
298
+
299
+ Bootstrap delivery is tracked separately from Workspace identity.
298
300
  `open_workspace` defaults to `context="auto"`: ForgeRelay fingerprints the current
299
301
  project context and returns the full AGENTS/Skills/Capability-guide/profile bootstrap
300
302
  only when that conversation has not already received the current fingerprint for
301
303
  the canonical workspace target. `context="full"` forces a refresh;
302
- `context="none"` opens or resumes the logical workspace without returning the full
303
- bootstrap and does not mark the current fingerprint as delivered. Closing or
304
- switching a logical workspace therefore does not by itself cause unchanged project
305
- context to be injected again, while changed context produces a new fingerprint and
306
- is delivered on the next `auto` open.
304
+ `context="none"` opens or resumes the Workspace without returning the full bootstrap
305
+ and does not mark the current fingerprint as delivered. Conversation-scoped
306
+ bootstrap delivery therefore remains independent from the persistent Workspace
307
+ identity: another conversation may reuse the same Workspace while independently
308
+ receiving the current bootstrap once, and changed context produces a new fingerprint
309
+ for the next `auto` open.
307
310
 
308
311
  Composite Workspaces use the same `open_workspace` entry point with
309
312
  `kind="composite"` and a human-readable `name`. They have no filesystem root of
@@ -324,26 +327,29 @@ Hooks, Skills, language services, and Activity facts remain owned by the underly
324
327
  Workspace. The Composite Activity Panel only aggregates their presentation into one
325
328
  Host Turn.
326
329
 
327
- Use `open_workspace(action="list")` only when the Agent needs to continue an older
328
- logical workspace, choose among multiple handles, or organize workspace state. The
329
- inventory is paginated (50 records by default, at most 100) and can filter by
330
- workspace ID, persisted status, derived state, mode, canonical root/source root, or
331
- stale-only state. Reading inventory does not refresh `lastUsedAt`. Persisted
330
+ Use `open_workspace(action="list")` only when the Agent needs to inspect known
331
+ Workspaces, continue earlier work, or organize Workspace state. The inventory is
332
+ paginated (50 records by default, at most 100) and can filter by Workspace ID,
333
+ persisted status, derived state, mode, canonical root/source root, or stale-only
334
+ state. Reading inventory does not refresh `lastUsedAt`. Persisted
332
335
  `status="active"` means the record has not been explicitly closed; the derived
333
336
  `state` distinguishes `active`, `stale`, `invalid`, and `closed`. A missing checkout
334
337
  or externally removed managed-worktree root can therefore remain diagnostically
335
- `status="active"` while appearing as `state="invalid"`. The existing
336
- `staleWorkspaces` field remains a passive same-workspace reminder for old handles;
337
- `action="list"` is the formal on-demand inventory path.
338
-
339
- `close_workspace` removes a checkout-backed logical handle without deleting checkout
340
- files. For a managed-worktree-backed workspace, `close_workspace` requires
341
- `commitMessage` and runs the existing safe worktree finalize lifecycle: close Hooks,
342
- commit when needed, fast-forward-only integration, cleanup, and alias invalidation.
343
- For a Composite Workspace, the same tool means dissolve: it removes only the
344
- Composite identity, membership, and Composite-facing panel/lifecycle state. It does
345
- not close members, finalize member worktrees, interrupt member processes, delete
346
- files, or remove remote registrations and relayed Workspace routes.
338
+ `status="active"` while appearing as `state="invalid"`. Canonical identity means
339
+ ordinary same-target opens no longer accumulate duplicate inventory rows;
340
+ `action="list"` remains the formal on-demand inventory path.
341
+
342
+ For checkout-backed Workspaces, `close_workspace` now defaults to `action="close"`:
343
+ it marks the persistent Workspace closed, removes current conversation bindings, and
344
+ keeps the same Workspace identity available for later `open_workspace` by path or ID.
345
+ Closed Workspaces remain visible in inventory but ordinary execution tools reject them
346
+ until reopened. `action="delete"` permanently removes ForgeRelay-owned checkout
347
+ identity/state while never deleting or mutating the user's checkout directory.
348
+ Managed-worktree close still requires `commitMessage` and runs the existing safe
349
+ finalize lifecycle; managed-worktree delete semantics land in the next lifecycle
350
+ stage. Composite close still dissolves in this stage, and Composite delete remains
351
+ unavailable until its persistent lifecycle stage. Relayed delete is likewise deferred
352
+ to Workspace Relay lifecycle parity.
347
353
 
348
354
  Hot workspace/session activity timestamps are coalesced in memory and flushed to the
349
355
  SQLite state database in a transaction at most every five minutes; normal shutdown
@@ -366,7 +372,7 @@ the initial run request terminates a not-yet-handed-off process so ForgeRelay do
366
372
  not leave an orphan process whose `processId` the Agent never received.
367
373
 
368
374
  Completed background processes are delivered once with a later tool result for the
369
- same logical workspace ID. Full buffered completion output is retained for five
375
+ same Workspace ID. Full buffered completion output is retained for five
370
376
  minutes; after that ForgeRelay compacts the completion to a bounded head/tail record
371
377
  and keeps it deliverable for up to 24 hours, still subject to the global completed
372
378
  process count bound. Completed processes no longer prevent `close_workspace`; the