@akira-tl/forgerelay 0.6.0 → 0.6.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +47 -12
  3. package/dist/activity/mcp-query-tools.js +48 -2
  4. package/dist/activity/query-service.js +1 -0
  5. package/dist/cli.js +181 -4
  6. package/dist/composite-activity.js +155 -0
  7. package/dist/composite-workspaces.js +197 -0
  8. package/dist/config.js +4 -1
  9. package/dist/oauth/router.js +21 -1
  10. package/dist/oauth-provider.js +32 -4
  11. package/dist/oauth-store.js +9 -0
  12. package/dist/remote-auth.js +110 -0
  13. package/dist/remote-transport.js +196 -0
  14. package/dist/remote-workspace-relay.js +473 -0
  15. package/dist/server.js +824 -121
  16. package/dist/ui/.vite/manifest.json +33 -33
  17. package/dist/ui/activity-panel-app.html +3 -3
  18. package/dist/ui/assets/{activity-panel-app-CjZVvVNc.js → activity-panel-app-E1ju2dqI.js} +1 -1
  19. package/dist/ui/assets/{heavy-payload-vGgBRvNX.js → heavy-payload-CeW-n9w5.js} +1 -1
  20. package/dist/ui/assets/{review-payload-4erWKckt.js → review-payload-B9CO298v.js} +1 -1
  21. package/dist/ui/assets/{scrollbar-CaOPzUJd.js → scrollbar-C2twAENW.js} +1 -1
  22. package/dist/ui/assets/workspace-app-BztEvZIC.js +5 -0
  23. package/dist/ui/assets/{workspace-app-DkAiSl_0.js → workspace-app-CwbJnb_w.js} +1 -1
  24. package/dist/ui/assets/workspace-app-YnUST8IP.css +1 -0
  25. package/dist/ui/assets/workspace-app-rKuhdae8.js +1 -0
  26. package/dist/ui/assets/workspace-lifecycle-app-CEfMdudP.js +1 -0
  27. package/dist/ui/workspace-app.html +4 -4
  28. package/dist/ui/workspace-lifecycle-app.html +4 -4
  29. package/dist/user-config.js +131 -5
  30. package/docs/configuration.md +26 -3
  31. package/docs/debugging.md +7 -0
  32. package/docs/versioning.md +11 -19
  33. package/package.json +5 -2
  34. package/scripts/ci/verify.mjs +38 -0
  35. package/scripts/debug/runtime.mjs +23 -1
  36. package/scripts/debug/runtime.test.mjs +14 -2
  37. package/scripts/debug/serve.mjs +4 -4
  38. package/scripts/release/pack.mjs +36 -0
  39. package/scripts/release/publish.mjs +157 -0
  40. package/scripts/release/release-gate.test.mjs +73 -16
  41. package/scripts/release-parity.mjs +5 -13
  42. package/scripts/release-proof.mjs +28 -19
  43. package/scripts/release-proof.test.mjs +32 -11
  44. package/scripts/release-version.mjs +1 -1
  45. package/dist/ui/assets/workspace-app-CcrHAUIn.css +0 -1
  46. package/dist/ui/assets/workspace-app-DJmkPYJC.js +0 -1
  47. package/dist/ui/assets/workspace-app-QyauBrJX.js +0 -5
  48. package/dist/ui/assets/workspace-lifecycle-app-BIXEo53I.js +0 -1
package/dist/server.js CHANGED
@@ -45,6 +45,9 @@ import { createCoreOperationExecutor, } from "./operations/core-operation-execut
45
45
  import { McpTransportRegistry, } from "./mcp-sessions.js";
46
46
  import { ProcessManager, resolveProcessId, } from "./process-sessions.js";
47
47
  import { createReviewCheckpointManager } from "./review-checkpoints.js";
48
+ import { CompositeActivityCoordinator } from "./composite-activity.js";
49
+ import { CompositeWorkspaceRegistry } from "./composite-workspaces.js";
50
+ import { RemoteWorkspaceRelay } from "./remote-workspace-relay.js";
48
51
  import { hostConversationScopeId, openAiConversationScopeId } from "./request-meta.js";
49
52
  import { ACTIVITY_PANEL_APP_LEGACY_URI, ACTIVITY_PANEL_APP_URI_TEMPLATE, MCP_APP_RESOURCE_TEMPLATE_REVISION, readActivityPanelAppManifestEntry, readWorkspaceAppManifestEntry, readWorkspaceLifecycleAppManifestEntry, resolveActivityPanelAppIdentity, resolveWorkspaceAppIdentity, resolveWorkspaceLifecycleAppIdentity, WORKSPACE_APP_LEGACY_URI, WORKSPACE_APP_URI_TEMPLATE, WORKSPACE_LIFECYCLE_APP_LEGACY_URI, WORKSPACE_LIFECYCLE_APP_URI_TEMPLATE, } from "./mcp-app-template.js";
50
53
  import { shutdownHttpServer } from "./server-shutdown.js";
@@ -875,6 +878,39 @@ function toolResultContent(result) {
875
878
  const content = result.content;
876
879
  return Array.isArray(content) ? content : [];
877
880
  }
881
+ function remapCompositeToolResult(result, executionWorkspaceId, compositeWorkspaceId, member) {
882
+ if (typeof result !== "object" || result === null)
883
+ return result;
884
+ const record = result;
885
+ const remapped = replaceWorkspaceIdentity(record, executionWorkspaceId, compositeWorkspaceId);
886
+ const meta = typeof remapped._meta === "object" && remapped._meta !== null
887
+ ? { ...remapped._meta }
888
+ : undefined;
889
+ if (meta) {
890
+ const card = typeof meta.card === "object" && meta.card !== null
891
+ ? { ...meta.card, workspaceId: compositeWorkspaceId, member }
892
+ : undefined;
893
+ if (card)
894
+ meta.card = card;
895
+ remapped._meta = meta;
896
+ }
897
+ const structured = typeof remapped.structuredContent === "object" && remapped.structuredContent !== null
898
+ ? { ...remapped.structuredContent, member }
899
+ : undefined;
900
+ if (structured)
901
+ remapped.structuredContent = structured;
902
+ return remapped;
903
+ }
904
+ function replaceWorkspaceIdentity(value, from, to) {
905
+ if (typeof value === "string")
906
+ return value.split(from).join(to);
907
+ if (Array.isArray(value))
908
+ return value.map((entry) => replaceWorkspaceIdentity(entry, from, to));
909
+ if (!value || typeof value !== "object")
910
+ return value;
911
+ return Object.fromEntries(Object.entries(value)
912
+ .map(([key, entry]) => [key, replaceWorkspaceIdentity(entry, from, to)]));
913
+ }
878
914
  function toolResultAgentsFiles(result) {
879
915
  if (typeof result !== "object" || result === null)
880
916
  return [];
@@ -955,6 +991,14 @@ function activityRelationFor(context) {
955
991
  ...(context.turnId ? { turnId: context.turnId } : {}),
956
992
  };
957
993
  }
