@akira-tl/forgerelay 0.8.0 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.1] - 2026-08-30
8
+
9
+ ### Added
10
+
11
+ - `close_workspace(action="delete")` explicitly removes ForgeRelay-owned checkout Workspace state without deleting or mutating project files.
12
+
13
+ ### Changed
14
+
15
+ - Checkout Workspace close is now reversible: it preserves the canonical identity for reopen by path or ID, closed Workspaces remain listable but non-executable, legacy aliases resolve canonically, and idle GC no longer deletes persistent identity.
16
+
7
17
  ## [0.8.0] - 2026-08-30
8
18
 
9
19
  ### Changed
@@ -38,7 +38,7 @@ function capabilityContractInstructions(config) {
38
38
  const staleWorkspacePolicy = config.toolMode === "codex"
39
39
  ? ""
40
40
  : ` If ${toolNames.openWorkspace} reports stale workspaces, let the user choose resume or ${toolNames.closeWorkspace}; never auto-close.`;
41
- const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse workspaceId from ${toolNames.openWorkspace}; change it only when asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} releases checkout workspaces or finalizes managed worktrees; managed close requires commitMessage.`;
41
+ const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse workspaceId from ${toolNames.openWorkspace}; change it only when asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} defaults to closing checkout Workspaces for later reopen; action=delete removes only ForgeRelay-owned checkout state, never project files. Managed close finalizes the worktree and requires commitMessage.`;
42
42
  const activityPanel = `Project-work order: ${toolNames.openWorkspace} if needed → activity_panel(workspaceId) once → work tools. activity_panel is the single ForgeRelay UI render tool: Workspace above Activity. A new workspaceId creates a new card. Never call activity_panel before needed ${toolNames.openWorkspace}.`;
43
43
  const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
44
44
  const capabilityGuides = `For optional capabilities from ${toolNames.openWorkspace}, use ${toolNames.capability}; if unfamiliar, describe first and read its advertised capability guide with ${toolNames.read}.`;
package/dist/server.js CHANGED
@@ -2921,9 +2921,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2921
2921
  });
