@akira-tl/forgerelay 0.8.0 → 0.8.2

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,23 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.8.2] - 2026-08-31
8
+
9
+ ### Changed
10
+
11
+ - Managed-worktree Workspace close now preserves the same identity for later reopen with fresh backing; explicit delete safely finalizes active work before removing ForgeRelay-owned state.
12
+ - Composite Workspace close now preserves identity and member topology for later reopen; explicit delete dissolves only the Composite relationship and leaves member Workspaces untouched.
13
+
14
+ ## [0.8.1] - 2026-08-30
15
+
16
+ ### Added
17
+
18
+ - `close_workspace(action="delete")` explicitly removes ForgeRelay-owned checkout Workspace state without deleting or mutating project files.
19
+
20
+ ### Changed
21
+
22
+ - 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.
23
+
7
24
  ## [0.8.0] - 2026-08-30
8
25
 
9
26
  ### Changed
package/README.md CHANGED
@@ -136,8 +136,10 @@ ForgeRelay does not merge member filesystems, Git state, Hooks, Skills, processe
136
136
  or audit facts, and it never infers a member from the tool type or purpose text.
137
137
  The Composite Activity Panel presents member operations in one Host Turn while the
138
138
  actual facts remain owned by the member Workspace. `close_workspace` on a Composite
139
- Workspace means **dissolve**: the Composite identity and membership are removed, but
140
- member Workspaces, worktrees, running jobs, files, and relay routes are preserved.
139
+ Workspace now preserves the Composite identity and member topology as `closed`; a
140
+ later `open_workspace` restores the same `cws_...` identity. `action="delete"` is the
141
+ explicit dissolve operation. Neither close nor delete closes member Workspaces,
142
+ finalizes their worktrees, stops their jobs, changes their files, or removes relay routes.
141
143
 
142
144
  ### Progressive MCP context
143
145
 
@@ -12,7 +12,7 @@ export class CompositeActivityCoordinator {
12
12
  return this.composites.has(workspaceId);
13
13
  }