994
+ function activityRequestFor(input, context) {
995
+ if (!context.activityMember || !input || typeof input !== "object" || Array.isArray(input))
996
+ return input;
997
+ return {
998
+ ...input,
999
+ member: context.activityMember,
1000
+ };
1001
+ }
958
1002
  function runActivityTool(lifecycle, workspace, conversationScopeId, tool, request, operation, outcome = standardActivityOutcome, relation = {}) {
959
1003
  return lifecycle.run({
960
1004
  tool,
@@ -969,13 +1013,14 @@ function runActivityTool(lifecycle, workspace, conversationScopeId, tool, reques
969
1013
  function runActivityToolWithHooks(lifecycle, hooks, workspace, conversationScopeId, request, hookOptions, relation = {}) {
970
1014
  return runActivityTool(lifecycle, workspace, conversationScopeId, hookOptions.tool, request, () => runToolWithHooks(hooks, hookOptions), standardActivityOutcome, relation);
971
1015
  }
972
- function registerProcessTools(server, config, workspaces, processSessions, hooks, activityLifecycle, bashOutputStore, shellRun) {
1016
+ function registerProcessTools(server, config, workspaces, processSessions, hooks, activityLifecycle, bashOutputStore, shellRun, routing) {
973
1017
  if (config.toolMode === "codex") {
974
1018
  registerAppTool(server, "exec_command", {
975
1019
  title: "Execute command",
976
1020
  description: `Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a processId for write_stdin. Use this for file inspection, tests, builds, package scripts, generators, formatters, and long-running processes. ${buildShellMutationPolicy()} Call open_workspace first and pass workspaceId.`,
977
1021
  inputSchema: {
978
1022
  workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
1023
+ member: z.string().optional().describe("Required for a Composite Workspace; explicit member name that owns this process."),
979
1024
  cmd: z.string().min(1).describe("Shell command to execute."),
980
1025
  tty: z
981
1026
  .boolean()
@@ -1012,22 +1057,34 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
1012
1057
  outputSchema: processOutputSchema(),
1013
1058
  ...toolWidgetDescriptorMeta(config, "shell"),
1014
1059
  annotations: SHELL_TOOL_ANNOTATIONS,
1015
- }, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens }, extra) => shellRun({
1016
- workspaceId,
1017
- command: cmd,
1018
- surface: "exec_command",
1019
- tty,
1020
- columns,
1021
- rows,
1022
- workingDirectory,
1023
- yieldTimeMs,
1024
- timeoutMs,
1025
- maxOutputTokens,
1026
- }, {
1027
- requestMeta: extra._meta,
1028
- signal: extra.signal,
1029
- sessionId: extra.sessionId,
1030
- }));
1060
+ }, async ({ workspaceId, member, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens }, extra) => {
1061
+ const target = routing.resolve(workspaceId, member);
1062
+ const context = await routing.prepare(target, extra._meta, extra.signal, extra.sessionId);
1063
+ if (routing.isRemote(target.executionWorkspaceId)) {
1064
+ return routing.present(await routing.execCommandRemote(target.executionWorkspaceId, {
1065
+ cmd,
1066
+ ...(tty !== undefined ? { tty } : {}),
1067
+ ...(columns !== undefined ? { columns } : {}),
1068
+ ...(rows !== undefined ? { rows } : {}),
1069
+ ...(workingDirectory !== undefined ? { workingDirectory } : {}),
1070
+ ...(yieldTimeMs !== undefined ? { yieldTimeMs } : {}),
1071
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
1072
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
1073
+ }, routing.hostScopeIdFor(extra._meta, extra.sessionId)), target);
1074
+ }
1075
+ return routing.present(await shellRun({
1076
+ workspaceId: target.executionWorkspaceId,
1077
+ command: cmd,
1078
+ surface: "exec_command",
1079
+ tty,
1080
+ columns,
1081
+ rows,
1082
+ workingDirectory,
1083
+ yieldTimeMs,
1084
+ timeoutMs,
1085
+ maxOutputTokens,
1086
+ }, context), target);
1087
+ });
1031
1088
  }
1032
1089
  if (config.toolMode !== "codex")
1033
1090
  return;
@@ -1036,6 +1093,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
1036
1093
  description: "Poll or write characters to a running process returned by exec_command, or retrieve complete durable process output by outputId. Omit chars or pass an empty string to poll. Waiting never kills the process; pass \\u0003 to explicitly send Ctrl-C.",
1037
1094
  inputSchema: {
1038
1095
  workspaceId: z.string().describe("Workspace identifier used to start the process."),
1096
+ member: z.string().optional().describe("Required for a Composite Workspace; explicit member name that owns the process."),
1039
1097
  processId: z.number().int().positive().optional().describe("Canonical process identifier returned by bash or exec_command."),
1040
1098
  sessionId: z.number().int().positive().optional().describe("Deprecated alias for processId. Retained for compatibility."),
1041
1099
  outputId: z.string().optional().describe("Stable output identifier returned by exec_command. When supplied, retrieve the complete durable output instead of controlling a process."),
@@ -1060,8 +1118,23 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
1060
1118
  outputSchema: processOutputSchema(),
1061
1119
  ...toolWidgetDescriptorMeta(config, "shell"),
1062
1120
  annotations: SHELL_TOOL_ANNOTATIONS,
1063
- }, async ({ workspaceId, processId, sessionId, outputId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
1064
- const workspace = workspaces.getWorkspace(workspaceId);
1121
+ }, async ({ workspaceId, member, processId, sessionId, outputId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
1122
+ const target = routing.resolve(workspaceId, member);
1123
+ await routing.prepare(target, extra._meta, extra.signal, extra.sessionId);
1124
+ if (routing.isRemote(target.executionWorkspaceId)) {
1125
+ return routing.present(await routing.writeStdinRemote(target.executionWorkspaceId, {
1126
+ ...(processId !== undefined ? { processId } : {}),
1127
+ ...(sessionId !== undefined ? { sessionId } : {}),
1128
+ ...(outputId !== undefined ? { outputId } : {}),
1129
+ ...(chars !== undefined ? { chars } : {}),
1130
+ ...(columns !== undefined ? { columns } : {}),
1131
+ ...(rows !== undefined ? { rows } : {}),
1132
+ ...(yieldTimeMs !== undefined ? { yieldTimeMs } : {}),
1133
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
1134
+ }, routing.hostScopeIdFor(extra._meta, extra.sessionId)), target);
1135
+ }
1136
+ const executionWorkspaceId = target.executionWorkspaceId;
1137
+ const workspace = workspaces.getWorkspace(executionWorkspaceId);
1065
1138
  if (outputId !== undefined) {
1066
1139
  if (processId !== undefined || sessionId !== undefined || chars !== undefined || columns !== undefined ||
1067
1140
  rows !== undefined || yieldTimeMs !== undefined || maxOutputTokens !== undefined) {
@@ -1072,7 +1145,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
1072
1145
  tool: "write_stdin",
1073
1146
  invocation: workspaceHookInvocation(workspace),
1074
1147
  payload: { outputId },
1075
- operation: async () => durableOutputResponse("write_stdin", workspaceId, readWorkspaceBashOutput(bashOutputStore, workspaceId, outputId)),
1148
+ operation: async () => durableOutputResponse("write_stdin", executionWorkspaceId, readWorkspaceBashOutput(bashOutputStore, executionWorkspaceId, outputId)),
1076
1149
  });
1077
1150
  }
1078
1151
  const resolvedProcessId = resolveProcessId(processId, sessionId);
@@ -1089,7 +1162,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
1089
1162
  operation: async () => {
1090
1163
  const startedAt = performance.now();
1091
1164
  const snapshot = await processSessions.write({
1092
- workspaceId,
1165
+ workspaceId: executionWorkspaceId,
1093
1166
  processId: resolvedProcessId,
1094
1167
  chars,
1095
1168
  columns,
@@ -1107,7 +1180,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
1107
1180
  success: snapshot.running || snapshot.exitCode === 0,
1108
1181
  durationMs: Math.round(performance.now() - startedAt),
1109
1182
  });
1110
- const response = processToolResponse("write_stdin", workspaceId, snapshot, {
1183
+ const response = processToolResponse("write_stdin", executionWorkspaceId, snapshot, {
1111
1184
  processId: resolvedProcessId,
1112
1185
  charactersWritten: chars?.length ?? 0,
1113
1186
  running: snapshot.running,
@@ -1119,12 +1192,57 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
1119
1192
  }
1120
1193
  return response;
1121
1194
  },
1122
- });
1195
+ }).then((result) => routing.present(result, target));
1123
1196
  });
1124
1197
  }
1125
1198
  export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters, codeIntelligence, activityLifecycle, bashOutputStore, activityQueries) {
1126
1199
  const connectionScopeId = `mcp-connection:${randomUUID()}`;
1200
+ const remoteWorkspaces = new RemoteWorkspaceRelay(config.configDir, config.stateDir);
1201
+ const compositeWorkspaces = new CompositeWorkspaceRegistry(config.stateDir);
1202
+ const compositeActivity = new CompositeActivityCoordinator(compositeWorkspaces, activityQueries, remoteWorkspaces);
1203
+ const resolveExecutionTarget = (workspaceId, memberName) => {
1204
+ if (!compositeWorkspaces.has(workspaceId)) {
1205
+ if (memberName !== undefined) {
1206
+ throw new Error(`Workspace ${workspaceId} is not composite and does not accept member.`);
1207
+ }
1208
+ return { executionWorkspaceId: workspaceId };
1209
+ }
1210
+ if (!memberName) {
1211
+ throw new Error(`Composite Workspace ${workspaceId} requires member for this operation.`);
1212
+ }
1213
+ const member = compositeWorkspaces.member(workspaceId, memberName);
1214
+ try {
1215
+ if (!remoteWorkspaces.has(member.workspaceId))
1216
+ workspaces.getWorkspace(member.workspaceId);
1217
+ }
1218
+ catch (error) {
1219
+ throw new Error(`Composite Workspace ${workspaceId} member ${member.name} is unavailable: ${error instanceof Error ? error.message : String(error)}`);
1220
+ }
1221
+ return {
1222
+ executionWorkspaceId: member.workspaceId,
1223
+ compositeWorkspaceId: workspaceId,
1224
+ memberName: member.name,
1225
+ };
1226
+ };
1227
+ const presentExecutionResult = (result, target) => {
1228
+ if (!target.compositeWorkspaceId || !target.memberName)
1229
+ return result;
1230
+ return remapCompositeToolResult(result, target.executionWorkspaceId, target.compositeWorkspaceId, target.memberName);
1231
+ };
1127
1232
  const hostScopeIdFor = (requestMeta, transportSessionId) => hostConversationScopeId(requestMeta, transportSessionId, connectionScopeId);
1233
+ const prepareExecutionContext = async (target, requestMeta, signal, sessionId) => {
1234
+ const conversationScopeId = hostScopeIdFor(requestMeta, sessionId);
1235
+ const turnId = target.compositeWorkspaceId && target.memberName
1236
+ ? await compositeActivity.prepareMember(target.compositeWorkspaceId, target.memberName, target.executionWorkspaceId, conversationScopeId)
1237
+ : undefined;
1238
+ return {
1239
+ requestMeta,
1240
+ signal,
1241
+ sessionId,
1242
+ ...(turnId ? { turnId } : {}),
1243
+ ...(target.memberName ? { activityMember: target.memberName } : {}),
1244
+ };
1245
+ };
1128
1246
  const toolDescriptions = buildToolDescriptions(config);
1129
1247
  const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result, (snapshot) => recordBashCompletion(activityLifecycle, bashOutputStore, snapshot.outputId)));
1130
1248
  const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
@@ -1222,11 +1340,59 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1222
1340
  },
1223
1341
  },
1224
1342
  });
