@akira-tl/forgerelay 0.8.1 → 0.8.3

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/dist/server.js CHANGED
@@ -21,7 +21,7 @@ import { HostTurnStore } from "./activity/host-turn-store.js";
21
21
  import { registerActivityQueryTools } from "./activity/mcp-query-tools.js";
22
22
  import { ActivityLifecycle, } from "./activity/lifecycle.js";
23
23
  import { ActivityQueryService } from "./activity/query-service.js";
24
- import { buildCapabilityFingerprint } from "./capabilities.js";
24
+ import { buildCapabilityFingerprint, loadCapabilityGuides } from "./capabilities.js";
25
25
  import { CapabilityError, createCapabilityRegistry, } from "./capability-registry.js";
26
26
  import { deletePath, renamePath } from "./file-mutations.js";
27
27
  import { downloadIncomingArtifact, isArtifactDownloadSupportedPlatform, } from "./artifact-tools.js";
@@ -53,6 +53,7 @@ import { ACTIVITY_PANEL_APP_LEGACY_URI, ACTIVITY_PANEL_APP_URI_TEMPLATE, MCP_APP
53
53
  import { shutdownHttpServer } from "./server-shutdown.js";
54
54
  import { formatPathForPrompt } from "./skills.js";
55
55
  import { createWorkspaceStore } from "./workspace-store.js";
56
+ import { WorkspaceTaskStore } from "./workspace-tasks.js";
56
57
  import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
57
58
  import { formatAvailableSubagentProfile, summarizeSubagentProfile } from "./subagents/profiles.js";
58
59
  import { formatSubagentProviderAvailabilitySummary, formatUnavailableSubagentProvider, getSubagentProviderAvailabilitySnapshot, } from "./subagents/providers/availability.js";
@@ -821,6 +822,7 @@ function workspaceHookInvocation(workspace) {
821
822
  function capabilityContextFor(workspace) {
822
823
  return {
823
824
  workspaceId: workspace.id,
825
+ workspaceKind: "workspace",
824
826
  workspaceRoot: workspace.root,
825
827
  guides: workspace.capabilityGuides.map((guide) => ({
826
828
  name: guide.name,
@@ -830,6 +832,56 @@ function capabilityContextFor(workspace) {
830
832
  })),
831
833
  };
832
834
  }
835
+ function compositeCapabilityContext(workspaceId, guides) {
836
+ return {
837
+ workspaceId,
838
+ workspaceKind: "composite",
839
+ guides: guides.map((guide) => ({
840
+ name: guide.name,
841
+ description: guide.description,
842
+ whenToRead: guide.whenToRead,
843
+ path: formatPathForPrompt(guide.filePath),
844
+ })),
845
+ };
846
+ }
847
+ function requireCapabilityWorkspaceRoot(context) {
848
+ if (!context.workspaceRoot) {
849
+ throw new CapabilityError("capability_unavailable", `Capability execution requires a filesystem-backed Workspace; ${context.workspaceId} is ${context.workspaceKind}.`);
850
+ }
851
+ return context.workspaceRoot;
852
+ }
853
+ function runWorkspaceTasksCapability(store, workspaceId, input) {
854
+ switch (input.operation) {
855
+ case "get":
856
+ return store.read(workspaceId);
857
+ case "list.create":
858
+ return store.createList(workspaceId, { name: input.name, position: input.position });
859
+ case "list.update":
860
+ return store.updateList(workspaceId, input.listId, {
861
+ name: input.name,
862
+ state: input.state,
863
+ position: input.position,
864
+ });
865
+ case "list.delete":
866
+ return store.deleteList(workspaceId, input.listId);
867
+ case "task.create":
868
+ return store.createTask(workspaceId, input.listId, {
869
+ subject: input.subject,
870
+ content: input.content,
871
+ status: input.status,
872
+ position: input.position,
873
+ });
874
+ case "task.update":
875
+ return store.updateTask(workspaceId, input.listId, input.taskId, {
876
+ subject: input.subject,
877
+ content: input.content,
878
+ status: input.status,
879
+ position: input.position,
880
+ });
881
+ case "task.delete":
882
+ return store.deleteTask(workspaceId, input.listId, input.taskId);
883
+ }
884
+ }
833
885
  async function reviewWorkspaceChanges(reviewCheckpoints, workspace) {
834
886
  return reviewCheckpoints.reviewChanges({
835
887
  workspaceId: workspace.id,
@@ -1192,6 +1244,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1192
1244
  const connectionScopeId = `mcp-connection:${randomUUID()}`;
1193
1245
  const remoteWorkspaces = new RemoteWorkspaceRelay(config.configDir, config.stateDir);
1194
1246
  const compositeWorkspaces = new CompositeWorkspaceRegistry(config.stateDir);
1247
+ const workspaceTasks = new WorkspaceTaskStore(config.stateDir);
1248
+ const compositeTaskGuides = loadCapabilityGuides(config).filter((guide) => guide.name === "workspace-tasks");
1195
1249
  const compositeActivity = new CompositeActivityCoordinator(compositeWorkspaces, activityQueries, remoteWorkspaces);
1196
1250
  const resolveExecutionTarget = (workspaceId, memberName) => {
1197
1251
  if (!compositeWorkspaces.has(workspaceId)) {
@@ -1248,6 +1302,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1248
1302
  const capabilityRegistry = createCapabilityRegistry({
1249
1303
  inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
1250
1304
  ...subagentMcp.registryDependencies,
1305
+ workspaceTasks: {
1306
+ available: true,
1307
+ run: async (input, context) => ({
1308
+ value: runWorkspaceTasksCapability(workspaceTasks, context.workspaceId, input),
1309
+ }),
1310
+ },
1251
1311
  batchExecute: {
1252
1312
  available: batchExecuteAvailable,
1253
1313
  unavailableReason: batchExecuteAvailable
@@ -1270,7 +1330,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1270
1330
  run: async (input, context, options) => {
1271
1331
  try {
1272
1332
  return {
1273
- value: await codeIntelligence.run(context.workspaceRoot, input, { signal: options.signal }),
1333
+ value: await codeIntelligence.run(requireCapabilityWorkspaceRoot(context), input, { signal: options.signal }),
1274
1334
  };
1275
1335
  }
1276
1336
  catch (error) {
@@ -1289,7 +1349,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1289
1349
  run: async (context) => {
1290
1350
  const review = await reviewWorkspaceChanges(reviewCheckpoints, {
1291
1351
  id: context.workspaceId,
1292
- root: context.workspaceRoot,
1352
+ root: requireCapabilityWorkspaceRoot(context),
1293
1353
  });
1294
1354
  return {
1295
1355
  value: {
@@ -1317,7 +1377,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1317
1377
  const downloaded = await downloadIncomingArtifact({
1318
1378
  registry: incomingArtifactRegistry,
1319
1379
  workspaceId: context.workspaceId,
1320
- workspaceRoot: context.workspaceRoot,
1380
+ workspaceRoot: requireCapabilityWorkspaceRoot(context),
1321
1381
  maxFileBytes: config.artifactMaxFileBytes,
1322
1382
  file: input.file,
1323
1383
  path: input.path,
@@ -2078,6 +2138,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2078
2138
  memberAction: z.enum(["add", "update", "remove"]).optional(),
2079
2139
  kind: z.enum(["workspace", "composite"]).optional(),
2080
2140
  name: z.string().optional(),
2141
+ status: z.string().optional(),
2142
+ state: z.enum(["active", "stale", "invalid", "closed"]).optional(),
2081
2143
  members: z.array(z.object({
2082
2144
  name: z.string(),
2083
2145
  purpose: z.string(),
@@ -2134,6 +2196,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2134
2196
  workspaceId: z.string(),
2135
2197
  kind: z.literal("composite"),
2136
2198
  name: z.string(),
2199
+ status: z.enum(["active", "closed"]),
2200
+ state: z.enum(["active", "closed"]),
2137
2201
  members: z.array(z.object({
2138
2202
  name: z.string(),
2139
2203
  purpose: z.string(),
@@ -2161,6 +2225,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2161
2225
  if (!workspaceId || !compositeWorkspaces.has(workspaceId)) {
2162
2226
  throw new Error("open_workspace action=member requires an existing Composite Workspace workspaceId.");
2163
2227
  }
2228
+ if (!compositeWorkspaces.isActive(workspaceId)) {
2229
+ throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before changing members.`);
2230
+ }
2164
2231
  if (!memberAction || !member) {
2165
2232
  throw new Error("open_workspace action=member requires memberAction and member.");
2166
2233
  }
@@ -2294,25 +2361,30 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2294
2361
  newWorkspace !== undefined || context !== undefined) {
2295
2362
  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
2363
  }
2364
+ const compositeInventory = () => compositeWorkspaces.list()
2365
+ .filter((entry) => workspaceId === undefined || entry.id === workspaceId)
2366
+ .filter((entry) => status === undefined || entry.status === status)
2367
+ .filter((entry) => state === undefined || entry.status === state)
2368
+ .map((entry) => ({
2369
+ workspaceId: entry.id,
2370
+ kind: entry.kind,
2371
+ name: entry.name,
2372
+ status: entry.status,
2373
+ state: entry.status,
2374
+ members: entry.members,
2375
+ createdAt: entry.createdAt,
2376
+ lastUsedAt: entry.lastUsedAt,
2377
+ }));
2297
2378
  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.");
2379
+ if (root !== undefined || mode !== undefined || staleOnly !== undefined ||
2380
+ offset !== undefined || limit !== undefined) {
2381
+ throw new Error("Composite Workspace inventory does not accept root/mode/staleOnly/offset/limit filters; use workspaceId/status/state when selecting Composite Workspaces.");
2301
2382
  }
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.";
2383
+ const composites = compositeInventory();
2384
+ 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
2385
  const result = [
2314
2386
  `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}`),
2387
+ ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] state=${entry.state} members=${entry.members.length} last-used=${entry.lastUsedAt}`),
2316
2388
  instruction,
2317
2389
  ].join("\n");
2318
2390
  return {
@@ -2325,20 +2397,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2325
2397
  };
2326
2398
  }
2327
2399
  const inventory = await workspaces.listWorkspaces({ workspaceId, mode, root, status, state, staleOnly, offset, limit }, { conversationScopeId, protectedWorkspaceIds });
2328
- const composites = kind === "workspace"
2400
+ const composites = kind === "workspace" || root !== undefined || mode !== undefined || staleOnly
2329
2401
  ? []
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
- }));
2402
+ : compositeInventory();
2338
2403
  const nextOffset = inventory.page.offset + inventory.page.limit;
2339
2404
  const instruction = [
2340
2405
  "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.",
2406
+ "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
2407
  inventory.page.hasMore
2343
2408
  ? `More matching workspaces are available; continue with offset=${nextOffset}.`
2344
2409
  : undefined,
@@ -2356,7 +2421,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2356
2421
  `root=${entry.root}`,
2357
2422
  `last-used=${entry.lastUsedAt}`,
2358
2423
  ].filter(Boolean).join(" ")),
2359
- ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] kind=composite members=${entry.members.length}`),
2424
+ ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] kind=composite state=${entry.state} members=${entry.members.length}`),
2360
2425
  instruction,
2361
2426
  ].join("\n");
2362
2427
  logToolCall(config, {
@@ -2393,6 +2458,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2393
2458
  const composite = workspaceId !== undefined
2394
2459
  ? compositeWorkspaces.open(workspaceId)
2395
2460
  : compositeWorkspaces.create(name ?? "");
2461
+ workspaceTasks.initializeWorkspace(composite.id);
2462
+ const compositeTaskContext = compositeCapabilityContext(composite.id, compositeTaskGuides);
2463
+ const compositeCapabilityCatalog = capabilityRegistry.catalog(compositeTaskContext);
2464
+ const compositeCapabilityGuides = compositeTaskGuides.map((guide) => ({
2465
+ name: guide.name,
2466
+ description: guide.description,
2467
+ whenToRead: guide.whenToRead,
2468
+ path: formatPathForPrompt(guide.filePath),
2469
+ }));
2396
2470
  const memberContext = memberName
2397
2471
  ? await loadCompositeMemberContext(composite.id, memberName, context ?? "auto", conversationScopeId, protectedWorkspaceIds)
2398
2472
  : undefined;
@@ -2403,10 +2477,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2403
2477
  ? `Members: ${composite.members.map((member) => `${member.name} — ${member.purpose}`).join("; ")}.`
2404
2478
  : "This Composite Workspace currently has no members.",
2405
2479
  "Member names and purposes are structural context and are always returned when this Composite Workspace is opened. context=auto/full/none controls only heavy member bootstrap context, not this Composite identity.",
2480
+ compositeCapabilityCatalog.length > 0
2481
+ ? `Composite-owned capabilities: ${compositeCapabilityCatalog.map((entry) => entry.name).join(", ")}. Use these without member because their state belongs to the Composite Workspace itself.`
2482
+ : undefined,
2406
2483
  composite.members.length > 0
2407
2484
  ? "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
2485
  : undefined,
2409
- "Use close_workspace only when the user chooses to dissolve this Composite Workspace; dissolution does not close or clean up member Workspaces.",
2486
+ "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
2487
  ].join("\n\n");
2411
2488
  const response = {
2412
2489
  content: [textBlock(instruction)],
@@ -2419,7 +2496,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2419
2496
  path: composite.name,
2420
2497
  members: composite.members,
2421
2498
  instruction,
2422
- summary: { members: composite.members.length },
2499
+ summary: { members: composite.members.length, status: composite.status },
2423
2500
  },
2424
2501
  },
2425
2502
  structuredContent: {
@@ -2427,7 +2504,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2427
2504
  workspaceId: composite.id,
2428
2505
  kind: "composite",
2429
2506
  name: composite.name,
2507
+ status: composite.status,
2508
+ state: composite.status,
2430
2509
  members: composite.members,
2510
+ capabilityCatalog: compositeCapabilityCatalog,
2511
+ capabilityGuides: compositeCapabilityGuides,
2431
2512
  ...(memberContext ? { memberContext } : {}),
2432
2513
  instruction,
2433
2514
  },
@@ -2540,6 +2621,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2540
2621
  conversationScopeId,
2541
2622
  protectedWorkspaceIds,
2542
2623
  });
2624
+ workspaceTasks.initializeWorkspace(workspace.id);
2543
2625
  const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
2544
2626
  const staleWorkspaces = await workspaces.listStaleWorkspaces(workspace);
2545
2627
  const capabilityFingerprint = buildCapabilityFingerprint(config, FORGERELAY_VERSION, {
@@ -2801,6 +2883,74 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2801
2883
  openWorldHint: true,
2802
2884
  },
2803
2885
  }, async ({ workspaceId, member, name, action, arguments: capabilityArguments, file }, extra) => {
2886
+ if (name === "workspace.tasks" && compositeWorkspaces.has(workspaceId)) {
2887
+ if (member !== undefined) {
2888
+ throw new Error(`workspace.tasks belongs to Composite Workspace ${workspaceId} itself and does not accept member.`);
2889
+ }
2890
+ if (!compositeWorkspaces.isActive(workspaceId)) {
2891
+ throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before use.`);
2892
+ }
2893
+ const startedAt = performance.now();
2894
+ const context = compositeCapabilityContext(workspaceId, compositeTaskGuides);
2895
+ try {
2896
+ if (action === "run") {
2897
+ const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, context, {
2898
+ nativeFile: file,
2899
+ signal: extra.signal,
2900
+ requestMeta: extra._meta,
2901
+ sessionId: extra.sessionId,
2902
+ });
2903
+ const result = {
2904
+ content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
2905
+ structuredContent: { name, action, result: execution.value },
2906
+ };
2907
+ logToolCall(config, {
2908
+ tool: toolNames.capability,
2909
+ capability: name,
2910
+ action,
2911
+ success: true,
2912
+ durationMs: Math.round(performance.now() - startedAt),
2913
+ });
2914
+ return result;
2915
+ }
2916
+ const capability = capabilityRegistry.describe(name, context);
2917
+ const result = {
2918
+ content: [textBlock([
2919
+ `${capability.name}: ${capability.description}`,
2920
+ `Available: ${capability.available}`,
2921
+ `Guide: ${capability.guide.path}`,
2922
+ capability.guide.readBeforeFirstUse
2923
+ ? "Read the guide before first use when this contract is unfamiliar."
2924
+ : undefined,
2925
+ ].filter(Boolean).join("\n"))],
2926
+ structuredContent: { name, action, capability },
2927
+ };
2928
+ logToolCall(config, {
2929
+ tool: toolNames.capability,
2930
+ capability: name,
2931
+ action,
2932
+ success: true,
2933
+ durationMs: Math.round(performance.now() - startedAt),
2934
+ });
2935
+ return result;
2936
+ }
2937
+ catch (error) {
2938
+ if (extra.signal.aborted)
2939
+ throw error;
2940
+ const capabilityError = error instanceof CapabilityError
2941
+ ? error
2942
+ : new CapabilityError("execution_failed", error instanceof Error ? error.message : String(error));
2943
+ return {
2944
+ content: [textBlock(`${capabilityError.code}: ${capabilityError.message}`)],
2945
+ structuredContent: {
2946
+ name,
2947
+ action,
2948
+ error: { code: capabilityError.code, message: capabilityError.message },
2949
+ },
2950
+ isError: true,
2951
+ };
2952
+ }
2953
+ }
2804
2954
  const target = resolveExecutionTarget(workspaceId, member);
2805
2955
  const executionWorkspaceId = target.executionWorkspaceId;
2806
2956
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
@@ -2921,13 +3071,13 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2921
3071
  });
2922
3072
  registerAppTool(server, toolNames.closeWorkspace, {
2923
3073
  title: "Close workspace",
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.",
3074
+ 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
3075
  inputSchema: {
2926
3076
  workspaceId: z.string().describe("Workspace identifier to close or delete."),
2927
3077
  action: z
2928
3078
  .enum(["close", "delete"])
2929
3079
  .optional()
2930
- .describe("Defaults to close. close preserves checkout identity for later reopen; delete permanently removes ForgeRelay-owned checkout state without deleting project files."),
3080
+ .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."),
2931
3081
  commitMessage: z
2932
3082
  .string()
2933
3083
  .min(1)
@@ -2945,6 +3095,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2945
3095
  purpose: z.string(),
2946
3096
  workspaceId: z.string(),
2947
3097
  })).optional(),
3098
+ status: z.enum(["active", "closed"]).optional(),
2948
3099
  dissolved: z.boolean().optional(),
2949
3100
  sourceRoot: z.string().optional(),
2950
3101
  branch: z.string().optional(),
@@ -2958,21 +3109,24 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2958
3109
  annotations: WRITE_TOOL_ANNOTATIONS,
2959
3110
  }, async ({ workspaceId, action = "close", commitMessage }, extra) => {
2960
3111
  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
- }
2964
3112
  if (commitMessage !== undefined) {
2965
- throw new Error("close_workspace commitMessage is not valid when dissolving a Composite Workspace.");
3113
+ throw new Error("close_workspace commitMessage is not valid for a Composite Workspace.");
2966
3114
  }
2967
- const composite = compositeWorkspaces.dissolve(workspaceId);
3115
+ const composite = action === "delete"
3116
+ ? compositeWorkspaces.dissolve(workspaceId)
3117
+ : compositeWorkspaces.close(workspaceId);
3118
+ if (action === "delete")
3119
+ workspaceTasks.deleteWorkspace(workspaceId);
2968
3120
  compositeActivity.forgetComposite(workspaceId);
2969
3121
  workspacePanelStates.delete(workspaceId);
2970
3122
  const result = [
2971
- `Dissolved Composite Workspace ${composite.name} (${workspaceId}).`,
3123
+ action === "delete"
3124
+ ? `Deleted Composite Workspace ${composite.name} (${workspaceId}); its Composite relationship and ForgeRelay-owned Composite state were dissolved.`
3125
+ : `Closed Composite Workspace ${composite.name} (${workspaceId}); its identity and member topology were preserved for later reopen.`,
2972
3126
  composite.members.length > 0
2973
3127
  ? `Preserved member Workspaces: ${composite.members.map((member) => `${member.name} [${member.workspaceId}]`).join(", ")}.`
2974
3128
  : "The Composite Workspace had no members.",
2975
- "Member Workspace handles, managed worktrees, processes, files, and Workspace Relay routes were not closed or cleaned up.",
3129
+ "Member Workspace handles, managed worktrees, processes, files, and Workspace Relay routes were not closed, finalized, deleted, or otherwise mutated.",
2976
3130
  ].join("\n");
2977
3131
  return {
2978
3132
  content: [textBlock(result)],
@@ -2980,22 +3134,24 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2980
3134
  tool: toolNames.closeWorkspace,
2981
3135
  card: {
2982
3136
  workspaceId,
2983
- action: "close",
3137
+ action,
2984
3138
  kind: "composite",
2985
3139
  name: composite.name,
2986
3140
  members: composite.members,
2987
- dissolved: true,
3141
+ ...(action === "close" ? { status: "closed" } : {}),
3142
+ dissolved: action === "delete",
2988
3143
  payload: { content: [textBlock(result)] },
2989
3144
  },
2990
3145
  },
2991
3146
  structuredContent: {
2992
3147
  result,
2993
3148
  workspaceId,
2994
- action: "close",
3149
+ action,
2995
3150
  kind: "composite",
2996
3151
  name: composite.name,
2997
3152
  members: composite.members,
2998
- dissolved: true,
3153
+ ...(action === "close" ? { status: "closed" } : {}),
3154
+ dissolved: action === "delete",
2999
3155
  },
3000
3156
  };
3001
3157
  }
@@ -3007,14 +3163,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3007
3163
  workspacePanelStates.delete(workspaceId);
3008
3164
  return response;
3009
3165
  }
3010
- if (action === "delete") {
3166
+ const session = workspaces.getWorkspaceSession(workspaceId);
3167
+ if (action === "delete" && session.mode === "checkout") {
3011
3168
  if (commitMessage !== undefined) {
3012
3169
  throw new Error("close_workspace commitMessage is not valid with action=delete for a checkout Workspace.");
3013
3170
  }
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
3171
  if (processSessions.activeWorkspaceIds().has(session.id)) {
3019
3172
  throw new Error(`Workspace ${session.id} still owns a running process. Poll, interrupt, or wait for it before deleting this Workspace.`);
3020
3173
  }
@@ -3030,6 +3183,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3030
3183
  payload: { workspaceId: session.id, action: "delete", mode: session.mode },
3031
3184
  operation: async () => {
3032
3185
  workspaces.deleteWorkspace(session.id);
3186
+ workspaceTasks.deleteWorkspace(session.id);
3033
3187
  await reviewCheckpoints.releaseWorkspace(session.id);
3034
3188
  const result = `Deleted ForgeRelay Workspace ${session.id}. Physical project files were not removed.`;
3035
3189
  return {
@@ -3055,12 +3209,59 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3055
3209
  workspacePanelStates.delete(session.id);
3056
3210
  return response;
3057
3211
  }
3058
- const workspace = workspaces.getWorkspace(workspaceId);
3212
+ if (action === "delete" && session.mode === "worktree" && session.status === "closed") {
3213
+ if (commitMessage !== undefined) {
3214
+ throw new Error("close_workspace commitMessage is not needed when deleting an already-closed managed-worktree Workspace.");
3215
+ }
3216
+ const hookRoot = session.sourceRoot ?? session.root;
3217
+ const response = await runToolWithHooks(hooks, {
3218
+ signal: extra.signal,
3219
+ tool: toolNames.closeWorkspace,
3220
+ invocation: {
3221
+ workspaceId: session.id,
3222
+ workspaceRoot: hookRoot,
3223
+ workspaceMode: session.mode,
3224
+ sourceRoot: session.sourceRoot,
3225
+ },
3226
+ payload: { workspaceId: session.id, action: "delete", mode: session.mode },
3227
+ operation: async () => {
3228
+ workspaces.deleteWorkspace(session.id);
3229
+ workspaceTasks.deleteWorkspace(session.id);
3230
+ await reviewCheckpoints.releaseWorkspace(session.id);
3231
+ const result = `Deleted closed managed-worktree Workspace ${session.id}. Its already-removed worktree backing was not recreated.`;
3232
+ return {
3233
+ content: [textBlock(result)],
3234
+ _meta: {
3235
+ tool: toolNames.closeWorkspace,
3236
+ card: {
3237
+ workspaceId: session.id,
3238
+ action: "delete",
3239
+ mode: "worktree",
3240
+ sourceRoot: session.sourceRoot,
3241
+ targetBranch: session.targetBranch,
3242
+ payload: { content: [textBlock(result)] },
3243
+ },
3244
+ },
3245
+ structuredContent: {
3246
+ result,
3247
+ workspaceId: session.id,
3248
+ action: "delete",
3249
+ mode: "worktree",
3250
+ sourceRoot: session.sourceRoot,
3251
+ targetBranch: session.targetBranch,
3252
+ },
3253
+ };
3254
+ },
3255
+ });
3256
+ workspacePanelStates.delete(session.id);
3257
+ return response;
3258
+ }
3259
+ const workspace = workspaces.getWorkspace(session.id);
3059
3260
  const response = await runToolWithHooks(hooks, {
3060
3261
  signal: extra.signal,
3061
3262
  tool: toolNames.closeWorkspace,
3062
3263
  invocation: workspaceHookInvocation(workspace),
3063
- payload: { workspaceId, action: "close", commitMessage, mode: workspace.mode },
3264
+ payload: { workspaceId: workspace.id, action, commitMessage, mode: workspace.mode },
3064
3265
  afterCwd: (response) => "sourceRoot" in response.structuredContent &&
3065
3266
  typeof response.structuredContent.sourceRoot === "string"
3066
3267
  ? response.structuredContent.sourceRoot
@@ -3068,7 +3269,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3068
3269
  operation: async () => {
3069
3270
  if (workspace.mode === "worktree") {
3070
3271
  if (!commitMessage) {
3071
- throw new Error(`Managed-worktree-backed workspace ${workspaceId} requires commitMessage when closing.`);
3272
+ throw new Error(`Managed-worktree-backed Workspace ${workspace.id} requires commitMessage when ${action === "delete" ? "deleting active work" : "closing"}.`);
3072
3273
  }
3073
3274
  const physicalWorkspaceIds = workspaces.workspaceIdsForPhysicalWorkspace(workspace);
3074
3275
  const busyWorkspaceIds = physicalWorkspaceIds
@@ -3080,14 +3281,20 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3080
3281
  const retirement = await codeIntelligence.retireWorkspaceRoot(workspace.root);
3081
3282
  let closed;
3082
3283
  try {
3083
- closed = await workspaces.closeWorktree(workspaceId, commitMessage);
3284
+ closed = await workspaces.closeWorktree(workspace.id, commitMessage);
3084
3285
  }
3085
3286
  finally {
3086
3287
  codeIntelligence.restoreWorkspaceRoot(retirement.root);
3087
3288
  }
3088
3289
  await Promise.all(physicalWorkspaceIds.map((id) => reviewCheckpoints.releaseWorkspace(id)));
3290
+ if (action === "delete") {
3291
+ workspaces.deleteWorkspace(workspace.id);
3292
+ workspaceTasks.deleteWorkspace(workspace.id);
3293
+ }
3089
3294
  const result = [
3090
- `Closed managed-worktree-backed workspace ${workspaceId}.`,
3295
+ action === "delete"
3296
+ ? `Safely finalized and deleted managed-worktree Workspace ${workspace.id}.`
3297
+ : `Closed managed-worktree-backed Workspace ${workspace.id}; its identity was preserved for later reopen.`,
3091
3298
  `Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
3092
3299
  `Source checkout: ${closed.sourceRoot}`,
3093
3300
  `Commit: ${closed.commitSha}`,
@@ -3107,8 +3314,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3107
3314
  _meta: {
3108
3315
  tool: toolNames.closeWorkspace,
3109
3316
  card: {
3110
- workspaceId,
3111
- action: "close",
3317
+ workspaceId: workspace.id,
3318
+ action,
3112
3319
  mode: "worktree",
3113
3320
  sourceRoot: closed.sourceRoot,
3114
3321
  branch: closed.branch,
@@ -3122,8 +3329,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3122
3329
  },
3123
3330
  structuredContent: {
3124
3331
  result,
3125
- workspaceId,
3126
- action: "close",
3332
+ workspaceId: workspace.id,
3333
+ action,
3127
3334
  mode: "worktree",
3128
3335
  sourceRoot: closed.sourceRoot,
3129
3336
  branch: closed.branch,
@@ -3178,7 +3385,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3178
3385
  member: z
3179
3386
  .string()
3180
3387
  .optional()
3181
- .describe("Required for a Composite Workspace; explicit member name that owns this operation."),
3388
+ .describe("Required for Composite member-scoped file reads. Omit only when reading an advertised Composite-owned capability guide."),
3182
3389
  path: z
3183
3390
  .string()
3184
3391
  .optional()
@@ -3220,6 +3427,29 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
3220
3427
  if ((path === undefined) === (paths === undefined)) {
3221
3428
  throw new Error("read requires exactly one of path or paths.");
3222
3429
  }
3430
+ if (compositeWorkspaces.has(workspaceId) && member === undefined && path !== undefined) {
3431
+ const guide = compositeTaskGuides.find((candidate) => formatPathForPrompt(candidate.filePath) === path || candidate.filePath === path);
3432
+ if (guide) {
3433
+ if (!compositeWorkspaces.isActive(workspaceId)) {
3434
+ throw new Error(`Composite Workspace ${workspaceId} is closed. Reopen it with open_workspace before use.`);
3435
+ }
3436
+ const startedAt = performance.now();
3437
+ const raw = readFileSync(guide.filePath, "utf8");
3438
+ const start = (offset ?? 1) - 1;
3439
+ const end = limit === undefined ? undefined : start + limit;
3440
+ const result = raw.split("\n").slice(start, end).join("\n");
3441
+ logToolCall(config, {
3442
+ tool: toolNames.read,
3443
+ path: formatPathForPrompt(guide.filePath),
3444
+ success: true,
3445
+ durationMs: Math.round(performance.now() - startedAt),
3446
+ });
3447
+ return {
3448
+ content: [textBlock(result)],
3449
+ structuredContent: { result },
3450
+ };
3451
+ }
3452
+ }
3223
3453
  const target = resolveExecutionTarget(workspaceId, member);
3224
3454
  const executionWorkspaceId = target.executionWorkspaceId;
3225
3455
  const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
@@ -19,6 +19,9 @@ export class SubagentSessionCapability {
19
19
  this.ownerAliveOverride = options.ownerAlive;
20
20
  }
21
21
  async run(input, context, options) {
22
+ if (!context.workspaceRoot) {
23
+ throw new CapabilityError("capability_unavailable", "subagent.session requires a filesystem-backed Workspace.");
24
+ }
22
25
  const manager = new SubagentSessionManager(this.config, {
23
26
  launch: (request) => this.launch(request),
24
27
  });