14
14
  beginPanel(workspaceId, conversationScopeId) {
15
- this.composites.open(workspaceId);
15
+ this.composites.touchActive(workspaceId);
16
16
  const snapshot = this.queries.beginTurn(conversationScopeId, workspaceId);
17
17
  this.turns.set(snapshot.turnId, {
18
18
  compositeWorkspaceId: workspaceId,
@@ -13,16 +13,20 @@ export class CompositeWorkspaceRegistry {
13
13
  has(workspaceId) {
14
14
  return this.records.has(workspaceId);
15
15
  }
16
+ isActive(workspaceId) {
17
+ return this.records.get(workspaceId)?.status === "active";
18
+ }
16
19
  create(name) {
17
20
  const normalized = normalizeName(name);
18
21
  const existing = [...this.records.values()].find((record) => record.name === normalized);
19
22
  if (existing)
20
- return this.touch(existing.id);
23
+ return this.open(existing.id);
21
24
  const now = new Date().toISOString();
22
25
  const record = {
23
26
  id: `cws_${randomBytes(5).toString("hex")}`,
24
27
  kind: "composite",
25
28
  name: normalized,
29
+ status: "active",
26
30
  members: [],
27
31
  createdAt: now,
28
32
  lastUsedAt: now,
@@ -38,7 +42,17 @@ export class CompositeWorkspaceRegistry {
38
42
  return cloneRecord(record);
39
43
  }
40
44
  open(workspaceId) {
41
- return this.touch(workspaceId);
45
+ const record = this.requireRecord(workspaceId);
46
+ record.status = "active";
47
+ return this.touchRecord(record);
48
+ }
49
+ close(workspaceId) {
50
+ const record = this.requireActive(workspaceId);
51
+ record.status = "closed";
52
+ return this.touchRecord(record);
53
+ }
54
+ touchActive(workspaceId) {
55
+ return this.touchRecord(this.requireActive(workspaceId));
42
56
  }
43
57
  list() {
44
58
  return [...this.records.values()]
@@ -46,7 +60,7 @@ export class CompositeWorkspaceRegistry {
46
60
  .sort((left, right) => right.lastUsedAt.localeCompare(left.lastUsedAt));
47
61
  }
48
62
  addMember(workspaceId, input) {
49
- const record = this.requireRecord(workspaceId);
63
+ const record = this.requireActive(workspaceId);
50
64
  const name = normalizeMemberName(input.name);
51
65
  const purpose = input.purpose.trim();
52
66
  if (!purpose)
@@ -64,7 +78,7 @@ export class CompositeWorkspaceRegistry {
64
78
  return cloneRecord(record);
65
79
  }
66
80
  updateMember(workspaceId, memberName, input) {
67
- const record = this.requireRecord(workspaceId);
81
+ const record = this.requireActive(workspaceId);
68
82
  const currentName = normalizeMemberName(memberName);
69
83
  const index = record.members.findIndex((member) => member.name === currentName);
70
84
  if (index < 0)
@@ -94,7 +108,7 @@ export class CompositeWorkspaceRegistry {
94
108
  return cloneRecord(record);
95
109
  }
96
110
  removeMember(workspaceId, memberName) {
97
- const record = this.requireRecord(workspaceId);
111
+ const record = this.requireActive(workspaceId);
98
112
  const name = normalizeMemberName(memberName);
99
113
  const index = record.members.findIndex((member) => member.name === name);
100
114
  if (index < 0)
@@ -105,7 +119,7 @@ export class CompositeWorkspaceRegistry {
105
119
  return cloneRecord(record);
106
120
  }
107
121
  member(workspaceId, memberName) {
108
- const record = this.requireRecord(workspaceId);
122
+ const record = this.requireActive(workspaceId);
109
123
  const name = normalizeMemberName(memberName);
110
124
  const member = record.members.find((entry) => entry.name === name);
111
125
  if (!member)
@@ -118,12 +132,18 @@ export class CompositeWorkspaceRegistry {
118
132
  this.persist();
119
133
  return cloneRecord(record);
120
134
  }
121
- touch(workspaceId) {
122
- const record = this.requireRecord(workspaceId);
135
+ touchRecord(record) {
123
136
  record.lastUsedAt = new Date().toISOString();
124
137
  this.persist();
125
138
  return cloneRecord(record);
126
139
  }
140
+ requireActive(workspaceId) {
141
+ const record = this.requireRecord(workspaceId);
142
+ if (record.status !== "active") {
143
+ throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before use.`);
144
+ }
145
+ return record;
146
+ }
127
147
  requireRecord(workspaceId) {
128
148
  const record = this.records.get(workspaceId);
129
149
  if (!record)
@@ -140,7 +160,7 @@ export class CompositeWorkspaceRegistry {
140
160
  return;
141
161
  throw new Error(`Failed to load Composite Workspace state: ${errorMessage(error)}`);
142
162
  }
143
- if (parsed?.version !== 1 || !Array.isArray(parsed.workspaces)) {
163
+ if ((parsed?.version !== 1 && parsed?.version !== 2) || !Array.isArray(parsed.workspaces)) {
144
164
  throw new Error("Composite Workspace state has an unsupported format.");
145
165
  }
146
166
  for (const record of parsed.workspaces) {
@@ -148,6 +168,7 @@ export class CompositeWorkspaceRegistry {
148
168
  continue;
149
169
  this.records.set(record.id, {
150
170
  ...record,
171
+ status: record.status === "closed" ? "closed" : "active",
151
172
  members: Array.isArray(record.members) ? record.members.map((member) => ({ ...member })) : [],
152
173
  });
153
174
  }
@@ -155,7 +176,7 @@ export class CompositeWorkspaceRegistry {
155
176
  persist() {
156
177
  mkdirSync(this.stateDir, { recursive: true });
157
178
  const state = {
158
- version: 1,
179
+ version: 2,
159
180
  workspaces: [...this.records.values()].map(cloneRecord),
160
181
  };
161
182
  const tempPath = `${this.statePath}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
@@ -57,6 +57,28 @@ export async function createManagedWorktree(input) {
57
57
  managed: true,
58
58
  };
59
59
  }
60
+ export async function discardFreshManagedWorktree(input) {
61
+ const sourceRoot = assertAllowedPath(input.worktree.sourceRoot, input.config.allowedRoots);
62
+ const worktreePath = assertAllowedPath(input.worktree.path, [input.config.worktreeRoot]);
63
+ const worktreeBranch = await currentBranch(worktreePath);
64
+ if (worktreeBranch !== input.worktree.branch) {
65
+ throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", `Cannot roll back reopened worktree because it is on branch ${JSON.stringify(worktreeBranch)} instead of ${JSON.stringify(input.worktree.branch)}.`);
66
+ }
67
+ if ((await git(["status", "--porcelain=v1"], worktreePath)).trim().length > 0) {
68
+ throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", "Cannot roll back reopened worktree because it acquired uncommitted changes during reopen.");
69
+ }
70
+ const worktreeHead = (await git(["rev-parse", "HEAD"], worktreePath)).trim();
71
+ if (worktreeHead !== input.worktree.baseSha) {
72
+ throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", "Cannot roll back reopened worktree because its branch advanced during reopen.");
73
+ }
74
+ try {
75
+ await git(["worktree", "remove", worktreePath], sourceRoot);
76
+ await git(["branch", "-D", input.worktree.branch], sourceRoot);
77
+ }
78
+ catch (error) {
79
+ throw new GitWorktreeError("GIT_WORKTREE_CLOSE_FAILED", `Git failed to remove the temporary managed worktree created for a failed reopen. ${error instanceof Error ? error.message : String(error)}`);
80
+ }
81
+ }
60
82
  export async function closeManagedWorktree(input) {
61
83
  const sourceRoot = assertAllowedPath(input.worktree.sourceRoot, input.config.allowedRoots);
62
84
  const worktreePath = assertAllowedPath(input.worktree.path, [input.config.worktreeRoot]);
@@ -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 = `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} preserves Workspace identity. Managed close finalizes backing and needs commitMessage. Composite close preserves members; delete removes only Composite state. Active worktree delete still finalizes safely; checkout files are never deleted.`;
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
@@ -2078,6 +2078,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2078
2078
  memberAction: z.enum(["add", "update", "remove"]).optional(),
2079
2079
  kind: z.enum(["workspace", "composite"]).optional(),
2080
2080
  name: z.string().optional(),
2081
+ status: z.string().optional(),
2082
+ state: z.enum(["active", "stale", "invalid", "closed"]).optional(),
2081
2083
  members: z.array(z.object({
2082
2084
  name: z.string(),
2083
2085
  purpose: z.string(),
@@ -2134,6 +2136,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2134
2136
  workspaceId: z.string(),
2135
2137
  kind: z.literal("composite"),
2136
2138
  name: z.string(),
2139
+ status: z.enum(["active", "closed"]),
2140
+ state: z.enum(["active", "closed"]),
2137
2141
  members: z.array(z.object({
2138
2142
  name: z.string(),
2139
2143
  purpose: z.string(),
@@ -2161,6 +2165,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2161
2165
  if (!workspaceId || !compositeWorkspaces.has(workspaceId)) {
2162
2166
  throw new Error("open_workspace action=member requires an existing Composite Workspace workspaceId.");
2163
2167
  }
2168
+ if (!compositeWorkspaces.isActive(workspaceId)) {
2169
+ throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before changing members.`);
2170
+ }
2164
2171
  if (!memberAction || !member) {
2165
2172
  throw new Error("open_workspace action=member requires memberAction and member.");
2166
2173
  }
@@ -2294,25 +2301,30 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2294
2301
  newWorkspace !== undefined || context !== undefined) {
2295
2302
  throw new Error("open_workspace action=list does not accept path, relay, name, memberName, baseRef, newWorktree, newWorkspace, or context. Use kind/root/workspaceId/mode/status/state/staleOnly for inventory filters.");
2296
2303
  }
2304
+ const compositeInventory = () => compositeWorkspaces.list()
2305
+ .filter((entry) => workspaceId === undefined || entry.id === workspaceId)
2306
+ .filter((entry) => status === undefined || entry.status === status)
2307
+ .filter((entry) => state === undefined || entry.status === state)
2308
+ .map((entry) => ({
2309
+ workspaceId: entry.id,
2310
+ kind: entry.kind,
2311
+ name: entry.name,
2312
+ status: entry.status,
2313
+ state: entry.status,
2314
+ members: entry.members,
2315
+ createdAt: entry.createdAt,
2316
+ lastUsedAt: entry.lastUsedAt,
2317
+ }));
2297
2318
  if (kind === "composite") {
2298
- if (root !== undefined || mode !== undefined || status !== undefined || state !== undefined ||
2299
- staleOnly !== undefined || offset !== undefined || limit !== undefined) {
2300
- throw new Error("Composite Workspace inventory does not accept root/mode/status/state/staleOnly/offset/limit filters; use workspaceId when selecting one Composite Workspace.");
2319
+ if (root !== undefined || mode !== undefined || staleOnly !== undefined ||
2320
+ offset !== undefined || limit !== undefined) {
2321
+ throw new Error("Composite Workspace inventory does not accept root/mode/staleOnly/offset/limit filters; use workspaceId/status/state when selecting Composite Workspaces.");
2301
2322
  }
2302
- const composites = compositeWorkspaces.list()
2303
- .filter((entry) => workspaceId === undefined || entry.id === workspaceId)
2304
- .map((entry) => ({
2305
- workspaceId: entry.id,
2306
- kind: entry.kind,
2307
- name: entry.name,
2308
- members: entry.members,
2309
- createdAt: entry.createdAt,
2310
- lastUsedAt: entry.lastUsedAt,
2311
- }));
2312
- const instruction = "Resume a Composite Workspace with open_workspace(action=\"open\", workspaceId=...). Use close_workspace only when the user chooses to dissolve it.";
2323
+ const composites = compositeInventory();
2324
+ const instruction = "Open a Composite Workspace by workspaceId to resume or reopen it. close_workspace preserves its identity; action=delete permanently dissolves only Composite-owned state.";
2313
2325
  const result = [
2314
2326
  `Composite Workspace inventory: ${composites.length} matching record${composites.length === 1 ? "" : "s"}.`,
2315
- ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] members=${entry.members.length} last-used=${entry.lastUsedAt}`),
2327
+ ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] state=${entry.state} members=${entry.members.length} last-used=${entry.lastUsedAt}`),
2316
2328
  instruction,
2317
2329
  ].join("\n");
2318
2330
  return {
@@ -2325,20 +2337,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2325
2337
  };
2326
2338
  }
2327
2339
  const inventory = await workspaces.listWorkspaces({ workspaceId, mode, root, status, state, staleOnly, offset, limit }, { conversationScopeId, protectedWorkspaceIds });
2328
- const composites = kind === "workspace"
2340
+ const composites = kind === "workspace" || root !== undefined || mode !== undefined || staleOnly
2329
2341
  ? []
2330
- : compositeWorkspaces.list().map((entry) => ({
2331
- workspaceId: entry.id,
2332
- kind: entry.kind,
2333
- name: entry.name,
2334
- members: entry.members,
2335
- createdAt: entry.createdAt,
2336
- lastUsedAt: entry.lastUsedAt,
2337
- }));
2342
+ : compositeInventory();
2338
2343
  const nextOffset = inventory.page.offset + inventory.page.limit;
2339
2344
  const instruction = [
2340
2345
  "Resume a selected workspaceId with open_workspace(action=\"open\", workspaceId=...).",
2341
- "Use close_workspace only after the user chooses cleanup or Composite dissolution; never close inventory entries automatically.",
2346
+ "Use close_workspace only after the user chooses cleanup; Composite close preserves identity, while action=delete dissolves only Composite-owned state. Never close inventory entries automatically.",
2342
2347
  inventory.page.hasMore
2343
2348
  ? `More matching workspaces are available; continue with offset=${nextOffset}.`
2344
2349
  : undefined,
@@ -2356,7 +2361,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2356
2361
  `root=${entry.root}`,
2357
2362
  `last-used=${entry.lastUsedAt}`,
2358
2363
  ].filter(Boolean).join(" ")),
2359
- ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] kind=composite members=${entry.members.length}`),
2364
+ ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] kind=composite state=${entry.state} members=${entry.members.length}`),
2360
2365
  instruction,
2361
2366
  ].join("\n");
2362
2367
  logToolCall(config, {
@@ -2406,7 +2411,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2406
2411
  composite.members.length > 0
2407
2412
  ? "Before first work on a member, reopen this Composite Workspace with memberName=<member> and context=auto to receive that member's project bootstrap without creating an implicit current member."
2408
2413
  : undefined,
2409
- "Use close_workspace only when the user chooses to dissolve this Composite Workspace; dissolution does not close or clean up member Workspaces.",
2414
+ "close_workspace preserves this Composite identity for later reopen. Use action=delete only when the user explicitly wants to dissolve the Composite relationship; neither operation closes or cleans up member Workspaces.",
2410
2415
  ].join("\n\n");
2411
2416
  const response = {
2412
2417
  content: [textBlock(instruction)],
@@ -2419,7 +2424,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2419
2424
  path: composite.name,
2420
2425
  members: composite.members,
2421
2426
  instruction,
2422
- summary: { members: composite.members.length },
2427
+ summary: { members: composite.members.length, status: composite.status },
2423
2428
  },
2424
2429
  },
2425
2430
  structuredContent: {
@@ -2427,6 +2432,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2427
2432
  workspaceId: composite.id,
2428
2433
  kind: "composite",
2429
2434
  name: composite.name,
2435
+ status: composite.status,
2436
+ state: composite.status,
2430
2437
  members: composite.members,
2431
2438
  ...(memberContext ? { memberContext } : {}),
2432
2439
  instruction,
@@ -2921,9 +2928,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2921
2928
  });
2922
2929
  registerAppTool(server, toolNames.closeWorkspace, {
2923
2930
  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.",
2931
+ description: "Close or explicitly delete one Workspace after the user chooses cleanup. action=close (default) preserves checkout, managed-worktree, and Composite identity for later reopen. action=delete permanently removes ForgeRelay-owned state. Managed-worktree-backed Workspaces still finalize safely when active and require commitMessage. Composite delete dissolves only Composite-owned state and never closes member Workspaces. Checkout project files are never deleted; relayed delete remains unavailable.",
2925
2932
  inputSchema: {
2926
- workspaceId: z.string().describe("Workspace identifier to close."),
2933
+ workspaceId: z.string().describe("Workspace identifier to close or delete."),
2934
+ action: z
2935
+ .enum(["close", "delete"])
2936
+ .optional()
2937
+ .describe("Defaults to close. close preserves checkout identity, managed-worktree identity, and Composite identity for later reopen; delete removes ForgeRelay-owned state. Composite delete dissolves only the Composite relationship. Active managed worktrees still require safe finalization and commitMessage; checkout project files are never deleted."),
2927
2938
  commitMessage: z
2928
2939
  .string()
2929
2940
  .min(1)
@@ -2932,6 +2943,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2932
2943
  },
2933
2944
  outputSchema: resultOutputSchema({
2934
2945
  workspaceId: z.string(),
2946
+ action: z.enum(["close", "delete"]).optional(),
2935
2947
  kind: z.enum(["workspace", "composite"]).optional(),
2936
2948
  mode: z.enum(["checkout", "worktree"]).optional(),
2937
2949
  name: z.string().optional(),
@@ -2940,6 +2952,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2940
2952
  purpose: z.string(),
2941
2953
  workspaceId: z.string(),
2942
2954
  })).optional(),
2955
+ status: z.enum(["active", "closed"]).optional(),
2943
2956
  dissolved: z.boolean().optional(),
2944
2957
  sourceRoot: z.string().optional(),
2945
2958
  branch: z.string().optional(),
@@ -2951,20 +2964,24 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2951
2964
  }),
2952
2965
  _meta: {},
2953
2966
  annotations: WRITE_TOOL_ANNOTATIONS,
2954
- }, async ({ workspaceId, commitMessage }, extra) => {
2967
+ }, async ({ workspaceId, action = "close", commitMessage }, extra) => {
2955
2968
  if (compositeWorkspaces.has(workspaceId)) {
2956
2969
  if (commitMessage !== undefined) {
2957
- throw new Error("close_workspace commitMessage is not valid when dissolving a Composite Workspace.");
2970
+ throw new Error("close_workspace commitMessage is not valid for a Composite Workspace.");
2958
2971
  }
2959
- const composite = compositeWorkspaces.dissolve(workspaceId);
2972
+ const composite = action === "delete"
2973
+ ? compositeWorkspaces.dissolve(workspaceId)
2974
+ : compositeWorkspaces.close(workspaceId);
2960
2975
  compositeActivity.forgetComposite(workspaceId);
2961
2976
  workspacePanelStates.delete(workspaceId);
2962
2977
  const result = [
2963
- `Dissolved Composite Workspace ${composite.name} (${workspaceId}).`,
2978
+ action === "delete"
2979
+ ? `Deleted Composite Workspace ${composite.name} (${workspaceId}); its Composite relationship and ForgeRelay-owned Composite state were dissolved.`
2980
+ : `Closed Composite Workspace ${composite.name} (${workspaceId}); its identity and member topology were preserved for later reopen.`,
2964
2981
  composite.members.length > 0
2965
2982
  ? `Preserved member Workspaces: ${composite.members.map((member) => `${member.name} [${member.workspaceId}]`).join(", ")}.`
2966
2983
  : "The Composite Workspace had no members.",
2967
- "Member Workspace handles, managed worktrees, processes, files, and Workspace Relay routes were not closed or cleaned up.",
2984
+ "Member Workspace handles, managed worktrees, processes, files, and Workspace Relay routes were not closed, finalized, deleted, or otherwise mutated.",
2968
2985
  ].join("\n");
2969
2986
  return {
2970
2987
  content: [textBlock(result)],
@@ -2972,34 +2989,132 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2972
2989
  tool: toolNames.closeWorkspace,
2973
2990
  card: {
2974
2991
  workspaceId,
2992
+ action,
2975
2993
  kind: "composite",
2976
2994
  name: composite.name,
2977
2995
  members: composite.members,
2978
- dissolved: true,
2996
+ ...(action === "close" ? { status: "closed" } : {}),
2997
+ dissolved: action === "delete",
2979
2998
  payload: { content: [textBlock(result)] },
2980
2999
  },
2981
3000
  },
2982
3001
  structuredContent: {
2983
3002
  result,
2984
3003
  workspaceId,
3004
+ action,
2985
3005
  kind: "composite",
2986
3006
  name: composite.name,
2987
3007
  members: composite.members,
2988
- dissolved: true,
3008
+ ...(action === "close" ? { status: "closed" } : {}),
3009
+ dissolved: action === "delete",
2989
3010
  },
2990
3011
  };
2991
3012
  }
2992
3013
  if (remoteWorkspaces.has(workspaceId)) {
3014
+ if (action === "delete") {
3015
+ throw new Error("close_workspace action=delete is not available for relayed Workspaces until Workspace Relay lifecycle parity is implemented.");
3016
+ }
2993
3017
  const response = await remoteWorkspaces.closeWorkspace(workspaceId, commitMessage, hostScopeIdFor(extra._meta, extra.sessionId));
2994
3018
  workspacePanelStates.delete(workspaceId);
2995
3019
  return response;
2996
3020
  }
2997
- const workspace = workspaces.getWorkspace(workspaceId);
3021
+ const session = workspaces.getWorkspaceSession(workspaceId);
3022
+ if (action === "delete" && session.mode === "checkout") {
3023
+ if (commitMessage !== undefined) {
3024
+ throw new Error("close_workspace commitMessage is not valid with action=delete for a checkout Workspace.");
3025
+ }
3026
+ if (processSessions.activeWorkspaceIds().has(session.id)) {
3027
+ throw new Error(`Workspace ${session.id} still owns a running process. Poll, interrupt, or wait for it before deleting this Workspace.`);
3028
+ }
3029
+ const response = await runToolWithHooks(hooks, {
3030
+ signal: extra.signal,
3031
+ tool: toolNames.closeWorkspace,
3032
+ invocation: {
3033
+ workspaceId: session.id,
3034
+ workspaceRoot: session.root,
3035
+ workspaceMode: session.mode,
3036
+ sourceRoot: session.sourceRoot,
3037
+ },
3038
+ payload: { workspaceId: session.id, action: "delete", mode: session.mode },
3039
+ operation: async () => {
3040
+ workspaces.deleteWorkspace(session.id);
3041
+ await reviewCheckpoints.releaseWorkspace(session.id);
3042
+ const result = `Deleted ForgeRelay Workspace ${session.id}. Physical project files were not removed.`;
3043
+ return {
3044
+ content: [textBlock(result)],
3045
+ _meta: {
3046
+ tool: toolNames.closeWorkspace,
3047
+ card: {
3048
+ workspaceId: session.id,
3049
+ action: "delete",
3050
+ mode: "checkout",
3051
+ payload: { content: [textBlock(result)] },
3052
+ },
3053
+ },
3054
+ structuredContent: {
3055
+ result,
3056
+ workspaceId: session.id,
3057
+ action: "delete",
3058
+ mode: "checkout",
3059
+ },
3060
+ };
3061
+ },
3062
+ });
3063
+ workspacePanelStates.delete(session.id);
3064
+ return response;
3065
+ }
3066
+ if (action === "delete" && session.mode === "worktree" && session.status === "closed") {
3067
+ if (commitMessage !== undefined) {
3068
+ throw new Error("close_workspace commitMessage is not needed when deleting an already-closed managed-worktree Workspace.");
3069
+ }
3070
+ const hookRoot = session.sourceRoot ?? session.root;
3071
+ const response = await runToolWithHooks(hooks, {
3072
+ signal: extra.signal,
3073
+ tool: toolNames.closeWorkspace,
3074
+ invocation: {
3075
+ workspaceId: session.id,
3076
+ workspaceRoot: hookRoot,
3077
+ workspaceMode: session.mode,
3078
+ sourceRoot: session.sourceRoot,
3079
+ },
3080
+ payload: { workspaceId: session.id, action: "delete", mode: session.mode },
3081
+ operation: async () => {
3082
+ workspaces.deleteWorkspace(session.id);
3083
+ await reviewCheckpoints.releaseWorkspace(session.id);
3084
+ const result = `Deleted closed managed-worktree Workspace ${session.id}. Its already-removed worktree backing was not recreated.`;
3085
+ return {
3086
+ content: [textBlock(result)],
3087
+ _meta: {
3088
+ tool: toolNames.closeWorkspace,
3089
+ card: {
3090
+ workspaceId: session.id,
3091
+ action: "delete",
3092
+ mode: "worktree",
3093
+ sourceRoot: session.sourceRoot,
3094
+ targetBranch: session.targetBranch,
3095
+ payload: { content: [textBlock(result)] },
3096
+ },
3097
+ },
3098
+ structuredContent: {
3099
+ result,
3100
+ workspaceId: session.id,
3101
+ action: "delete",
3102
+ mode: "worktree",
3103
+ sourceRoot: session.sourceRoot,
3104
+ targetBranch: session.targetBranch,
3105
+ },
3106
+ };
3107
+ },
3108
+ });
3109
+ workspacePanelStates.delete(session.id);
3110
+ return response;
3111
+ }
3112
+ const workspace = workspaces.getWorkspace(session.id);
2998
3113
  const response = await runToolWithHooks(hooks, {
2999
3114
  signal: extra.signal,
3000
3115
  tool: toolNames.closeWorkspace,
3001
3116
  invocation: workspaceHookInvocation(workspace),
3002
- payload: { workspaceId, commitMessage, mode: workspace.mode },
3117
+ payload: { workspaceId: workspace.id, action, commitMessage, mode: workspace.mode },
3003
3118
  afterCwd: (response) => "sourceRoot" in response.structuredContent &&
3004
3119
  typeof response.structuredContent.sourceRoot === "string"
3005
3120
  ? response.structuredContent.sourceRoot
@@ -3007,7 +3122,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3007
3122
  operation: async () => {
3008
3123
  if (workspace.mode === "worktree") {
3009
3124
  if (!commitMessage) {
3010
- throw new Error(`Managed-worktree-backed workspace ${workspaceId} requires commitMessage when closing.`);
3125
+ throw new Error(`Managed-worktree-backed Workspace ${workspace.id} requires commitMessage when ${action === "delete" ? "deleting active work" : "closing"}.`);
3011
3126
  }
3012
3127
  const physicalWorkspaceIds = workspaces.workspaceIdsForPhysicalWorkspace(workspace);
3013
3128
  const busyWorkspaceIds = physicalWorkspaceIds
@@ -3019,14 +3134,19 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3019
3134
  const retirement = await codeIntelligence.retireWorkspaceRoot(workspace.root);
3020
3135
  let closed;
3021
3136
  try {
3022
- closed = await workspaces.closeWorktree(workspaceId, commitMessage);
3137
+ closed = await workspaces.closeWorktree(workspace.id, commitMessage);
3023
3138
  }
3024
3139
  finally {
3025
3140
  codeIntelligence.restoreWorkspaceRoot(retirement.root);
3026
3141
  }
3027
3142
  await Promise.all(physicalWorkspaceIds.map((id) => reviewCheckpoints.releaseWorkspace(id)));
3143
+ if (action === "delete") {
3144
+ workspaces.deleteWorkspace(workspace.id);
3145
+ }
3028
3146
  const result = [
3029
- `Closed managed-worktree-backed workspace ${workspaceId}.`,
3147
+ action === "delete"
3148
+ ? `Safely finalized and deleted managed-worktree Workspace ${workspace.id}.`
3149
+ : `Closed managed-worktree-backed Workspace ${workspace.id}; its identity was preserved for later reopen.`,
3030
3150
  `Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
3031
3151
  `Source checkout: ${closed.sourceRoot}`,
3032
3152
  `Commit: ${closed.commitSha}`,
@@ -3046,7 +3166,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3046
3166
  _meta: {
3047
3167
  tool: toolNames.closeWorkspace,
3048
3168
  card: {
3049
- workspaceId,
3169
+ workspaceId: workspace.id,
3170
+ action,
3050
3171
  mode: "worktree",
3051
3172
  sourceRoot: closed.sourceRoot,
3052
3173
  branch: closed.branch,
@@ -3060,7 +3181,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3060
3181
  },
3061
3182
  structuredContent: {
3062
3183
  result,
3063
- workspaceId,
3184
+ workspaceId: workspace.id,
3185
+ action,
3064
3186
  mode: "worktree",
3065
3187
  sourceRoot: closed.sourceRoot,
3066
3188
  branch: closed.branch,
@@ -3075,27 +3197,34 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3075
3197
  if (commitMessage !== undefined) {
3076
3198
  throw new Error("close_workspace commitMessage is only valid for managed-worktree-backed workspaces.");
3077
3199
  }
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.`);
3200
+ const checkoutWorkspaceId = workspace.id;
3201
+ if (processSessions.activeWorkspaceIds().has(checkoutWorkspaceId)) {
3202
+ throw new Error(`Workspace ${checkoutWorkspaceId} still owns a running process. Poll, interrupt, or wait for it before closing this workspace.`);
3080
3203
  }
3081
- workspaces.closeWorkspace(workspaceId);
3082
- await reviewCheckpoints.releaseWorkspace(workspaceId);
3083
- const result = `Closed checkout-backed workspace ${workspaceId}. Physical project files were not removed.`;
3204
+ workspaces.closeWorkspace(checkoutWorkspaceId);
3205
+ await reviewCheckpoints.releaseWorkspace(checkoutWorkspaceId);
3206
+ const result = `Closed checkout-backed Workspace ${checkoutWorkspaceId}; its ForgeRelay identity was preserved for later reopen. Physical project files were not removed.`;
3084
3207
  return {
3085
3208
  content: [textBlock(result)],
3086
3209
  _meta: {
3087
3210
  tool: toolNames.closeWorkspace,
3088
3211
  card: {
3089
- workspaceId,
3212
+ workspaceId: checkoutWorkspaceId,
3213
+ action: "close",
3090
3214
  mode: "checkout",
3091
3215
  payload: { content: [textBlock(result)] },
3092
3216
  },
3093
3217
  },
3094
- structuredContent: { result, workspaceId, mode: "checkout" },
3218
+ structuredContent: {
3219
+ result,
3220
+ workspaceId: checkoutWorkspaceId,
3221
+ action: "close",
3222
+ mode: "checkout",
3223
+ },
3095
3224
  };
3096
3225
  },
3097
3226
  });
3098
- workspacePanelStates.delete(workspaceId);
3227
+ workspacePanelStates.delete(workspace.id);
3099
3228
  return response;
3100
3229
  });
3101
3230
  registerAppTool(server, toolNames.read, {