1343
+ const loadCompositeMemberContext = async (compositeWorkspaceId, memberName, contextPolicy, conversationScopeId, protectedWorkspaceIds) => {
1344
+ const target = resolveExecutionTarget(compositeWorkspaceId, memberName);
1345
+ if (remoteWorkspaces.has(target.executionWorkspaceId)) {
1346
+ const resumed = await remoteWorkspaces.resumeWorkspace(target.executionWorkspaceId, contextPolicy, conversationScopeId);
1347
+ const presented = presentExecutionResult(resumed, target);
1348
+ return {
1349
+ member: memberName,
1350
+ ...(presented.structuredContent ?? {}),
1351
+ };
1352
+ }
1353
+ const opened = await workspaces.openWorkspace({ workspaceId: target.executionWorkspaceId, context: contextPolicy }, { conversationScopeId, protectedWorkspaceIds });
1354
+ const workspace = opened.workspace;
1355
+ const capabilityFingerprint = buildCapabilityFingerprint(config, FORGERELAY_VERSION, {
1356
+ artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
1357
+ });
1358
+ const capabilityCatalog = capabilityRegistry.catalog(capabilityContextFor(workspace));
1359
+ const agentsFiles = opened.agentsFiles.map((file) => ({
1360
+ path: formatAgentsPath(file.path, workspace.root),
1361
+ content: file.content,
1362
+ }));
1363
+ const availableAgentsFiles = opened.availableAgentsFiles.map((file) => ({
1364
+ path: formatAgentsPath(file.path, workspace.root),
1365
+ }));
1366
+ const skills = workspace.skills
1367
+ .filter((skill) => !skill.disableModelInvocation)
1368
+ .map((skill) => ({ name: skill.name, description: skill.description }));
1369
+ return {
1370
+ member: memberName,
1371
+ workspaceId: compositeWorkspaceId,
1372
+ root: workspace.root,
1373
+ mode: workspace.mode,
1374
+ contextFingerprint: opened.contextFingerprint,
1375
+ capabilityFingerprint,
1376
+ capabilityCatalog,
1377
+ includeBootstrapContext: opened.includeBootstrapContext,
1378
+ ...(opened.includeBootstrapContext
1379
+ ? {
1380
+ agentsFiles,
1381
+ availableAgentsFiles,
1382
+ skills,
1383
+ skillDiagnostics: redactSkillDiagnosticPaths(workspace.skillDiagnostics),
1384
+ }
1385
+ : {}),
1386
+ instruction: opened.includeBootstrapContext
1387
+ ? `Bootstrap context for Composite member ${memberName}. Keep using Composite workspaceId ${compositeWorkspaceId} and pass member=${memberName} for work operations.`
1388
+ : `Composite member ${memberName} context was already delivered for this Host context; keep using Composite workspaceId ${compositeWorkspaceId} with member=${memberName}.`,
1389
+ };
1390
+ };
1225
1391
  const coreOperations = createCoreOperationExecutor({
1226
1392
  read: async (input, context) => {
1227
1393
  const { workspaceId, ...readInput } = input;
1228
1394
  const workspace = workspaces.getWorkspace(workspaceId);
1229
- return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), input, {
1395
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), activityRequestFor(input, context), {
1230
1396
  signal: context.signal,
1231
1397
  tool: toolNames.read,
1232
1398
  invocation: workspaceHookInvocation(workspace),
@@ -1298,7 +1464,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1298
1464
  write: async (input, context) => {
1299
1465
  const { workspaceId, ...writeInput } = input;
1300
1466
  const workspace = workspaces.getWorkspace(workspaceId);
1301
- return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), input, {
1467
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), activityRequestFor(input, context), {
1302
1468
  signal: context.signal,
1303
1469
  tool: toolNames.write,
1304
1470
  invocation: workspaceHookInvocation(workspace),
@@ -1359,7 +1525,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1359
1525
  edit: async (input, context) => {
1360
1526
  const { workspaceId, ...editInput } = input;
1361
1527
  const workspace = workspaces.getWorkspace(workspaceId);
1362
- return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), input, {
1528
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), activityRequestFor(input, context), {
1363
1529
  signal: context.signal,
1364
1530
  tool: toolNames.edit,
1365
1531
  invocation: workspaceHookInvocation(workspace),
@@ -1421,7 +1587,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1421
1587
  rename: async (input, context) => {
1422
1588
  const { workspaceId, path, newPath } = input;
1423
1589
  const workspace = workspaces.getWorkspace(workspaceId);
1424
- return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), input, {
1590
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), activityRequestFor(input, context), {
1425
1591
  signal: context.signal,
1426
1592
  tool: toolNames.rename,
1427
1593
  invocation: workspaceHookInvocation(workspace),
@@ -1480,7 +1646,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1480
1646
  delete: async (input, context) => {
1481
1647
  const { workspaceId, path, recursive } = input;
1482
1648
  const workspace = workspaces.getWorkspace(workspaceId);
1483
- return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), input, {
1649
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), activityRequestFor(input, context), {
1484
1650
  signal: context.signal,
1485
1651
  tool: toolNames.delete,
1486
1652
  invocation: workspaceHookInvocation(workspace),
@@ -1564,7 +1730,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1564
1730
  maxOutputTokens,
1565
1731
  };
1566
1732
  let undeliveredProcessId;
1567
- const activityResult = await runActivityTool(activityLifecycle, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), surface, activityRequest, async (activityContext) => {
1733
+ const activityResult = await runActivityTool(activityLifecycle, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), surface, activityRequestFor(activityRequest, context), async (activityContext) => {
1568
1734
  try {
1569
1735
  const result = await runToolWithHooks(hooks, {
1570
1736
  signal: context.signal,
@@ -1638,7 +1804,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1638
1804
  const { workspaceId, name, arguments: capabilityArguments, file } = input;
1639
1805
  const workspace = workspaces.getWorkspace(workspaceId);
1640
1806
  let changedPaths = [];
1641
- return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), { workspaceId, name, action: "run", arguments: capabilityArguments, file }, {
1807
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(context.requestMeta, context.sessionId), activityRequestFor({ workspaceId, name, action: "run", arguments: capabilityArguments, file }, context), {
1642
1808
  signal: context.signal,
1643
1809
  tool: toolNames.capability,
1644
1810
  invocation: workspaceHookInvocation(workspace),
@@ -1735,6 +1901,26 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1735
1901
  instructions: buildServerInstructions(config),
1736
1902
  });
1737
1903
  const workspacePanelStates = new Map();
1904
+ const workspacePanelState = (workspaceId) => {
1905
+ const remembered = workspacePanelStates.get(workspaceId);
1906
+ if (remembered)
1907
+ return remembered;
1908
+ try {
1909
+ const workspace = workspaces.getWorkspace(workspaceId);
1910
+ return {
1911
+ workspaceId: workspace.id,
1912
+ root: workspace.root,
1913
+ path: workspace.root,
1914
+ mode: workspace.mode,
1915
+ sourceRoot: workspace.sourceRoot,
1916
+ instruction: `Use workspaceId ${workspace.id} for subsequent calls.`,
1917
+ summary: { mode: workspace.mode },
1918
+ };
1919
+ }
1920
+ catch {
1921
+ return undefined;
1922
+ }
1923
+ };
1738
1924
  const rememberWorkspacePanelState = (workspaceId, response) => {
1739
1925
  if (typeof response._meta !== "object" || response._meta === null)
1740
1926
  return;
@@ -1772,16 +1958,48 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1772
1958
  server.registerResource("ForgeRelay Activity Panel compatibility", new ResourceTemplate(ACTIVITY_PANEL_APP_URI_TEMPLATE, { list: undefined }), { ...activityPanelResourceMetadata, mimeType: RESOURCE_MIME_TYPE }, async (uri, _variables, extra) => readActivityPanelAppResource(config, uri.toString(), extra.sessionId));
1773
1959
  registerAppTool(server, "open_workspace", {
1774
1960
  title: "Open workspace",
1775
- description: "Open or resume a local coding workspace. Reuse the returned workspaceId for later calls. Default to checkout; use mode=\"worktree\" only when the user explicitly requests isolated or parallel Git work. Every call returns lightweight workspace metadata; bootstrap context is delivered automatically only when needed and can be explicitly suppressed or refreshed.",
1961
+ description: "Open or resume a ForgeRelay Workspace. Ordinary workspaces default to local execution; relay may name a registered remote ForgeRelay. Composite Workspaces use the same open lifecycle but have kind=\"composite\" and a name instead of a mounted root. Reuse the returned workspaceId for later calls. Bootstrap context is delivered automatically only when needed and can be suppressed or refreshed.",
1776
1962
  inputSchema: {
1777
1963
  action: z
1778
- .enum(["open", "list"])
1964
+ .enum(["open", "list", "member"])
1965
+ .optional()
1966
+ .describe("Defaults to open. Use list to inspect logical workspaces. Use member to add/remove a named execution member on an existing Composite Workspace."),
1967
+ memberAction: z
1968
+ .enum(["add", "update", "remove"])
1779
1969
  .optional()
1780
- .describe("Defaults to open. Use list only when you need to inspect or choose logical workspaces before resuming or cleaning them up."),
1970
+ .describe("Required with action=member."),
1971
+ member: z.object({
1972
+ name: z.string().describe("Stable member name such as code or compute. For update/remove this identifies the existing member."),
1973
+ newName: z.string().optional().describe("Optional replacement member name for memberAction=update."),
1974
+ purpose: z.string().optional().describe("Agent-facing purpose. Required when adding a member; optional replacement when updating."),
1975
+ workspaceId: z.string().optional().describe("Existing ordinary or relayed Workspace to mount. Mutually exclusive with path."),
1976
+ path: z.string().optional().describe("Workspace path to open internally and mount. Mutually exclusive with workspaceId."),
1977
+ relay: z.string().optional().describe("Optional registered remote ForgeRelay alias for a path-backed member."),
1978
+ mode: z.enum(["checkout", "worktree"]).optional(),
1979
+ baseRef: z.string().optional(),
1980
+ newWorktree: z.boolean().optional(),
1981
+ newWorkspace: z.boolean().optional(),
1982
+ }).optional().describe("Composite member definition used by action=member."),
1983
+ kind: z
1984
+ .enum(["workspace", "composite"])
1985
+ .optional()
1986
+ .describe("Workspace kind for action=open. Defaults to workspace. Use composite with name and no path to create or reopen a named Composite Workspace."),
1987
+ name: z
1988
+ .string()
1989
+ .optional()
1990
+ .describe("Composite Workspace name. Used only with kind=\"composite\" when creating/opening by name."),
1991
+ memberName: z
1992
+ .string()
1993
+ .optional()
1994
+ .describe("For action=open on a Composite Workspace, load bootstrap context for this named member without making it an implicit current member."),
1781
1995
  path: z
1782
1996
  .string()
1783
1997
  .optional()
1784
- .describe("Project path to open. Required for action=open unless workspaceId is supplied. With mode=\"worktree\", this may also be a managed worktree path previously returned by ForgeRelay."),
1998
+ .describe("Project path to open for an ordinary Workspace. Required for action=open unless workspaceId is supplied or kind=\"composite\" with name is used. With mode=\"worktree\", this may also be a managed worktree path previously returned by ForgeRelay."),
1999
+ relay: z
2000
+ .string()
2001
+ .optional()
2002
+ .describe("Optional registered remote ForgeRelay alias. When supplied for action=open, the workspace is opened and executed on that remote instance while this Gateway returns its own workspaceId."),
1785
2003
  workspaceId: z
1786
2004
  .string()
1787
2005
  .optional()
@@ -1837,8 +2055,17 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1837
2055
  .describe("For action=list, maximum records to return. Defaults to 50; maximum 100."),
1838
2056
  },
1839
2057
  outputSchema: {
1840
- action: z.enum(["open", "list"]),
2058
+ action: z.enum(["open", "list", "member"]),
1841
2059
  workspaceId: z.string().optional(),
2060
+ memberAction: z.enum(["add", "update", "remove"]).optional(),
2061
+ kind: z.enum(["workspace", "composite"]).optional(),
2062
+ name: z.string().optional(),
2063
+ members: z.array(z.object({
2064
+ name: z.string(),
2065
+ purpose: z.string(),
2066
+ workspaceId: z.string(),
2067
+ })).optional(),
2068
+ memberContext: z.unknown().optional(),
1842
2069
  root: z.string().optional(),
1843
2070
  mode: z.enum(["checkout", "worktree"]).optional(),
1844
2071
  sourceRoot: z.string().optional(),
@@ -1885,6 +2112,18 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1885
2112
  agents: z.array(workspaceLocalAgentOutputSchema).optional(),
1886
2113
  skillDiagnostics: z.array(workspaceSkillDiagnosticOutputSchema).optional(),
1887
2114
  workspaces: z.array(workspaceInventoryEntryOutputSchema).optional(),
2115
+ compositeWorkspaces: z.array(z.object({
2116
+ workspaceId: z.string(),
2117
+ kind: z.literal("composite"),
2118
+ name: z.string(),
2119
+ members: z.array(z.object({
2120
+ name: z.string(),
2121
+ purpose: z.string(),
2122
+ workspaceId: z.string(),
2123
+ })),
2124
+ createdAt: z.string(),
2125
+ lastUsedAt: z.string(),
2126
+ })).optional(),
1888
2127
  summary: workspaceInventorySummaryOutputSchema.optional(),
1889
2128
  page: workspaceInventoryPageOutputSchema.optional(),
1890
2129
  instruction: z.string(),
@@ -1896,26 +2135,198 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1896
2135
  idempotentHint: false,
1897
2136
  openWorldHint: false,
1898
2137
  },
1899
- }, async ({ action = "open", path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, }, { _meta, sessionId }) => {
2138
+ }, async ({ action = "open", memberAction, member, kind, name, memberName, path, relay, workspaceId, mode, baseRef, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, }, { _meta, sessionId }) => {
1900
2139
  const startedAt = performance.now();
1901
2140
  const conversationScopeId = openAiConversationScopeId(_meta);
1902
2141
  const protectedWorkspaceIds = processSessions.activeWorkspaceIds();
2142
+ if (action === "member") {
2143
+ if (!workspaceId || !compositeWorkspaces.has(workspaceId)) {
2144
+ throw new Error("open_workspace action=member requires an existing Composite Workspace workspaceId.");
2145
+ }
2146
+ if (!memberAction || !member) {
2147
+ throw new Error("open_workspace action=member requires memberAction and member.");
2148
+ }
2149
+ if (kind !== undefined || name !== undefined || memberName !== undefined || path !== undefined || relay !== undefined || mode !== undefined ||
2150
+ baseRef !== undefined || newWorktree !== undefined || newWorkspace !== undefined || context !== undefined ||
2151
+ root !== undefined || status !== undefined || state !== undefined || staleOnly !== undefined ||
2152
+ offset !== undefined || limit !== undefined) {
2153
+ throw new Error("open_workspace action=member accepts workspaceId, memberAction, and member only. Put any Workspace open definition inside member.");
2154
+ }
2155
+ const resolveMemberTargetWorkspaceId = async () => {
2156
+ const byWorkspaceId = typeof member.workspaceId === "string" && member.workspaceId.length > 0;
2157
+ const byPath = typeof member.path === "string" && member.path.length > 0;
2158
+ if (byWorkspaceId === byPath) {
2159
+ throw new Error("A Composite Workspace member target requires exactly one of member.workspaceId or member.path.");
2160
+ }
2161
+ if (byWorkspaceId) {
2162
+ const targetWorkspaceId = member.workspaceId;
2163
+ if (compositeWorkspaces.has(targetWorkspaceId)) {
2164
+ throw new Error("A Composite Workspace cannot be mounted as a Composite Workspace member.");
2165
+ }
2166
+ if (member.relay !== undefined || member.mode !== undefined || member.baseRef !== undefined ||
2167
+ member.newWorktree !== undefined || member.newWorkspace !== undefined) {
2168
+ throw new Error("member.workspaceId cannot be combined with relay/mode/baseRef/newWorktree/newWorkspace.");
2169
+ }
2170
+ if (!remoteWorkspaces.has(targetWorkspaceId))
2171
+ workspaces.getWorkspace(targetWorkspaceId);
2172
+ return targetWorkspaceId;
2173
+ }
2174
+ if (member.relay !== undefined) {
2175
+ const opened = await remoteWorkspaces.openWorkspace(member.relay, {
2176
+ path: member.path,
2177
+ ...(member.mode ? { mode: member.mode } : {}),
2178
+ ...(member.baseRef ? { baseRef: member.baseRef } : {}),
2179
+ ...(member.newWorktree !== undefined ? { newWorktree: member.newWorktree } : {}),
2180
+ ...(member.newWorkspace !== undefined ? { newWorkspace: member.newWorkspace } : {}),
2181
+ context: "none",
2182
+ });
2183
+ return opened.workspaceId;
2184
+ }
2185
+ const opened = await workspaces.openWorkspace({
2186
+ path: member.path,
2187
+ ...(member.mode ? { mode: member.mode } : {}),
2188
+ ...(member.baseRef ? { baseRef: member.baseRef } : {}),
2189
+ ...(member.newWorktree !== undefined ? { newWorktree: member.newWorktree } : {}),
2190
+ ...(member.newWorkspace !== undefined ? { newWorkspace: member.newWorkspace } : {}),
2191
+ context: "none",
2192
+ }, { protectedWorkspaceIds });
2193
+ return opened.workspace.id;
2194
+ };
2195
+ let composite;
2196
+ if (memberAction === "add") {
2197
+ if (member.newName !== undefined) {
2198
+ throw new Error("Adding a Composite Workspace member does not accept member.newName.");
2199
+ }
2200
+ const purpose = member.purpose?.trim();
2201
+ if (!purpose)
2202
+ throw new Error("Adding a Composite Workspace member requires member.purpose.");
2203
+ const targetWorkspaceId = await resolveMemberTargetWorkspaceId();
2204
+ composite = compositeWorkspaces.addMember(workspaceId, {
2205
+ name: member.name,
2206
+ purpose,
2207
+ workspaceId: targetWorkspaceId,
2208
+ });
2209
+ }
2210
+ else if (memberAction === "update") {
2211
+ const targetFieldsPresent = member.workspaceId !== undefined || member.path !== undefined || member.relay !== undefined ||
2212
+ member.mode !== undefined || member.baseRef !== undefined || member.newWorktree !== undefined ||
2213
+ member.newWorkspace !== undefined;
2214
+ if (member.newName === undefined && member.purpose === undefined && !targetFieldsPresent) {
2215
+ throw new Error("Updating a Composite Workspace member requires newName, purpose, or a replacement Workspace target.");
2216
+ }
2217
+ const targetWorkspaceId = targetFieldsPresent
2218
+ ? await resolveMemberTargetWorkspaceId()
2219
+ : undefined;
2220
+ composite = compositeWorkspaces.updateMember(workspaceId, member.name, {
2221
+ ...(member.newName !== undefined ? { name: member.newName } : {}),
2222
+ ...(member.purpose !== undefined ? { purpose: member.purpose } : {}),
2223
+ ...(targetWorkspaceId !== undefined ? { workspaceId: targetWorkspaceId } : {}),
2224
+ });
2225
+ }
2226
+ else {
2227
+ if (member.newName !== undefined || member.purpose !== undefined || member.workspaceId !== undefined || member.path !== undefined ||
2228
+ member.relay !== undefined || member.mode !== undefined || member.baseRef !== undefined ||
2229
+ member.newWorktree !== undefined || member.newWorkspace !== undefined) {
2230
+ throw new Error("Removing a Composite Workspace member accepts only member.name.");
2231
+ }
2232
+ composite = compositeWorkspaces.removeMember(workspaceId, member.name);
2233
+ }
2234
+ const memberActionVerb = memberAction === "add"
2235
+ ? "Added"
2236
+ : memberAction === "update"
2237
+ ? "Updated"
2238
+ : "Removed";
2239
+ const memberActionPreposition = memberAction === "remove" ? "from" : "in";
2240
+ const instruction = [
2241
+ `${memberActionVerb} member ${member.name} ${memberActionPreposition} Composite Workspace ${composite.name} (${composite.id}).`,
2242
+ composite.members.length > 0
2243
+ ? `Members: ${composite.members.map((entry) => `${entry.name} — ${entry.purpose}`).join("; ")}.`
2244
+ : "This Composite Workspace currently has no members.",
2245
+ "Use the Composite workspaceId as the top-level handle. Work operations on it require an explicit member name; ForgeRelay never infers a member from tool type or purpose.",
2246
+ ].join("\n");
2247
+ const response = {
2248
+ content: [textBlock(instruction)],
2249
+ _meta: {
2250
+ tool: "open_workspace",
2251
+ card: {
2252
+ workspaceId: composite.id,
2253
+ kind: "composite",
2254
+ name: composite.name,
2255
+ path: composite.name,
2256
+ members: composite.members,
2257
+ instruction,
2258
+ summary: { members: composite.members.length },
2259
+ },
2260
+ },
2261
+ structuredContent: {
2262
+ action: "member",
2263
+ workspaceId: composite.id,
2264
+ memberAction,
2265
+ kind: "composite",
2266
+ name: composite.name,
2267
+ members: composite.members,
2268
+ instruction,
2269
+ },
2270
+ };
2271
+ rememberWorkspacePanelState(composite.id, response);
2272
+ return response;
2273
+ }
1903
2274
  if (action === "list") {
1904
- if (path !== undefined || baseRef !== undefined || newWorktree !== undefined ||
2275
+ if (path !== undefined || relay !== undefined || name !== undefined || memberName !== undefined || baseRef !== undefined || newWorktree !== undefined ||
1905
2276
  newWorkspace !== undefined || context !== undefined) {
1906
- throw new Error("open_workspace action=list does not accept path, baseRef, newWorktree, newWorkspace, or context. Use root/workspaceId/mode/status/state/staleOnly for inventory filters.");
2277
+ 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.");
2278
+ }
2279
+ if (kind === "composite") {
2280
+ if (root !== undefined || mode !== undefined || status !== undefined || state !== undefined ||
2281
+ staleOnly !== undefined || offset !== undefined || limit !== undefined) {
2282
+ throw new Error("Composite Workspace inventory does not accept root/mode/status/state/staleOnly/offset/limit filters; use workspaceId when selecting one Composite Workspace.");
2283
+ }
2284
+ const composites = compositeWorkspaces.list()
2285
+ .filter((entry) => workspaceId === undefined || entry.id === workspaceId)
2286
+ .map((entry) => ({
2287
+ workspaceId: entry.id,
2288
+ kind: entry.kind,
2289
+ name: entry.name,
2290
+ members: entry.members,
2291
+ createdAt: entry.createdAt,
2292
+ lastUsedAt: entry.lastUsedAt,
2293
+ }));
2294
+ const instruction = "Resume a Composite Workspace with open_workspace(action=\"open\", workspaceId=...). Use close_workspace only when the user chooses to dissolve it.";
2295
+ const result = [
2296
+ `Composite Workspace inventory: ${composites.length} matching record${composites.length === 1 ? "" : "s"}.`,
2297
+ ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] members=${entry.members.length} last-used=${entry.lastUsedAt}`),
2298
+ instruction,
2299
+ ].join("\n");
2300
+ return {
2301
+ content: [textBlock(result)],
2302
+ structuredContent: {
2303
+ action: "list",
2304
+ compositeWorkspaces: composites,
2305
+ instruction,
2306
+ },
2307
+ };
1907
2308
  }
1908
2309
  const inventory = await workspaces.listWorkspaces({ workspaceId, mode, root, status, state, staleOnly, offset, limit }, { conversationScopeId, protectedWorkspaceIds });
2310
+ const composites = kind === "workspace"
2311
+ ? []
2312
+ : compositeWorkspaces.list().map((entry) => ({
2313
+ workspaceId: entry.id,
2314
+ kind: entry.kind,
2315
+ name: entry.name,
2316
+ members: entry.members,
2317
+ createdAt: entry.createdAt,
2318
+ lastUsedAt: entry.lastUsedAt,
2319
+ }));
1909
2320
  const nextOffset = inventory.page.offset + inventory.page.limit;
1910
2321
  const instruction = [
1911
2322
  "Resume a selected workspaceId with open_workspace(action=\"open\", workspaceId=...).",
1912
- "Use close_workspace only after the user chooses cleanup; never close inventory entries automatically.",
2323
+ "Use close_workspace only after the user chooses cleanup or Composite dissolution; never close inventory entries automatically.",
1913
2324
  inventory.page.hasMore
1914
2325
  ? `More matching workspaces are available; continue with offset=${nextOffset}.`
1915
2326
  : undefined,
1916
2327
  ].filter(Boolean).join(" ");
1917
2328
  const result = [
1918
- `Logical workspace inventory: ${inventory.summary.matching} matching of ${inventory.summary.total} stored records.`,
2329
+ `Logical workspace inventory: ${inventory.summary.matching} matching ordinary records; ${composites.length} Composite Workspace record${composites.length === 1 ? "" : "s"}.`,
1919
2330
  `States: active=${inventory.summary.active}, stale=${inventory.summary.stale}, invalid=${inventory.summary.invalid}, closed=${inventory.summary.closed}.`,
1920
2331
  ...inventory.workspaces.map((entry) => [
1921
2332
  entry.label,
@@ -1927,6 +2338,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1927
2338
  `root=${entry.root}`,
1928
2339
  `last-used=${entry.lastUsedAt}`,
1929
2340
  ].filter(Boolean).join(" ")),
2341
+ ...composites.map((entry) => `${entry.name} [${entry.workspaceId}] kind=composite members=${entry.members.length}`),
1930
2342
  instruction,
1931
2343
  ].join("\n");
1932
2344
  logToolCall(config, {
@@ -1941,6 +2353,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1941
2353
  structuredContent: {
1942
2354
  action: "list",
1943
2355
  ...inventory,
2356
+ ...(composites.length > 0 ? { compositeWorkspaces: composites } : {}),
1944
2357
  instruction,
1945
2358
  },
1946
2359
  };
@@ -1949,6 +2362,162 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1949
2362
  staleOnly !== undefined || offset !== undefined || limit !== undefined) {
1950
2363
  throw new Error("open_workspace inventory filters root, status, state, staleOnly, offset, and limit are only valid with action=list.");
1951
2364
  }
2365
+ const openingComposite = kind === "composite" ||
2366
+ (workspaceId !== undefined && compositeWorkspaces.has(workspaceId));
2367
+ if (openingComposite) {
2368
+ if (relay !== undefined || path !== undefined || mode !== undefined || baseRef !== undefined ||
2369
+ newWorktree !== undefined || newWorkspace !== undefined) {
2370
+ throw new Error("Composite Workspace open accepts name/workspaceId/context only; members are attached separately and keep their own Workspace definitions.");
2371
+ }
2372
+ if (workspaceId !== undefined && kind === "workspace") {
2373
+ throw new Error(`${workspaceId} is a Composite Workspace, not an ordinary Workspace.`);
2374
+ }
2375
+ const composite = workspaceId !== undefined
2376
+ ? compositeWorkspaces.open(workspaceId)
2377
+ : compositeWorkspaces.create(name ?? "");
2378
+ const memberContext = memberName
2379
+ ? await loadCompositeMemberContext(composite.id, memberName, context ?? "auto", conversationScopeId, protectedWorkspaceIds)
2380
+ : undefined;
2381
+ const instruction = [
2382
+ `This is Composite Workspace ${composite.name} (${composite.id}).`,
2383
+ "It has no mounted working directory of its own. Use the Composite workspaceId as the top-level Workspace handle and explicitly select one named member for member-scoped work operations.",
2384
+ composite.members.length > 0
2385
+ ? `Members: ${composite.members.map((member) => `${member.name} — ${member.purpose}`).join("; ")}.`
2386
+ : "This Composite Workspace currently has no members.",
2387
+ "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.",
2388
+ composite.members.length > 0
2389
+ ? "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."
2390
+ : undefined,
2391
+ "Use close_workspace only when the user chooses to dissolve this Composite Workspace; dissolution does not close or clean up member Workspaces.",
2392
+ ].join("\n\n");
2393
+ const response = {
2394
+ content: [textBlock(instruction)],
2395
+ _meta: {
2396
+ tool: "open_workspace",
2397
+ card: {
2398
+ workspaceId: composite.id,
2399
+ kind: "composite",
2400
+ name: composite.name,
2401
+ path: composite.name,
2402
+ members: composite.members,
2403
+ instruction,
2404
+ summary: { members: composite.members.length },
2405
+ },
2406
+ },
2407
+ structuredContent: {
2408
+ action: "open",
2409
+ workspaceId: composite.id,
2410
+ kind: "composite",
2411
+ name: composite.name,
2412
+ members: composite.members,
2413
+ ...(memberContext ? { memberContext } : {}),
2414
+ instruction,
2415
+ },
2416
+ };
2417
+ logToolCall(config, {
2418
+ tool: "open_workspace",
2419
+ action: "composite",
2420
+ success: true,
2421
+ durationMs: Math.round(performance.now() - startedAt),
2422
+ });
2423
+ rememberWorkspacePanelState(composite.id, response);
2424
+ return response;
2425
+ }
2426
+ if (name !== undefined || memberName !== undefined) {
2427
+ throw new Error("open_workspace name/memberName are only valid for a Composite Workspace.");
2428
+ }
2429
+ if (relay !== undefined) {
2430
+ if (workspaceId !== undefined) {
2431
+ throw new Error("Relayed open_workspace requires a path; resuming a relayed workspace is not available in this tracer bullet.");
2432
+ }
2433
+ if (!path)
2434
+ throw new Error("Relayed open_workspace requires path.");
2435
+ const opened = await remoteWorkspaces.openWorkspace(relay, {
2436
+ path,
2437
+ mode,
2438
+ baseRef,
2439
+ newWorktree,
2440
+ newWorkspace,
2441
+ context,
2442
+ }, hostScopeIdFor(_meta, sessionId));
2443
+ const relayedSkills = Array.isArray(opened.skills)
2444
+ ? opened.skills
2445
+ : [];
2446
+ const relayedCapabilities = Array.isArray(opened.capabilityCatalog)
2447
+ ? opened.capabilityCatalog
2448
+ : [];
2449
+ const result = [
2450
+ `Opened relayed workspace ${opened.workspaceId}.`,
2451
+ `Execution remote: ${relay}`,
2452
+ `Root: ${opened.root}`,
2453
+ `Mode: ${opened.mode}`,
2454
+ relayedSkills.length > 0
2455
+ ? `Available skills: ${relayedSkills.map((skill) => String(skill.name ?? "")).filter(Boolean).join(", ")}`
2456
+ : undefined,
2457
+ relayedCapabilities.length > 0
2458
+ ? `Optional capabilities: ${relayedCapabilities.map((entry) => String(entry.name ?? "")).filter(Boolean).join(", ")}`
2459
+ : undefined,
2460
+ opened.instruction,
2461
+ ].filter(Boolean).join("\n");
2462
+ const response = {
2463
+ content: [textBlock(result)],
2464
+ _meta: {
2465
+ tool: "open_workspace",
2466
+ card: {
2467
+ workspaceId: opened.workspaceId,
2468
+ kind: "workspace",
2469
+ root: opened.root,
2470
+ path: opened.root,
2471
+ mode: opened.mode,
2472
+ relay,
2473
+ instruction: opened.instruction,
2474
+ summary: { mode: opened.mode, relay },
2475
+ },
2476
+ },
2477
+ structuredContent: {
2478
+ action: "open",
2479
+ workspaceId: opened.workspaceId,
2480
+ kind: "workspace",
2481
+ root: opened.root,
2482
+ mode: opened.mode,
2483
+ ...(opened.sourceRoot ? { sourceRoot: opened.sourceRoot } : {}),
2484
+ ...(opened.contextFingerprint !== undefined
2485
+ ? { contextFingerprint: opened.contextFingerprint }
2486
+ : {}),
2487
+ ...(opened.capabilityFingerprint !== undefined
2488
+ ? { capabilityFingerprint: opened.capabilityFingerprint }
2489
+ : {}),
2490
+ ...(opened.capabilityCatalog !== undefined
2491
+ ? { capabilityCatalog: opened.capabilityCatalog }
2492
+ : {}),
2493
+ ...(opened.capabilityGuides !== undefined
2494
+ ? { capabilityGuides: opened.capabilityGuides }
2495
+ : {}),
2496
+ ...(opened.agentsFiles !== undefined ? { agentsFiles: opened.agentsFiles } : {}),
2497
+ ...(opened.availableAgentsFiles !== undefined
2498
+ ? { availableAgentsFiles: opened.availableAgentsFiles }
2499
+ : {}),
2500
+ ...(opened.skills !== undefined ? { skills: opened.skills } : {}),
2501
+ ...(opened.agentProviders !== undefined
2502
+ ? { agentProviders: opened.agentProviders }
2503
+ : {}),
2504
+ ...(opened.agents !== undefined ? { agents: opened.agents } : {}),
2505
+ ...(opened.skillDiagnostics !== undefined
2506
+ ? { skillDiagnostics: opened.skillDiagnostics }
2507
+ : {}),
2508
+ instruction: opened.instruction,
2509
+ },
2510
+ };
2511
+ logToolCall(config, {
2512
+ tool: "open_workspace",
2513
+ action: "relay",
2514
+ path: opened.root,
2515
+ success: true,
2516
+ durationMs: Math.round(performance.now() - startedAt),
2517
+ });
2518
+ rememberWorkspacePanelState(opened.workspaceId, response);
2519
+ return response;
2520
+ }
1952
2521
  const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, contextFingerprint, } = await workspaces.openWorkspace({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context }, {
1953
2522
  conversationScopeId,
1954
2523
  protectedWorkspaceIds,
@@ -2086,6 +2655,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2086
2655
  tool: "open_workspace",
2087
2656
  card: {
2088
2657
  workspaceId: workspace.id,
2658
+ kind: "workspace",
2089
2659
  root: workspace.root,
2090
2660
  path: workspace.root,
2091
2661
  mode: workspace.mode,
@@ -2118,6 +2688,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2118
2688
  structuredContent: {
2119
2689
  action: "open",
2120
2690
  workspaceId: workspace.id,
2691
+ kind: "workspace",
2121
2692
  root: workspace.root,
2122
2693
  mode: workspace.mode,
2123
2694
  sourceRoot: workspace.sourceRoot,
@@ -2144,12 +2715,41 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2144
2715
  rememberWorkspacePanelState(workspace.id, response);
2145
2716
  return response;
2146
2717
  });
2147
- registerActivityQueryTools(server, activityQueries, connectionScopeId, toolWidgetDescriptorMeta(config, "activity")._meta, config.activityPanelExpanded, config.logging, (workspaceId) => workspacePanelStates.get(workspaceId));
2718
+ registerActivityQueryTools(server, activityQueries, connectionScopeId, toolWidgetDescriptorMeta(config, "activity")._meta, config.activityPanelExpanded, config.logging, workspacePanelState, {
2719
+ panel: async (workspaceId, conversationScopeId) => {
2720
+ if (compositeWorkspaces.has(workspaceId)) {
2721
+ return compositeActivity.beginPanel(workspaceId, conversationScopeId);
2722
+ }
2723
+ return remoteWorkspaces.has(workspaceId)
2724
+ ? remoteWorkspaces.activityPanel(workspaceId, conversationScopeId)
2725
+ : undefined;
2726
+ },
2727
+ snapshot: async (input, conversationScopeId) => {
2728
+ const compositeTurnId = input.turnId ?? (input.workspaceId && compositeWorkspaces.has(input.workspaceId)
2729
+ ? compositeActivity.currentTurnId(conversationScopeId, input.workspaceId)
2730
+ : undefined);
2731
+ if (compositeTurnId) {
2732
+ const composite = await compositeActivity.snapshot(compositeTurnId, input.knownRevision);
2733
+ if (composite)
2734
+ return composite;
2735
+ }
2736
+ return remoteWorkspaces.activitySnapshot(input, conversationScopeId);
2737
+ },
2738
+ detail: async (turnId, activityId, conversationScopeId) => {
2739
+ const composite = await compositeActivity.detail(turnId, activityId);
2740
+ return composite ?? remoteWorkspaces.activityDetail(turnId, activityId, conversationScopeId);
2741
+ },
2742
+ output: async (turnId, outputId, conversationScopeId) => {
2743
+ const composite = await compositeActivity.output(turnId, outputId);
2744
+ return composite ?? remoteWorkspaces.activityOutput(turnId, outputId, conversationScopeId);
2745
+ },
2746
+ });
2148
2747
  registerAppTool(server, toolNames.capability, {
2149
2748
  title: "Use optional capability",
2150
2749
  description: "Describe or run one optional ForgeRelay capability advertised by open_workspace. Use describe when the capability contract is unfamiliar, then read its advertised guide if needed. Run dispatches only explicitly registered capabilities; it cannot invoke arbitrary shell commands, URLs, or methods.",
2151
2750
  inputSchema: {
2152
2751
  workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
2752
+ member: z.string().optional().describe("Required for a Composite Workspace; explicit member name whose capability surface is used."),
2153
2753
  name: z
2154
2754
  .string()
2155
2755
  .regex(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/)
@@ -2181,9 +2781,20 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2181
2781
  idempotentHint: false,
2182
2782
  openWorldHint: true,
2183
2783
  },
2184
- }, async ({ workspaceId, name, action, arguments: capabilityArguments, file }, extra) => {
2784
+ }, async ({ workspaceId, member, name, action, arguments: capabilityArguments, file }, extra) => {
2785
+ const target = resolveExecutionTarget(workspaceId, member);
2786
+ const executionWorkspaceId = target.executionWorkspaceId;
2787
+ const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
2788
+ if (remoteWorkspaces.has(executionWorkspaceId)) {
2789
+ return presentExecutionResult(await remoteWorkspaces.capability(executionWorkspaceId, {
2790
+ name,
2791
+ action,
2792
+ ...(capabilityArguments !== undefined ? { arguments: capabilityArguments } : {}),
2793
+ ...(file !== undefined ? { file } : {}),
2794
+ }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
2795
+ }
2185
2796
  if (action === "run" && name === "batch.execute") {
2186
- const workspace = workspaces.getWorkspace(workspaceId);
2797
+ const workspace = workspaces.getWorkspace(executionWorkspaceId);
2187
2798
  const startedAt = performance.now();
2188
2799
  try {
2189
2800
  const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, capabilityContextFor(workspace), {
@@ -2204,7 +2815,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2204
2815
  success: true,
2205
2816
  durationMs: Math.round(performance.now() - startedAt),
2206
2817
  });
2207
- return result;
2818
+ return presentExecutionResult(result, target);
2208
2819
  }
2209
2820
  catch (error) {
2210
2821
  if (extra.signal.aborted)
@@ -2227,18 +2838,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2227
2838
  capability: name,
2228
2839
  action,
2229
2840
  }, result.content, startedAt);
2230
- return result;
2841
+ return presentExecutionResult(result, target);
2231
2842
  }
2232
2843
  }
2233
2844
  if (action === "run") {
2234
- return coreOperations.capabilityRun({ workspaceId, name, arguments: capabilityArguments, file }, {
2235
- requestMeta: extra._meta,
2236
- signal: extra.signal,
2237
- sessionId: extra.sessionId,
2238
- });
2845
+ return presentExecutionResult(await coreOperations.capabilityRun({ workspaceId: executionWorkspaceId, name, arguments: capabilityArguments, file }, executionContext), target);
2239
2846
  }
2240
- const workspace = workspaces.getWorkspace(workspaceId);
2241
- return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(extra._meta, extra.sessionId), { workspaceId, name, action, arguments: capabilityArguments, file }, {
2847
+ const workspace = workspaces.getWorkspace(executionWorkspaceId);
2848
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(extra._meta, extra.sessionId), activityRequestFor({ workspaceId: executionWorkspaceId, name, action, arguments: capabilityArguments, file }, executionContext), {
2242
2849
  signal: extra.signal,
2243
2850
  tool: toolNames.capability,
2244
2851
  invocation: workspaceHookInvocation(workspace),
@@ -2291,11 +2898,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2291
2898
  return result;
2292
2899
  }
2293
2900
  },
2294
- });
2901
+ }, activityRelationFor(executionContext)).then((result) => presentExecutionResult(result, target));
2295
2902
  });
2296
2903
  registerAppTool(server, toolNames.closeWorkspace, {
2297
2904
  title: "Close workspace",
2298
- description: "Close one workspace after the user chooses cleanup. Checkout-backed workspaces release only the logical handle. Managed-worktree-backed workspaces finalize the existing safe worktree lifecycle, including hooks, commit/integration, and cleanup; provide commitMessage for that mode. Running processes prevent closure; completed background results are delivered with the close response when available.",
2905
+ description: "Close one workspace after the user chooses cleanup. Composite Workspaces dissolve here: only the Composite identity and member links are removed; member Workspaces, files, processes, worktrees, and relay routes remain intact. Checkout-backed workspaces release only the logical handle. Managed-worktree-backed workspaces run the safe finalize lifecycle (hooks, commit, fast-forward integration, cleanup) and require commitMessage. Running processes block ordinary Workspace closure.",
2299
2906
  inputSchema: {
2300
2907
  workspaceId: z.string().describe("Workspace identifier to close."),
2301
2908
  commitMessage: z
@@ -2306,7 +2913,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2306
2913
  },
2307
2914
  outputSchema: resultOutputSchema({
2308
2915
  workspaceId: z.string(),
2309
- mode: z.enum(["checkout", "worktree"]),
2916
+ kind: z.enum(["workspace", "composite"]).optional(),
2917
+ mode: z.enum(["checkout", "worktree"]).optional(),
2918
+ name: z.string().optional(),
2919
+ members: z.array(z.object({
2920
+ name: z.string(),
2921
+ purpose: z.string(),
2922
+ workspaceId: z.string(),
2923
+ })).optional(),
2924
+ dissolved: z.boolean().optional(),
2310
2925
  sourceRoot: z.string().optional(),
2311
2926
  branch: z.string().optional(),
2312
2927
  targetBranch: z.string().optional(),
@@ -2318,6 +2933,48 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2318
2933
  _meta: {},
2319
2934
  annotations: WRITE_TOOL_ANNOTATIONS,
2320
2935
  }, async ({ workspaceId, commitMessage }, extra) => {
2936
+ if (compositeWorkspaces.has(workspaceId)) {
2937
+ if (commitMessage !== undefined) {
2938
+ throw new Error("close_workspace commitMessage is not valid when dissolving a Composite Workspace.");
2939
+ }
2940
+ const composite = compositeWorkspaces.dissolve(workspaceId);
2941
+ compositeActivity.forgetComposite(workspaceId);
2942
+ workspacePanelStates.delete(workspaceId);
2943
+ const result = [
2944
+ `Dissolved Composite Workspace ${composite.name} (${workspaceId}).`,
2945
+ composite.members.length > 0
2946
+ ? `Preserved member Workspaces: ${composite.members.map((member) => `${member.name} [${member.workspaceId}]`).join(", ")}.`
2947
+ : "The Composite Workspace had no members.",
2948
+ "Member Workspace handles, managed worktrees, processes, files, and Workspace Relay routes were not closed or cleaned up.",
2949
+ ].join("\n");
2950
+ return {
2951
+ content: [textBlock(result)],
2952
+ _meta: {
2953
+ tool: toolNames.closeWorkspace,
2954
+ card: {
2955
+ workspaceId,
2956
+ kind: "composite",
2957
+ name: composite.name,
2958
+ members: composite.members,
2959
+ dissolved: true,
2960
+ payload: { content: [textBlock(result)] },
2961
+ },
2962
+ },
2963
+ structuredContent: {
2964
+ result,
2965
+ workspaceId,
2966
+ kind: "composite",
2967
+ name: composite.name,
2968
+ members: composite.members,
2969
+ dissolved: true,
2970
+ },
2971
+ };
2972
+ }
2973
+ if (remoteWorkspaces.has(workspaceId)) {
2974
+ const response = await remoteWorkspaces.closeWorkspace(workspaceId, commitMessage, hostScopeIdFor(extra._meta, extra.sessionId));
2975
+ workspacePanelStates.delete(workspaceId);
2976
+ return response;
2977
+ }
2321
2978
  const workspace = workspaces.getWorkspace(workspaceId);
2322
2979
  const response = await runToolWithHooks(hooks, {
2323
2980
  signal: extra.signal,
@@ -2429,6 +3086,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2429
3086
  workspaceId: z
2430
3087
  .string()
2431
3088
  .describe("Workspace identifier returned by open_workspace."),
3089
+ member: z
3090
+ .string()
3091
+ .optional()
3092
+ .describe("Required for a Composite Workspace; explicit member name that owns this operation."),
2432
3093
  path: z
2433
3094
  .string()
2434
3095
  .optional()
@@ -2466,27 +3127,27 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2466
3127
  }),
2467
3128
  ...toolWidgetDescriptorMeta(config, "read"),
2468
3129
  annotations: { readOnlyHint: true },
2469
- }, async ({ workspaceId, path, paths, offset, limit }, extra) => {
3130
+ }, async ({ workspaceId, member, path, paths, offset, limit }, extra) => {
2470
3131
  if ((path === undefined) === (paths === undefined)) {
2471
3132
  throw new Error("read requires exactly one of path or paths.");
2472
3133
  }
3134
+ const target = resolveExecutionTarget(workspaceId, member);
3135
+ const executionWorkspaceId = target.executionWorkspaceId;
3136
+ const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3137
+ if (remoteWorkspaces.has(executionWorkspaceId)) {
3138
+ return presentExecutionResult(await remoteWorkspaces.read(executionWorkspaceId, { path, paths, offset, limit }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3139
+ }
2473
3140
  if (path !== undefined) {
2474
- return coreOperations.read({ workspaceId, path, offset, limit }, {
2475
- requestMeta: extra._meta,
2476
- signal: extra.signal,
2477
- sessionId: extra.sessionId,
2478
- });
3141
+ return presentExecutionResult(await coreOperations.read({ workspaceId: executionWorkspaceId, path, offset, limit }, executionContext), target);
2479
3142
  }
2480
- const workspace = workspaces.getWorkspace(workspaceId);
3143
+ const workspace = workspaces.getWorkspace(executionWorkspaceId);
2481
3144
  let response;
2482
- await runActivityTool(activityLifecycle, workspace, hostScopeIdFor(extra._meta, extra.sessionId), toolNames.read, { workspaceId, paths, offset, limit }, async (parentContext) => {
3145
+ await runActivityTool(activityLifecycle, workspace, hostScopeIdFor(extra._meta, extra.sessionId), toolNames.read, activityRequestFor({ workspaceId: executionWorkspaceId, paths, offset, limit }, executionContext), async (parentContext) => {
2483
3146
  const execution = await executeBulkRead({
2484
3147
  paths: paths,
2485
3148
  signal: extra.signal,
2486
- run: (childPath) => coreOperations.read({ workspaceId, path: childPath, offset, limit }, {
2487
- requestMeta: extra._meta,
2488
- signal: extra.signal,
2489
- sessionId: extra.sessionId,
3149
+ run: (childPath) => coreOperations.read({ workspaceId: executionWorkspaceId, path: childPath, offset, limit }, {
3150
+ ...executionContext,
2490
3151
  parentActivityId: parentContext.activityId,
2491
3152
  turnId: parentContext.turnId,
2492
3153
  }),
@@ -2527,10 +3188,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2527
3188
  };
2528
3189
  }, (summary) => summary.failed > 0
2529
3190
  ? { type: "failed", error: `${summary.failed} of ${summary.childCount} child Reads failed.` }
2530
- : { type: "succeeded" });
3191
+ : { type: "succeeded" }, activityRelationFor(executionContext));
2531
3192
  if (!response)
2532
3193
  throw new Error("Bulk Read completed without a response.");
2533
- return response;
3194
+ return presentExecutionResult(response, target);
2534
3195
  });
2535
3196
  if (config.toolMode !== "codex") {
2536
3197
  registerAppTool(server, toolNames.write, {
@@ -2540,6 +3201,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2540
3201
  workspaceId: z
2541
3202
  .string()
2542
3203
  .describe("Workspace identifier returned by open_workspace."),
3204
+ member: z.string().optional().describe("Required for a Composite Workspace; explicit member name that owns this operation."),
2543
3205
  path: z
2544
3206
  .string()
2545
3207
  .describe("File path to write, relative to the workspace root or absolute inside the OS temp directory."),
@@ -2548,11 +3210,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2548
3210
  outputSchema: resultOutputSchema(),
2549
3211
  ...toolWidgetDescriptorMeta(config, "write"),
2550
3212
  annotations: WRITE_TOOL_ANNOTATIONS,
2551
- }, async ({ workspaceId, ...input }, extra) => coreOperations.write({ workspaceId, ...input }, {
2552
- requestMeta: extra._meta,
2553
- signal: extra.signal,
2554
- sessionId: extra.sessionId,
2555
- }));
3213
+ }, async ({ workspaceId, member, ...input }, extra) => {
3214
+ const target = resolveExecutionTarget(workspaceId, member);
3215
+ const executionWorkspaceId = target.executionWorkspaceId;
3216
+ const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3217
+ if (remoteWorkspaces.has(executionWorkspaceId)) {
3218
+ return presentExecutionResult(await remoteWorkspaces.write(executionWorkspaceId, input, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3219
+ }
3220
+ return presentExecutionResult(await coreOperations.write({ workspaceId: executionWorkspaceId, ...input }, executionContext), target);
3221
+ });
2556
3222
  registerAppTool(server, toolNames.edit, {
2557
3223
  title: "Edit file",
2558
3224
  description: toolDescriptions.edit,
@@ -2560,6 +3226,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2560
3226
  workspaceId: z
2561
3227
  .string()
2562
3228
  .describe("Workspace identifier returned by open_workspace."),
3229
+ member: z.string().optional().describe("Required for a Composite Workspace; explicit member name that owns this operation."),
2563
3230
  path: z
2564
3231
  .string()
2565
3232
  .optional()
@@ -2593,22 +3260,20 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2593
3260
  }),
2594
3261
  ...toolWidgetDescriptorMeta(config, "edit"),
2595
3262
  annotations: EDIT_TOOL_ANNOTATIONS,
2596
- }, async ({ workspaceId, path, paths, edits }, extra) => {
3263
+ }, async ({ workspaceId, member, path, paths, edits }, extra) => {
2597
3264
  if ((path === undefined) === (paths === undefined)) {
2598
3265
  throw new Error("edit requires exactly one of path or paths.");
2599
3266
  }
3267
+ const target = resolveExecutionTarget(workspaceId, member);
3268
+ const executionWorkspaceId = target.executionWorkspaceId;
3269
+ const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3270
+ if (remoteWorkspaces.has(executionWorkspaceId)) {
3271
+ return presentExecutionResult(await remoteWorkspaces.edit(executionWorkspaceId, { path, paths, edits }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3272
+ }
2600
3273
  if (path !== undefined) {
2601
- return coreOperations.edit({ workspaceId, path, edits }, {
2602
- requestMeta: extra._meta,
2603
- signal: extra.signal,
2604
- sessionId: extra.sessionId,
2605
- });
3274
+ return presentExecutionResult(await coreOperations.edit({ workspaceId: executionWorkspaceId, path, edits }, executionContext), target);
2606
3275
  }
2607
- return nativeBulkMutations.edit({ workspaceId, paths: paths, edits }, {
2608
- requestMeta: extra._meta,
2609
- signal: extra.signal,
2610
- sessionId: extra.sessionId,
2611
- });
3276
+ return presentExecutionResult(await nativeBulkMutations.edit({ workspaceId: executionWorkspaceId, paths: paths, edits }, executionContext), target);
2612
3277
  });
2613
3278
  }
2614
3279
  registerAppTool(server, toolNames.rename, {
@@ -2616,6 +3281,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2616
3281
  description: toolDescriptions.rename,
2617
3282
  inputSchema: {
2618
3283
  workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
3284
+ member: z.string().optional().describe("Required for a Composite Workspace; explicit member name that owns this operation."),
2619
3285
  path: z.string().describe("Source file or directory path relative to the workspace root, or absolute inside the OS temp directory."),
2620
3286
  newPath: z.string().describe("Destination path relative to the workspace root, or absolute inside the OS temp directory. The destination must not already exist."),
2621
3287
  },
@@ -2626,16 +3292,21 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2626
3292
  }),
2627
3293
  ...toolWidgetDescriptorMeta(config, "edit"),
2628
3294
  annotations: EDIT_TOOL_ANNOTATIONS,
2629
- }, async ({ workspaceId, path, newPath }, extra) => coreOperations.rename({ workspaceId, path, newPath }, {
2630
- requestMeta: extra._meta,
2631
- signal: extra.signal,
2632
- sessionId: extra.sessionId,
2633
- }));
3295
+ }, async ({ workspaceId, member, path, newPath }, extra) => {
3296
+ const target = resolveExecutionTarget(workspaceId, member);
3297
+ const executionWorkspaceId = target.executionWorkspaceId;
3298
+ const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3299
+ if (remoteWorkspaces.has(executionWorkspaceId)) {
3300
+ return presentExecutionResult(await remoteWorkspaces.rename(executionWorkspaceId, { path, newPath }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3301
+ }
3302
+ return presentExecutionResult(await coreOperations.rename({ workspaceId: executionWorkspaceId, path, newPath }, executionContext), target);
3303
+ });
2634
3304
  registerAppTool(server, toolNames.delete, {
2635
3305
  title: "Delete path",
2636
3306
  description: toolDescriptions.delete,
2637
3307
  inputSchema: {
2638
3308
  workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
3309
+ member: z.string().optional().describe("Required for a Composite Workspace; explicit member name that owns this operation."),
2639
3310
  path: z.string().optional().describe("One file or directory path to delete. Use exactly one of path or paths."),
2640
3311
  paths: z
2641
3312
  .array(z.string())
@@ -2661,22 +3332,20 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2661
3332
  }),
2662
3333
  ...toolWidgetDescriptorMeta(config, "edit"),
2663
3334
  annotations: EDIT_TOOL_ANNOTATIONS,
2664
- }, async ({ workspaceId, path, paths, recursive }, extra) => {
3335
+ }, async ({ workspaceId, member, path, paths, recursive }, extra) => {
2665
3336
  if ((path === undefined) === (paths === undefined)) {
2666
3337
  throw new Error("delete requires exactly one of path or paths.");
2667
3338
  }
3339
+ const target = resolveExecutionTarget(workspaceId, member);
3340
+ const executionWorkspaceId = target.executionWorkspaceId;
3341
+ const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3342
+ if (remoteWorkspaces.has(executionWorkspaceId)) {
3343
+ return presentExecutionResult(await remoteWorkspaces.delete(executionWorkspaceId, { path, paths, recursive }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3344
+ }
2668
3345
  if (path !== undefined) {
2669
- return coreOperations.delete({ workspaceId, path, recursive }, {
2670
- requestMeta: extra._meta,
2671
- signal: extra.signal,
2672
- sessionId: extra.sessionId,
2673
- });
3346
+ return presentExecutionResult(await coreOperations.delete({ workspaceId: executionWorkspaceId, path, recursive }, executionContext), target);
2674
3347
  }
2675
- return nativeBulkMutations.delete({ workspaceId, paths: paths, recursive }, {
2676
- requestMeta: extra._meta,
2677
- signal: extra.signal,
2678
- sessionId: extra.sessionId,
2679
- });
3348
+ return presentExecutionResult(await nativeBulkMutations.delete({ workspaceId: executionWorkspaceId, paths: paths, recursive }, executionContext), target);
2680
3349
  });
2681
3350
  if (config.toolMode === "codex") {
2682
3351
  registerAppTool(server, "apply_patch", {
@@ -2686,6 +3355,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2686
3355
  workspaceId: z
2687
3356
  .string()
2688
3357
  .describe("Workspace identifier returned by open_workspace."),
3358
+ member: z.string().optional().describe("Required for a Composite Workspace; explicit member name that owns this patch."),
2689
3359
  patch: z
2690
3360
  .string()
2691
3361
  .describe("Patch text enclosed by *** Begin Patch and *** End Patch markers."),
@@ -2701,9 +3371,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2701
3371
  }),
2702
3372
  ...toolWidgetDescriptorMeta(config, "edit"),
2703
3373
  annotations: EDIT_TOOL_ANNOTATIONS,
2704
- }, async ({ workspaceId, patch }, extra) => {
2705
- const workspace = workspaces.getWorkspace(workspaceId);
2706
- return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(extra._meta, extra.sessionId), { workspaceId, patch }, {
3374
+ }, async ({ workspaceId, member, patch }, extra) => {
3375
+ const target = resolveExecutionTarget(workspaceId, member);
3376
+ const executionWorkspaceId = target.executionWorkspaceId;
3377
+ const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3378
+ if (remoteWorkspaces.has(executionWorkspaceId)) {
3379
+ return presentExecutionResult(await remoteWorkspaces.applyPatch(executionWorkspaceId, { patch }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3380
+ }
3381
+ const workspace = workspaces.getWorkspace(executionWorkspaceId);
3382
+ return runActivityToolWithHooks(activityLifecycle, hooks, workspace, hostScopeIdFor(extra._meta, extra.sessionId), activityRequestFor({ workspaceId: executionWorkspaceId, patch }, executionContext), {
2707
3383
  signal: extra.signal,
2708
3384
  tool: "apply_patch",
2709
3385
  invocation: workspaceHookInvocation(workspace),
@@ -2731,7 +3407,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2731
3407
  _meta: {
2732
3408
  tool: "apply_patch",
2733
3409
  card: {
2734
- workspaceId,
3410
+ workspaceId: executionWorkspaceId,
2735
3411
  path: displayPath,
2736
3412
  summary: {
2737
3413
  files: applied.files.length,
@@ -2750,7 +3426,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2750
3426
  },
2751
3427
  };
2752
3428
  },
2753
- });
3429
+ }, activityRelationFor(executionContext)).then((result) => presentExecutionResult(result, target));
2754
3430
  });
2755
3431
  }
2756
3432
  if (config.toolMode !== "codex") {
@@ -2761,6 +3437,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2761
3437
  workspaceId: z
2762
3438
  .string()
2763
3439
  .describe("Workspace identifier returned by open_workspace."),
3440
+ member: z.string().optional().describe("Required for a Composite Workspace; explicit member name that owns this process operation."),
2764
3441
  action: z
2765
3442
  .enum(["run", "process", "output"])
2766
3443
  .optional()
@@ -2834,16 +3511,36 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2834
3511
  outputSchema: processOutputSchema(),
2835
3512
  ...toolWidgetDescriptorMeta(config, "shell"),
2836
3513
  annotations: SHELL_TOOL_ANNOTATIONS,
2837
- }, async ({ workspaceId, action = "run", command, processId, outputId, input, interrupt, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens, }, extra) => {
2838
- const workspace = workspaces.getWorkspace(workspaceId);
3514
+ }, async ({ workspaceId, member, action = "run", command, processId, outputId, input, interrupt, tty, columns, rows, workingDirectory, yieldTimeMs, timeoutMs, maxOutputTokens, }, extra) => {
3515
+ const target = resolveExecutionTarget(workspaceId, member);
3516
+ const executionWorkspaceId = target.executionWorkspaceId;
3517
+ const executionContext = await prepareExecutionContext(target, extra._meta, extra.signal, extra.sessionId);
3518
+ if (remoteWorkspaces.has(executionWorkspaceId)) {
3519
+ return presentExecutionResult(await remoteWorkspaces.bash(executionWorkspaceId, {
3520
+ action,
3521
+ ...(command !== undefined ? { command } : {}),
3522
+ ...(processId !== undefined ? { processId } : {}),
3523
+ ...(outputId !== undefined ? { outputId } : {}),
3524
+ ...(input !== undefined ? { input } : {}),
3525
+ ...(interrupt !== undefined ? { interrupt } : {}),
3526
+ ...(tty !== undefined ? { tty } : {}),
3527
+ ...(columns !== undefined ? { columns } : {}),
3528
+ ...(rows !== undefined ? { rows } : {}),
3529
+ ...(workingDirectory !== undefined ? { workingDirectory } : {}),
3530
+ ...(yieldTimeMs !== undefined ? { yieldTimeMs } : {}),
3531
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
3532
+ ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
3533
+ }, hostScopeIdFor(extra._meta, extra.sessionId)), target);
3534
+ }
3535
+ const workspace = workspaces.getWorkspace(executionWorkspaceId);
2839
3536
  if (action === "run") {
2840
3537
  if (!command)
2841
3538
  throw new Error("bash action=run requires command.");
2842
3539
  if (processId !== undefined || outputId !== undefined || input !== undefined || interrupt !== undefined) {
2843
3540
  throw new Error("bash action=run does not accept processId, outputId, input, or interrupt.");
2844
3541
  }
2845
- return coreOperations.shellRun({
2846
- workspaceId,
3542
+ return presentExecutionResult(await coreOperations.shellRun({
3543
+ workspaceId: executionWorkspaceId,
2847
3544
  command,
2848
3545
  surface: "bash",
2849
3546
  tty,
@@ -2853,11 +3550,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2853
3550
  yieldTimeMs,
2854
3551
  timeoutMs,
2855
3552
  maxOutputTokens,
2856
- }, {
2857
- requestMeta: extra._meta,
2858
- signal: extra.signal,
2859
- sessionId: extra.sessionId,
2860
- });
3553
+ }, executionContext), target);
2861
3554
  }
2862
3555
  if (action === "output") {
2863
3556
  if (!outputId)
@@ -2872,8 +3565,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2872
3565
  tool: toolNames.shell,
2873
3566
  invocation: workspaceHookInvocation(workspace),
2874
3567
  payload: { action, outputId },
2875
- operation: async () => durableOutputResponse(toolNames.shell, workspaceId, readWorkspaceBashOutput(bashOutputStore, workspaceId, outputId)),
2876
- });
3568
+ operation: async () => durableOutputResponse(toolNames.shell, executionWorkspaceId, readWorkspaceBashOutput(bashOutputStore, executionWorkspaceId, outputId)),
3569
+ }).then((result) => presentExecutionResult(result, target));
2877
3570
  }
2878
3571
  if (outputId !== undefined)
2879
3572
  throw new Error("bash action=process does not accept outputId.");
@@ -2901,7 +3594,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2901
3594
  operation: async () => {
2902
3595
  const startedAt = performance.now();
2903
3596
  const snapshot = await processSessions.write({
2904
- workspaceId,
3597
+ workspaceId: executionWorkspaceId,
2905
3598
  processId,
2906
3599
  chars: interrupt ? "\u0003" : input,
2907
3600
  columns,
@@ -2919,7 +3612,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2919
3612
  success: snapshot.running || snapshot.exitCode === 0,
2920
3613
  durationMs: Math.round(performance.now() - startedAt),
2921
3614
  });
2922
- const response = processToolResponse(toolNames.shell, workspaceId, snapshot, {
3615
+ const response = processToolResponse(toolNames.shell, executionWorkspaceId, snapshot, {
2923
3616
  action,
2924
3617
  processId,
2925
3618
  inputLength: input?.length ?? 0,
@@ -2931,12 +3624,20 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
2931
3624
  if (!snapshot.running) {
2932
3625
  recordBashCompletion(activityLifecycle, bashOutputStore, snapshot.outputId);
2933
3626
  }
2934
- return response;
3627
+ return presentExecutionResult(response, target);
2935
3628
  },
2936
3629
  });
2937
3630
  });
2938
3631
  }
2939
- registerProcessTools(server, config, workspaces, processSessions, hooks, activityLifecycle, bashOutputStore, (input, context) => coreOperations.shellRun(input, context));
3632
+ registerProcessTools(server, config, workspaces, processSessions, hooks, activityLifecycle, bashOutputStore, (input, context) => coreOperations.shellRun(input, context), {
3633
+ resolve: resolveExecutionTarget,
3634
+ prepare: prepareExecutionContext,
3635
+ present: presentExecutionResult,
3636
+ isRemote: (workspaceId) => remoteWorkspaces.has(workspaceId),
3637
+ execCommandRemote: (workspaceId, input, conversationScopeId) => remoteWorkspaces.execCommand(workspaceId, input, conversationScopeId),
3638
+ writeStdinRemote: (workspaceId, input, conversationScopeId) => remoteWorkspaces.writeStdin(workspaceId, input, conversationScopeId),
3639
+ hostScopeIdFor,
3640
+ });
2940
3641
  return server;
2941
3642
  }
2942
3643
  export function createServer(config = loadConfig(), options = {}) {
@@ -3069,6 +3770,8 @@ export function createServer(config = loadConfig(), options = {}) {
3069
3770
  });
3070
3771
  app.use(createForgeRelayAuthRouter({
3071
3772
  provider: oauthProvider,
3773
+ cliAuthenticationProvider: oauthProvider,
3774
+ instanceId: config.instanceId,
3072
3775
  issuerUrl: new URL(config.publicBaseUrl),
3073
3776
  resourceServerUrl,
3074
3777
  scopesSupported: config.oauth.scopes,