2922
2922
  registerAppTool(server, toolNames.closeWorkspace, {
2923
2923
  title: "Close workspace",
2924
- description: "Close one Workspace after the user chooses cleanup. In v0.8.0, Composite close still dissolves only the Composite identity/member links. Checkout close removes ForgeRelay state but never project files. Managed-worktree-backed Workspaces run the safe finalize lifecycle (hooks, commit, fast-forward integration, cleanup) and require commitMessage. Running processes block ordinary Workspace closure.",
2924
+ description: "Close or explicitly delete one Workspace after the user chooses cleanup. action=close (default) preserves checkout identity for later reopen. action=delete permanently removes ForgeRelay-owned checkout state but never project files. Managed-worktree-backed Workspaces still use the safe finalize lifecycle and require commitMessage; Composite and relayed delete semantics are not available in this stage.",
2925
2925
  inputSchema: {
2926
- workspaceId: z.string().describe("Workspace identifier to close."),
2926
+ workspaceId: z.string().describe("Workspace identifier to close or delete."),
2927
+ action: z
2928
+ .enum(["close", "delete"])
2929
+ .optional()
2930
+ .describe("Defaults to close. close preserves checkout identity for later reopen; delete permanently removes ForgeRelay-owned checkout state without deleting project files."),
2927
2931
  commitMessage: z
2928
2932
  .string()
2929
2933
  .min(1)
@@ -2932,6 +2936,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2932
2936
  },
2933
2937
  outputSchema: resultOutputSchema({
2934
2938
  workspaceId: z.string(),
2939
+ action: z.enum(["close", "delete"]).optional(),
2935
2940
  kind: z.enum(["workspace", "composite"]).optional(),
2936
2941
  mode: z.enum(["checkout", "worktree"]).optional(),
2937
2942
  name: z.string().optional(),
@@ -2951,8 +2956,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2951
2956
  }),
2952
2957
  _meta: {},
2953
2958
  annotations: WRITE_TOOL_ANNOTATIONS,
2954
- }, async ({ workspaceId, commitMessage }, extra) => {
2959
+ }, async ({ workspaceId, action = "close", commitMessage }, extra) => {
2955
2960
  if (compositeWorkspaces.has(workspaceId)) {
2961
+ if (action === "delete") {
2962
+ throw new Error("close_workspace action=delete is not available for Composite Workspaces until the Composite persistent lifecycle stage.");
2963
+ }
2956
2964
  if (commitMessage !== undefined) {
2957
2965
  throw new Error("close_workspace commitMessage is not valid when dissolving a Composite Workspace.");
2958
2966
  }
@@ -2972,6 +2980,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2972
2980
  tool: toolNames.closeWorkspace,
2973
2981
  card: {
2974
2982
  workspaceId,
2983
+ action: "close",
2975
2984
  kind: "composite",
2976
2985
  name: composite.name,
2977
2986
  members: composite.members,
@@ -2982,6 +2991,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2982
2991
  structuredContent: {
2983
2992
  result,
2984
2993
  workspaceId,
2994
+ action: "close",
2985
2995
  kind: "composite",
2986
2996
  name: composite.name,
2987
2997
  members: composite.members,
@@ -2990,16 +3000,67 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2990
3000
  };
2991
3001
  }
2992
3002
  if (remoteWorkspaces.has(workspaceId)) {
3003
+ if (action === "delete") {
3004
+ throw new Error("close_workspace action=delete is not available for relayed Workspaces until Workspace Relay lifecycle parity is implemented.");
3005
+ }
2993
3006
  const response = await remoteWorkspaces.closeWorkspace(workspaceId, commitMessage, hostScopeIdFor(extra._meta, extra.sessionId));
2994
3007
  workspacePanelStates.delete(workspaceId);
2995
3008
  return response;
2996
3009
  }
3010
+ if (action === "delete") {
3011
+ if (commitMessage !== undefined) {
3012
+ throw new Error("close_workspace commitMessage is not valid with action=delete for a checkout Workspace.");
3013
+ }
3014
+ const session = workspaces.getWorkspaceSession(workspaceId);
3015
+ if (session.mode !== "checkout") {
3016
+ throw new Error("close_workspace action=delete is not available for managed-worktree-backed Workspaces until their persistent lifecycle stage.");
3017
+ }
3018
+ if (processSessions.activeWorkspaceIds().has(session.id)) {
3019
+ throw new Error(`Workspace ${session.id} still owns a running process. Poll, interrupt, or wait for it before deleting this Workspace.`);
3020
+ }
3021
+ const response = await runToolWithHooks(hooks, {
3022
+ signal: extra.signal,
3023
+ tool: toolNames.closeWorkspace,
3024
+ invocation: {
3025
+ workspaceId: session.id,
3026
+ workspaceRoot: session.root,
3027
+ workspaceMode: session.mode,
3028
+ sourceRoot: session.sourceRoot,
3029
+ },
3030
+ payload: { workspaceId: session.id, action: "delete", mode: session.mode },
3031
+ operation: async () => {
3032
+ workspaces.deleteWorkspace(session.id);
3033
+ await reviewCheckpoints.releaseWorkspace(session.id);
3034
+ const result = `Deleted ForgeRelay Workspace ${session.id}. Physical project files were not removed.`;
3035
+ return {
3036
+ content: [textBlock(result)],
3037
+ _meta: {
3038
+ tool: toolNames.closeWorkspace,
3039
+ card: {
3040
+ workspaceId: session.id,
3041
+ action: "delete",
3042
+ mode: "checkout",
3043
+ payload: { content: [textBlock(result)] },
3044
+ },
3045
+ },
3046
+ structuredContent: {
3047
+ result,
3048
+ workspaceId: session.id,
3049
+ action: "delete",
3050
+ mode: "checkout",
3051
+ },
3052
+ };
3053
+ },
3054
+ });
3055
+ workspacePanelStates.delete(session.id);
3056
+ return response;
3057
+ }
2997
3058
  const workspace = workspaces.getWorkspace(workspaceId);
2998
3059
  const response = await runToolWithHooks(hooks, {
2999
3060
  signal: extra.signal,
3000
3061
  tool: toolNames.closeWorkspace,
3001
3062
  invocation: workspaceHookInvocation(workspace),
3002
- payload: { workspaceId, commitMessage, mode: workspace.mode },
3063
+ payload: { workspaceId, action: "close", commitMessage, mode: workspace.mode },
3003
3064
  afterCwd: (response) => "sourceRoot" in response.structuredContent &&
3004
3065
  typeof response.structuredContent.sourceRoot === "string"
3005
3066
  ? response.structuredContent.sourceRoot
@@ -3047,6 +3108,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3047
3108
  tool: toolNames.closeWorkspace,
3048
3109
  card: {
3049
3110
  workspaceId,
3111
+ action: "close",
3050
3112
  mode: "worktree",
3051
3113
  sourceRoot: closed.sourceRoot,
3052
3114
  branch: closed.branch,
@@ -3061,6 +3123,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3061
3123
  structuredContent: {
3062
3124
  result,
3063
3125
  workspaceId,
3126
+ action: "close",
3064
3127
  mode: "worktree",
3065
3128
  sourceRoot: closed.sourceRoot,
3066
3129
  branch: closed.branch,
@@ -3075,27 +3138,34 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3075
3138
  if (commitMessage !== undefined) {
3076
3139
  throw new Error("close_workspace commitMessage is only valid for managed-worktree-backed workspaces.");
3077
3140
  }
3078
- if (processSessions.activeWorkspaceIds().has(workspaceId)) {
3079
- throw new Error(`Workspace ${workspaceId} still owns a running process. Poll, interrupt, or wait for it before closing this workspace.`);
3141
+ const checkoutWorkspaceId = workspace.id;
3142
+ if (processSessions.activeWorkspaceIds().has(checkoutWorkspaceId)) {
3143
+ throw new Error(`Workspace ${checkoutWorkspaceId} still owns a running process. Poll, interrupt, or wait for it before closing this workspace.`);
3080
3144
  }
3081
- workspaces.closeWorkspace(workspaceId);
3082
- await reviewCheckpoints.releaseWorkspace(workspaceId);
3083
- const result = `Closed checkout-backed workspace ${workspaceId}. Physical project files were not removed.`;
3145
+ workspaces.closeWorkspace(checkoutWorkspaceId);
3146
+ await reviewCheckpoints.releaseWorkspace(checkoutWorkspaceId);
3147
+ const result = `Closed checkout-backed Workspace ${checkoutWorkspaceId}; its ForgeRelay identity was preserved for later reopen. Physical project files were not removed.`;
3084
3148
  return {
3085
3149
  content: [textBlock(result)],
3086
3150
  _meta: {
3087
3151
  tool: toolNames.closeWorkspace,
3088
3152
  card: {
3089
- workspaceId,
3153
+ workspaceId: checkoutWorkspaceId,
3154
+ action: "close",
3090
3155
  mode: "checkout",
3091
3156
  payload: { content: [textBlock(result)] },
3092
3157
  },
3093
3158
  },
3094
- structuredContent: { result, workspaceId, mode: "checkout" },
3159
+ structuredContent: {
3160
+ result,
3161
+ workspaceId: checkoutWorkspaceId,
3162
+ action: "close",
3163
+ mode: "checkout",
3164
+ },
3095
3165
  };
3096
3166
  },
3097
3167
  });
3098
- workspacePanelStates.delete(workspaceId);
3168
+ workspacePanelStates.delete(workspace.id);
3099
3169
  return response;
3100
3170
  });
3101
3171
  registerAppTool(server, toolNames.read, {
@@ -71,6 +71,9 @@ export class WorkspaceRegistry {
71
71
  .filter((binding) => binding.conversationScopeId === openOptions.conversationScopeId)
72
72
  .map((binding) => binding.workspaceSessionId)
73
73
  : []);
74
+ const workspaceIdFilter = input.workspaceId
75
+ ? this.store?.getSession(input.workspaceId)?.id ?? input.workspaceId
76
+ : undefined;
74
77
  const rootKey = input.root
75
78
  ? await canonicalPath(assertAllowedPath(input.root, [...this.config.allowedRoots, this.config.worktreeRoot]))
76
79
  : undefined;
@@ -110,7 +113,7 @@ export class WorkspaceRegistry {
110
113
  const entry = entries[index];
111
114
  if (!session || !entry)
112
115
  continue;
113
- if (input.workspaceId && entry.workspaceId !== input.workspaceId)
116
+ if (workspaceIdFilter && entry.workspaceId !== workspaceIdFilter)
114
117
  continue;
115
118
  if (input.status && entry.status !== input.status)
116
119
  continue;
@@ -149,7 +152,7 @@ export class WorkspaceRegistry {
149
152
  };
150
153
  }
151
154
  async resumeWorkspace(workspaceId, conversationScopeId, bootstrapContext = "auto") {
152
- const workspace = this.getWorkspace(workspaceId);
155
+ const workspace = this.workspaceForOpen(workspaceId);
153
156
  const context = await this.reusedWorkspaceContext(workspace);
154
157
  if (!conversationScopeId || !this.store) {
155
158
  return {
@@ -209,22 +212,23 @@ export class WorkspaceRegistry {
209
212
  const workspace = this.getWorkspace(workspaceId);
210
213
  const canonicalWorkspaceId = workspace.id;
211
214
  if (workspace.mode === "worktree") {
212
- const aliases = this.activeSessions("worktree")
213
- .filter((session) => resolve(session.root) === resolve(workspace.root));
214
- if (aliases.length <= 1) {
215
- throw new Error(`Workspace ${canonicalWorkspaceId} is backed by a managed worktree. Use close_workspace with its managed-worktree finalize lifecycle instead of releasing the physical target as a separate handle.`);
216
- }
215
+ throw new Error(`Workspace ${canonicalWorkspaceId} is backed by a managed worktree. Use close_workspace with its managed-worktree finalize lifecycle.`);
217
216
  }
218
217
  if (this.store) {
219
- for (const binding of this.store.listConversationBindings()) {
220
- if (binding.workspaceSessionId === canonicalWorkspaceId) {
221
- this.store.deleteConversationBinding(binding.conversationScopeId, binding.targetKey);
222
- }
223
- }
224
- this.store.deleteSession(canonicalWorkspaceId);
218
+ this.deleteConversationBindingsForWorkspace(canonicalWorkspaceId);
219
+ this.store.setSessionStatus(canonicalWorkspaceId, "closed");
225
220
  }
226
221
  this.workspaces.delete(canonicalWorkspaceId);
227
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);
231
+ }
228
232
  workspaceIdsForPhysicalWorkspace(workspace) {
229
233
  const root = resolve(workspace.root);
230
234
  return this.activeSessions(workspace.mode)
@@ -336,7 +340,7 @@ export class WorkspaceRegistry {
336
340
  if (boundContext)
337
341
  return boundContext;
338
342
  const context = await this.openOnce(targetKey, async () => {
339
- const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout");
343
+ const reusableWorkspace = await this.findReusableWorkspaceByDirectory(projectKey, "checkout", true);
340
344
  if (!reusableWorkspace)
341
345
  return this.openCheckoutWorkspace(path);
342
346
  return this.reusedWorkspaceContext(reusableWorkspace);
@@ -547,13 +551,27 @@ export class WorkspaceRegistry {
547
551
  });
548
552
  }
549
553
  }
550
- async findReusableWorkspaceByDirectory(directoryKey, mode) {
551
- for (const session of this.activeSessions(mode)) {
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;
552
563
  const root = await this.validSessionRoot(session);
553
564
  if (!root)
554
565
  continue;
555
566
  if (await canonicalPath(root) !== directoryKey)
556
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
+ }
557
575
  return this.workspaceFromSession(session, false);
558
576
  }
559
577
  return undefined;
@@ -636,6 +654,50 @@ export class WorkspaceRegistry {
636
654
  includeBootstrapContext: true,
637
655
  };
638
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
+ }
639
701
  getWorkspace(workspaceId) {
640
702
  const workspace = this.workspaces.get(workspaceId);
641
703
  if (workspace) {
@@ -288,11 +288,15 @@ normal open path may still include `staleWorkspaces` for an idle persistent
288
288
  Workspace; use `open_workspace(action="list")` for complete, filtered inventory
289
289
  when continuation or cleanup actually requires it.
290
290
 
291
- In v0.8.0, `close_workspace` is still the existing public close operation:
292
- checkout-backed Workspaces remove their ForgeRelay record without deleting project
293
- files, while managed-worktree-backed Workspaces require `commitMessage` and run the
294
- safe commit / fast-forward-only integration / cleanup lifecycle. Persistent
295
- closed/reopen semantics land in the following lifecycle stage.
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.
296
300
 
297
301
  Shell commands are allowed to modify ordinary project files when that is a
298
302
  natural part of the user's requested development task; ForgeRelay does not apply
@@ -339,15 +339,17 @@ or externally removed managed-worktree root can therefore remain diagnostically
339
339
  ordinary same-target opens no longer accumulate duplicate inventory rows;
340
340
  `action="list"` remains the formal on-demand inventory path.
341
341
 
342
- In v0.8.0, `close_workspace` still removes a checkout-backed Workspace record without
343
- deleting checkout files; the persistent closed/reopen lifecycle is introduced by
344
- the next lifecycle stage. For a managed-worktree-backed Workspace, `close_workspace` requires
345
- `commitMessage` and runs the existing safe worktree finalize lifecycle: close Hooks,
346
- commit when needed, fast-forward-only integration, cleanup, and alias invalidation.
347
- For a Composite Workspace, the same tool means dissolve: it removes only the
348
- Composite identity, membership, and Composite-facing panel/lifecycle state. It does
349
- not close members, finalize member worktrees, interrupt member processes, delete
350
- files, or remove remote registrations and relayed Workspace routes.
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.
351
353
 
352
354
  Hot workspace/session activity timestamps are coalesced in memory and flushed to the
353
355
  SQLite state database in a transaction at most every five minutes; normal shutdown
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
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",
@@ -32,6 +32,7 @@ const stateDir = resolve(acceptanceRoot, "state");
32
32
  const worktreeRoot = resolve(acceptanceRoot, "worktrees");
33
33
  const hookLog = resolve(acceptanceRoot, "hooks.jsonl");
34
34
  const checkoutWorkspace = resolve(acceptanceRoot, "workspace");
35
+ const lifecycleDeleteWorkspace = resolve(acceptanceRoot, "delete-workspace");
35
36
  const codeIntelligenceLog = resolve(acceptanceRoot, "code-intelligence-lsp.jsonl");
36
37
  const fakeLanguageServer = resolve(repoRoot, "src", "lsp", "test-fixtures", "fake-lsp-server.mjs");
37
38
  const gitProject = resolve(acceptanceRoot, "git-project");
@@ -45,6 +46,8 @@ await assertDebugPortFree();
45
46
  rmSync(acceptanceRoot, { recursive: true, force: true });
46
47
  mkdirSync(acceptanceRoot, { recursive: true });
47
48
  setupGitProject(checkoutWorkspace);
49
+ setupGitProject(lifecycleDeleteWorkspace);
50
+ writeFileSync(join(lifecycleDeleteWorkspace, "keep.txt"), "keep checkout files\n");
48
51
  setupCodeIntelligenceAcceptanceProject({
49
52
  root: checkoutWorkspace,
50
53
  fakeLanguageServer,
@@ -279,42 +282,52 @@ try {
279
282
  assert.equal(capabilityCatalog[0].available, true);
280
283
  assert.equal(capabilityCatalog[0].guide.name, "lifecycle-hooks");
281
284
 
282
- const freshLogical = callTool(oauth.accessToken, sessionId, 84, "open_workspace", {
285
+ const repeatedOpen = callTool(oauth.accessToken, sessionId, 84, "open_workspace", {
283
286
  path: checkoutWorkspace,
284
287
  newWorkspace: true,
285
288
  }, workspaceConversationMeta);
286
- const freshLogicalId = freshLogical.structuredContent.workspaceId;
287
- assert.notEqual(freshLogicalId, workspaceId);
288
- assert.equal(freshLogical.structuredContent.action, "open");
289
+ assert.equal(repeatedOpen.structuredContent.workspaceId, workspaceId);
290
+ assert.equal(repeatedOpen.structuredContent.action, "open");
289
291
  assert.equal(
290
- freshLogical.structuredContent.contextFingerprint,
292
+ repeatedOpen.structuredContent.contextFingerprint,
291
293
  opened.structuredContent.contextFingerprint,
292
294
  );
293
- assert.equal(freshLogical.structuredContent.agentsFiles, undefined);
294
- assert.equal(freshLogical.structuredContent.capabilityGuides, undefined);
295
+ assert.equal(repeatedOpen.structuredContent.agentsFiles, undefined);
296
+ assert.equal(repeatedOpen.structuredContent.capabilityGuides, undefined);
295
297
 
296
298
  const workspaceInventory = callTool(oauth.accessToken, sessionId, 85, "open_workspace", {
297
299
  action: "list",
298
300
  root: checkoutWorkspace,
299
301
  }, workspaceConversationMeta);
300
302
  assert.equal(workspaceInventory.structuredContent.action, "list");
301
- assert.equal(workspaceInventory.structuredContent.summary.matching, 2);
303
+ assert.equal(workspaceInventory.structuredContent.summary.matching, 1);
302
304
  const inventoryEntries = workspaceInventory.structuredContent.workspaces;
303
- assert.equal(inventoryEntries.length, 2);
304
- assert.equal(
305
- inventoryEntries.find((entry) => entry.workspaceId === freshLogicalId)?.current,
306
- true,
307
- );
308
- assert.equal(
309
- inventoryEntries.find((entry) => entry.workspaceId === workspaceId)?.current,
310
- false,
311
- );
305
+ assert.equal(inventoryEntries.length, 1);
306
+ assert.equal(inventoryEntries[0].workspaceId, workspaceId);
307
+ assert.equal(inventoryEntries[0].current, true);
308
+
309
+ const closedWorkspace = callTool(oauth.accessToken, sessionId, 86, "close_workspace", {
310
+ workspaceId,
311
+ });
312
+ assert.equal(closedWorkspace.isError, undefined);
313
+ assert.equal(closedWorkspace.structuredContent.workspaceId, workspaceId);
314
+ assert.equal(closedWorkspace.structuredContent.action, "close");
315
+
316
+ const closedInventory = callTool(oauth.accessToken, sessionId, 87, "open_workspace", {
317
+ action: "list",
318
+ workspaceId,
319
+ }, workspaceConversationMeta);
320
+ assert.equal(closedInventory.structuredContent.workspaces.length, 1);
321
+ assert.equal(closedInventory.structuredContent.workspaces[0].state, "closed");
322
+ assert.equal(closedInventory.structuredContent.workspaces[0].current, false);
312
323
 
313
- const closedFreshLogical = callTool(oauth.accessToken, sessionId, 86, "close_workspace", {
314
- workspaceId: freshLogicalId,
324
+ const closedRead = callTool(oauth.accessToken, sessionId, 89, "read", {
325
+ workspaceId,
326
+ path: "AGENTS.md",
315
327
  });
316
- assert.equal(closedFreshLogical.isError, undefined);
317
- const resumedOriginal = callTool(oauth.accessToken, sessionId, 87, "open_workspace", {
328
+ assert.equal(closedRead.isError, true);
329
+
330
+ const resumedOriginal = callTool(oauth.accessToken, sessionId, 90, "open_workspace", {
318
331
  workspaceId,
319
332
  }, workspaceConversationMeta);
320
333
  assert.equal(resumedOriginal.structuredContent.workspaceId, workspaceId);
@@ -323,9 +336,33 @@ try {
323
336
  resumedOriginal.structuredContent.contextFingerprint,
324
337
  opened.structuredContent.contextFingerprint,
325
338
  );
339
+
340
+ const deleteOpened = callTool(oauth.accessToken, sessionId, 91, "open_workspace", {
341
+ path: lifecycleDeleteWorkspace,
342
+ context: "none",
343
+ }, { "openai/session": "acceptance-workspace-delete" });
344
+ const deleteWorkspaceId = deleteOpened.structuredContent.workspaceId;
345
+ const deleteClosed = callTool(oauth.accessToken, sessionId, 92, "close_workspace", {
346
+ workspaceId: deleteWorkspaceId,
347
+ });
348
+ assert.equal(deleteClosed.structuredContent.action, "close");
349
+ const deletedWorkspace = callTool(oauth.accessToken, sessionId, 93, "close_workspace", {
350
+ workspaceId: deleteWorkspaceId,
351
+ action: "delete",
352
+ });
353
+ assert.equal(deletedWorkspace.isError, undefined);
354
+ assert.equal(deletedWorkspace.structuredContent.workspaceId, deleteWorkspaceId);
355
+ assert.equal(deletedWorkspace.structuredContent.action, "delete");
356
+ assert.equal(readFileSync(join(lifecycleDeleteWorkspace, "keep.txt"), "utf8"), "keep checkout files\n");
357
+ const deletedInventory = callTool(oauth.accessToken, sessionId, 94, "open_workspace", {
358
+ action: "list",
359
+ workspaceId: deleteWorkspaceId,
360
+ });
361
+ assert.equal(deletedInventory.structuredContent.workspaces.length, 0);
362
+
326
363
  pass(
327
- "workspace context + inventory",
328
- `${workspaceId} -> ${freshLogicalId} -> list -> close -> resume without bootstrap replay`,
364
+ "workspace lifecycle + inventory",
365
+ `${workspaceId} canonical reuse -> close -> list -> reopen; explicit delete preserves checkout files`,
329
366
  );
330
367
 
331
368
  const directCapability = callTool(oauth.accessToken, sessionId, 79, "capability", {
@@ -972,13 +1009,14 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
972
1009
 
973
1010
  function exerciseSubagentHooks(runtimeEnv, debugStateDir, workspaceId, workspaceRoot) {
974
1011
  const seedScript = [
975
- 'import { LocalAgentStore } from "./src/local-agent-store.js";',
976
- 'const store = new LocalAgentStore(process.env.FORGERELAY_STATE_DIR);',
1012
+ 'import { SubagentSessionStore } from "./src/subagents/sessions/store.js";',
1013
+ 'const store = new SubagentSessionStore(process.env.FORGERELAY_STATE_DIR);',
977
1014
  'const record = store.create({',
978
1015
  ` workspaceId: ${JSON.stringify(workspaceId)},`,
979
1016
  ` workspaceRoot: ${JSON.stringify(workspaceRoot)},`,
980
1017
  ' profileName: "debug-missing-profile",',
981
1018
  ' provider: "codex",',
1019
+ ' activeRun: { id: "run_debug_acceptance", startedAt: new Date().toISOString() },',
982
1020
  '});',
983
1021
  'store.close();',
984
1022
  'process.stdout.write(record.id);',
@@ -1006,11 +1044,13 @@ function exerciseSubagentHooks(runtimeEnv, debugStateDir, workspaceId, workspace
1006
1044
  }
1007
1045
 
1008
1046
  const inspectScript = [
1009
- 'import { LocalAgentStore } from "./src/local-agent-store.js";',
1010
- 'const store = new LocalAgentStore(process.env.FORGERELAY_STATE_DIR);',
1047
+ 'import { SubagentSessionStore } from "./src/subagents/sessions/store.js";',
1048
+ 'import { SubagentDeliveryMailbox } from "./src/subagents/sessions/delivery-mailbox.js";',
1049
+ 'const store = new SubagentSessionStore(process.env.FORGERELAY_STATE_DIR);',
1011
1050
  `const record = store.get(${JSON.stringify(agentId)});`,
1051
+ `const deliveries = new SubagentDeliveryMailbox(process.env.FORGERELAY_STATE_DIR).claimSession(${JSON.stringify(workspaceId)}, ${JSON.stringify(agentId)});`,
1012
1052
  'store.close();',
1013
- 'process.stdout.write(JSON.stringify({ status: record?.status, error: record?.error }));',
1053
+ 'process.stdout.write(JSON.stringify({ status: record?.status, latestRun: record?.latestRun, delivery: deliveries[0] }));',
1014
1054
  ].join("\n");
1015
1055
  const inspected = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", inspectScript], {
1016
1056
  cwd: repoRoot,
@@ -1021,9 +1061,11 @@ function exerciseSubagentHooks(runtimeEnv, debugStateDir, workspaceId, workspace
1021
1061
  throw new Error(`unable to inspect debug subagent: ${inspected.stderr.trim()}`);
1022
1062
  }
1023
1063
  const record = JSON.parse(inspected.stdout);
1024
- assert.equal(record.status, "error");
1025
- assert.match(record.error, /Subagent profile not found/);
1026
- pass("subagent hook path", `${agentId} stopped in deterministic error path without calling a provider`);
1064
+ assert.equal(record.status, "idle");
1065
+ assert.equal(record.latestRun?.status, "failed");
1066
+ assert.equal(record.delivery?.outcome, "failed");
1067
+ assert.match(record.delivery?.error ?? "", /Subagent profile not found/);
1068
+ pass("subagent hook path", `${agentId} stopped in deterministic failed Run without calling a provider`);
1027
1069
  }
1028
1070
 
1029
1071
  function toolText(result) {