@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 +17 -0
- package/README.md +4 -2
- package/dist/composite-activity.js +1 -1
- package/dist/composite-workspaces.js +31 -10
- package/dist/git-worktrees.js +22 -0
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/server.js +181 -52
- package/dist/workspace-store.js +25 -0
- package/dist/workspaces.js +169 -25
- package/docs/chatgpt-coding-workflow.md +24 -5
- package/docs/configuration.md +26 -9
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +190 -33
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
|
|
140
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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`;
|
package/dist/git-worktrees.js
CHANGED
|
@@ -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 = `
|
|
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 ||
|
|
2299
|
-
|
|
2300
|
-
throw new Error("Composite Workspace inventory does not accept root/mode/
|
|
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 =
|
|
2303
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
2970
|
+
throw new Error("close_workspace commitMessage is not valid for a Composite Workspace.");
|
|
2958
2971
|
}
|
|
2959
|
-
const composite =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
3079
|
-
|
|
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(
|
|
3082
|
-
await reviewCheckpoints.releaseWorkspace(
|
|
3083
|
-
const result = `Closed checkout-backed
|
|
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: {
|
|
3218
|
+
structuredContent: {
|
|
3219
|
+
result,
|
|
3220
|
+
workspaceId: checkoutWorkspaceId,
|
|
3221
|
+
action: "close",
|
|
3222
|
+
mode: "checkout",
|
|
3223
|
+
},
|
|
3095
3224
|
};
|
|
3096
3225
|
},
|
|
3097
3226
|
});
|
|
3098
|
-
workspacePanelStates.delete(
|
|
3227
|
+
workspacePanelStates.delete(workspace.id);
|
|
3099
3228
|
return response;
|
|
3100
3229
|
});
|
|
3101
3230
|
registerAppTool(server, toolNames.read, {
|