@akira-tl/forgerelay 0.3.5 → 0.3.7

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
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { access, realpath } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
+ import { resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
7
8
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
@@ -42,6 +43,7 @@ import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvail
42
43
  // transport. Bound stale transport-session retention so abandoned transports do
43
44
  // not accumulate for the life of the process.
44
45
  const MCP_TRANSPORT_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
46
+ const MAX_MCP_TRANSPORT_SESSIONS = 64;
45
47
  const FORGERELAY_VERSION = readForgeRelayVersion();
46
48
  const MCP_TRANSPORT_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
47
49
  const WRITE_TOOL_ANNOTATIONS = {
@@ -92,6 +94,30 @@ function workspaceLogContext(workspace, _transportSessionId) {
92
94
  workspace: workspaceLogLabel(workspace.root, workspace.id),
93
95
  };
94
96
  }
97
+ function formatDiscoveredWorkspaceInstructions(files, workspaceRoot) {
98
+ return [
99
+ "Workspace instructions discovered for this path. Apply them to follow-up work under their directories:",
100
+ ...files.flatMap((file) => [
101
+ `--- ${formatAgentsPath(file.path, workspaceRoot)} ---`,
102
+ file.content.trimEnd(),
103
+ ]),
104
+ ].join("\n");
105
+ }
106
+ async function assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, paths) {
107
+ const discovered = new Map();
108
+ for (const path of paths) {
109
+ const absolutePath = resolve(workspace.root, path);
110
+ for (const file of await workspaces.discoverPathInstructions(workspace, absolutePath)) {
111
+ discovered.set(file.path, file);
112
+ }
113
+ }
114
+ if (discovered.size === 0)
115
+ return;
116
+ throw new Error([
117
+ formatDiscoveredWorkspaceInstructions([...discovered.values()], workspace.root),
118
+ "Apply these instructions, then retry this tool call. No mutation or command was executed.",
119
+ ].join("\n"));
120
+ }
95
121
  function formatVisibleAgent(agent) {
96
122
  const model = agent.model ? `, model ${agent.model}` : "";
97
123
  const thinking = agent.thinking ? `, thinking ${agent.thinking}` : "";
@@ -164,6 +190,36 @@ const workspaceLocalAgentProviderOutputSchema = z.object({
164
190
  const workspaceAvailableAgentsFileOutputSchema = z.object({
165
191
  path: z.string(),
166
192
  });
193
+ const workspaceInventoryEntryOutputSchema = z.object({
194
+ label: z.string(),
195
+ workspaceId: z.string(),
196
+ root: z.string(),
197
+ status: z.string(),
198
+ state: z.enum(["active", "stale", "invalid", "closed"]),
199
+ mode: z.enum(["checkout", "worktree"]),
200
+ sourceRoot: z.string().optional(),
201
+ branch: z.string().optional(),
202
+ targetBranch: z.string().optional(),
203
+ managed: z.boolean(),
204
+ createdAt: z.string(),
205
+ lastUsedAt: z.string(),
206
+ idleMs: z.number().nonnegative(),
207
+ rootValid: z.boolean(),
208
+ current: z.boolean(),
209
+ });
210
+ const workspaceInventorySummaryOutputSchema = z.object({
211
+ total: z.number().int().nonnegative(),
212
+ matching: z.number().int().nonnegative(),
213
+ active: z.number().int().nonnegative(),
214
+ stale: z.number().int().nonnegative(),
215
+ invalid: z.number().int().nonnegative(),
216
+ closed: z.number().int().nonnegative(),
217
+ });
218
+ const workspaceInventoryPageOutputSchema = z.object({
219
+ offset: z.number().int().nonnegative(),
220
+ limit: z.number().int().positive(),
221
+ hasMore: z.boolean(),
222
+ });
167
223
  const reviewFileOutputSchema = z.object({
168
224
  path: z.string(),
169
225
  previousPath: z.string().optional(),
@@ -578,6 +634,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
578
634
  operation: async () => {
579
635
  const startedAt = performance.now();
580
636
  const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
637
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
581
638
  const snapshot = await processSessions.start({
582
639
  workspaceId,
583
640
  command: cmd,
@@ -775,20 +832,24 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
775
832
  }, async (uri, _variables, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
776
833
  registerAppTool(server, "open_workspace", {
777
834
  title: "Open workspace",
778
- 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 a capability fingerprint; bootstrap calls also expose project context, skills, and capability guides when needed.",
835
+ 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.",
779
836
  inputSchema: {
837
+ action: z
838
+ .enum(["open", "list"])
839
+ .optional()
840
+ .describe("Defaults to open. Use list only when you need to inspect or choose logical workspaces before resuming or cleaning them up."),
780
841
  path: z
781
842
  .string()
782
843
  .optional()
783
- .describe("Project path to open. Required unless workspaceId is supplied. With mode=\"worktree\", this may also be a managed worktree path previously returned by ForgeRelay."),
844
+ .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."),
784
845
  workspaceId: z
785
846
  .string()
786
847
  .optional()
787
- .describe("Existing logical workspace ID to resume in this conversation. When supplied, ForgeRelay resumes that workspace rather than allocating another ID."),
848
+ .describe("For action=open, an existing logical workspace ID to resume in this conversation. For action=list, filters inventory to one workspace ID."),
788
849
  mode: z
789
850
  .enum(["checkout", "worktree"])
790
851
  .optional()
791
- .describe("Defaults to checkout, which works in the actual directory. Use worktree only when the user explicitly requests isolated or parallel Git work."),
852
+ .describe("For action=open, defaults to checkout and uses the actual directory unless worktree isolation is explicitly requested. For action=list, filters by workspace mode."),
792
853
  baseRef: z
793
854
  .string()
794
855
  .optional()
@@ -801,11 +862,45 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
801
862
  .boolean()
802
863
  .optional()
803
864
  .describe("When true, allocate a fresh logical workspaceId for the same physical checkout or worktree and bind this conversation to it. Use only after the user explicitly requests a new logical workspace."),
865
+ context: z
866
+ .enum(["auto", "full", "none"])
867
+ .optional()
868
+ .describe("Bootstrap context policy for action=open. auto (default) sends full project context only when this conversation has not received the current context fingerprint; full forces a refresh; none opens/resumes without returning the full bootstrap context."),
869
+ root: z
870
+ .string()
871
+ .optional()
872
+ .describe("For action=list, filter by canonical workspace root or source root."),
873
+ status: z
874
+ .string()
875
+ .optional()
876
+ .describe("For action=list, filter by persisted workspace status such as active or closed."),
877
+ state: z
878
+ .enum(["active", "stale", "invalid", "closed"])
879
+ .optional()
880
+ .describe("For action=list, filter by derived lifecycle state."),
881
+ staleOnly: z
882
+ .boolean()
883
+ .optional()
884
+ .describe("For action=list, return only active logical workspaces idle for more than two days."),
885
+ offset: z
886
+ .number()
887
+ .int()
888
+ .nonnegative()
889
+ .optional()
890
+ .describe("For action=list, zero-based inventory offset. Defaults to 0."),
891
+ limit: z
892
+ .number()
893
+ .int()
894
+ .min(1)
895
+ .max(100)
896
+ .optional()
897
+ .describe("For action=list, maximum records to return. Defaults to 50; maximum 100."),
804
898
  },
805
899
  outputSchema: {
806
- workspaceId: z.string(),
807
- root: z.string(),
808
- mode: z.enum(["checkout", "worktree"]),
900
+ action: z.enum(["open", "list"]),
901
+ workspaceId: z.string().optional(),
902
+ root: z.string().optional(),
903
+ mode: z.enum(["checkout", "worktree"]).optional(),
809
904
  sourceRoot: z.string().optional(),
810
905
  worktree: z
811
906
  .object({
@@ -828,7 +923,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
828
923
  targetBranch: z.string().optional(),
829
924
  managed: z.boolean(),
830
925
  current: z.boolean(),
831
- })),
926
+ })).optional(),
832
927
  staleWorkspaces: z.array(z.object({
833
928
  workspaceId: z.string(),
834
929
  root: z.string(),
@@ -838,9 +933,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
838
933
  branch: z.string().optional(),
839
934
  targetBranch: z.string().optional(),
840
935
  managed: z.boolean(),
841
- })),
842
- capabilityFingerprint: capabilityFingerprintOutputSchema,
843
- capabilityCatalog: z.array(capabilityCatalogOutputSchema),
936
+ })).optional(),
937
+ capabilityFingerprint: capabilityFingerprintOutputSchema.optional(),
938
+ contextFingerprint: z.string().optional(),
939
+ capabilityCatalog: z.array(capabilityCatalogOutputSchema).optional(),
844
940
  capabilityGuides: z.array(capabilityGuideOutputSchema).optional(),
845
941
  agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
846
942
  availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
@@ -848,6 +944,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
848
944
  agentProviders: z.array(workspaceLocalAgentProviderOutputSchema).optional(),
849
945
  agents: z.array(workspaceLocalAgentOutputSchema).optional(),
850
946
  skillDiagnostics: z.array(z.unknown()).optional(),
947
+ workspaces: z.array(workspaceInventoryEntryOutputSchema).optional(),
948
+ summary: workspaceInventorySummaryOutputSchema.optional(),
949
+ page: workspaceInventoryPageOutputSchema.optional(),
851
950
  instruction: z.string(),
852
951
  },
853
952
  ...toolWidgetDescriptorMeta(config, "workspace"),
@@ -857,11 +956,62 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
857
956
  idempotentHint: false,
858
957
  openWorldHint: false,
859
958
  },
860
- }, async ({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace }, { _meta, sessionId }) => {
959
+ }, async ({ action = "open", path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context, root, status, state, staleOnly, offset, limit, }, { _meta, sessionId }) => {
861
960
  const startedAt = performance.now();
862
- const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace }, {
863
- conversationScopeId: openAiConversationScopeId(_meta),
864
- protectedWorkspaceIds: processSessions.activeWorkspaceIds(),
961
+ const conversationScopeId = openAiConversationScopeId(_meta);
962
+ const protectedWorkspaceIds = processSessions.activeWorkspaceIds();
963
+ if (action === "list") {
964
+ if (path !== undefined || baseRef !== undefined || newWorktree !== undefined ||
965
+ newWorkspace !== undefined || context !== undefined) {
966
+ 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.");
967
+ }
968
+ const inventory = await workspaces.listWorkspaces({ workspaceId, mode, root, status, state, staleOnly, offset, limit }, { conversationScopeId, protectedWorkspaceIds });
969
+ const nextOffset = inventory.page.offset + inventory.page.limit;
970
+ const instruction = [
971
+ "Resume a selected workspaceId with open_workspace(action=\"open\", workspaceId=...).",
972
+ "Use close_workspace only after the user chooses cleanup; never close inventory entries automatically.",
973
+ inventory.page.hasMore
974
+ ? `More matching workspaces are available; continue with offset=${nextOffset}.`
975
+ : undefined,
976
+ ].filter(Boolean).join(" ");
977
+ const result = [
978
+ `Logical workspace inventory: ${inventory.summary.matching} matching of ${inventory.summary.total} stored records.`,
979
+ `States: active=${inventory.summary.active}, stale=${inventory.summary.stale}, invalid=${inventory.summary.invalid}, closed=${inventory.summary.closed}.`,
980
+ ...inventory.workspaces.map((entry) => [
981
+ entry.label,
982
+ `state=${entry.state}`,
983
+ `status=${entry.status}`,
984
+ `mode=${entry.mode}`,
985
+ entry.managed ? "managed" : undefined,
986
+ entry.current ? "current" : undefined,
987
+ `root=${entry.root}`,
988
+ `last-used=${entry.lastUsedAt}`,
989
+ ].filter(Boolean).join(" ")),
990
+ instruction,
991
+ ].join("\n");
992
+ logToolCall(config, {
993
+ tool: "open_workspace",
994
+ action: "list",
995
+ path: root,
996
+ success: true,
997
+ durationMs: Math.round(performance.now() - startedAt),
998
+ });
999
+ return {
1000
+ content: [textBlock(result)],
1001
+ structuredContent: {
1002
+ action: "list",
1003
+ ...inventory,
1004
+ instruction,
1005
+ },
1006
+ };
1007
+ }
1008
+ if (root !== undefined || status !== undefined || state !== undefined ||
1009
+ staleOnly !== undefined || offset !== undefined || limit !== undefined) {
1010
+ throw new Error("open_workspace inventory filters root, status, state, staleOnly, offset, and limit are only valid with action=list.");
1011
+ }
1012
+ const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, contextFingerprint, } = await workspaces.openWorkspace({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace, context }, {
1013
+ conversationScopeId,
1014
+ protectedWorkspaceIds,
865
1015
  });
866
1016
  const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
867
1017
  const staleWorkspaces = await workspaces.listStaleWorkspaces(workspace);
@@ -911,20 +1061,26 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
911
1061
  const visibleAgents = includeBootstrapContext ? cardAgents : [];
912
1062
  const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : [];
913
1063
  const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : [];
1064
+ const workspaceContextInstruction = "For later open_workspace calls, context=\"auto\" avoids repeating unchanged bootstrap context; use context=\"none\" when only the workspace handle/metadata is needed, or context=\"full\" to force a refresh.";
1065
+ const workspaceManagementInstruction = "When you need to continue an earlier logical workspace or organize workspace state, use open_workspace(action=\"list\") to inspect candidates, then resume a selected workspaceId or ask the user before close_workspace cleanup.";
914
1066
  const cardInstruction = config.skillsEnabled
915
- ? "Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches an available skill or capability guide, read its advertised path before proceeding."
916
- : "Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches a capability guide, read its advertised path before proceeding.";
1067
+ ? `Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches an available skill or capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction}`
1068
+ : `Use this workspaceId in all subsequent tool calls for this project. Follow loaded agentsFiles instructions. Read an availableAgentsFiles path before working under it. When a task matches a capability guide, read its advertised path before proceeding. ${workspaceContextInstruction} ${workspaceManagementInstruction}`;
917
1069
  const instruction = workspaceReused
918
1070
  ? includeBootstrapContext
919
1071
  ? [
920
1072
  `Workspace already exists as ${workspace.id} for this directory.`,
921
1073
  "Reuse this workspaceId for subsequent tool calls.",
922
1074
  "The complete project context is included because it has not yet been provided in this conversation or host context.",
1075
+ workspaceContextInstruction,
1076
+ workspaceManagementInstruction,
923
1077
  ].join("\n\n")
924
1078
  : [
925
1079
  `Workspace already open as ${workspace.id}.`,
926
1080
  "Reuse this workspaceId for subsequent tool calls. This is the same directory previously opened in this conversation.",
927
1081
  "Continue following the project instructions, nested instruction files, skills, capability guides, agent profiles, and diagnostics previously provided for this workspace. They remain active and are not repeated here.",
1082
+ workspaceContextInstruction,
1083
+ workspaceManagementInstruction,
928
1084
  ].join("\n\n")
929
1085
  : workspace.mode === "worktree"
930
1086
  ? "Use this workspaceId for subsequent tool calls. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for this isolated worktree."
@@ -998,6 +1154,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
998
1154
  worktrees: knownWorktrees,
999
1155
  staleWorkspaces,
1000
1156
  capabilityFingerprint,
1157
+ contextFingerprint,
1001
1158
  capabilityCatalog,
1002
1159
  agentsFiles: cardAgentsFiles,
1003
1160
  availableAgentsFiles: cardAvailableAgentsFiles,
@@ -1017,6 +1174,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1017
1174
  },
1018
1175
  },
1019
1176
  structuredContent: {
1177
+ action: "open",
1020
1178
  workspaceId: workspace.id,
1021
1179
  root: workspace.root,
1022
1180
  mode: workspace.mode,
@@ -1025,6 +1183,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1025
1183
  worktrees: knownWorktrees,
1026
1184
  staleWorkspaces,
1027
1185
  capabilityFingerprint,
1186
+ contextFingerprint,
1028
1187
  capabilityCatalog,
1029
1188
  ...(includeBootstrapContext
1030
1189
  ? {
@@ -1205,14 +1364,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1205
1364
  if (!commitMessage) {
1206
1365
  throw new Error(`Managed-worktree-backed workspace ${workspaceId} requires commitMessage when closing.`);
1207
1366
  }
1208
- const busyWorkspaceIds = workspaces
1209
- .workspaceIdsForPhysicalWorkspace(workspace)
1367
+ const physicalWorkspaceIds = workspaces.workspaceIdsForPhysicalWorkspace(workspace);
1368
+ const busyWorkspaceIds = physicalWorkspaceIds
1210
1369
  .filter((id) => processSessions.activeWorkspaceIds().has(id));
1211
1370
  if (busyWorkspaceIds.length > 0) {
1212
1371
  throw new Error(`Cannot close this worktree-backed workspace while logical workspace processes are still running or awaiting completion delivery: ${busyWorkspaceIds.join(", ")}.`);
1213
1372
  }
1214
1373
  const startedAt = performance.now();
1215
1374
  const closed = await workspaces.closeWorktree(workspaceId, commitMessage);
1375
+ await Promise.all(physicalWorkspaceIds.map((id) => reviewCheckpoints.releaseWorkspace(id)));
1216
1376
  const result = [
1217
1377
  `Closed managed-worktree-backed workspace ${workspaceId}.`,
1218
1378
  `Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
@@ -1252,6 +1412,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1252
1412
  throw new Error(`Workspace ${workspaceId} still owns a running process or an unconsumed process completion. Poll or consume it before closing this workspace.`);
1253
1413
  }
1254
1414
  workspaces.closeWorkspace(workspaceId);
1415
+ await reviewCheckpoints.releaseWorkspace(workspaceId);
1255
1416
  const result = `Closed checkout-backed workspace ${workspaceId}. Physical project files were not removed.`;
1256
1417
  return {
1257
1418
  content: [textBlock(result)],
@@ -1285,7 +1446,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1285
1446
  .optional()
1286
1447
  .describe("Maximum number of lines to read."),
1287
1448
  },
1288
- outputSchema: resultOutputSchema(),
1449
+ outputSchema: resultOutputSchema({
1450
+ agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
1451
+ }),
1289
1452
  ...toolWidgetDescriptorMeta(config, "read"),
1290
1453
  annotations: { readOnlyHint: true },
1291
1454
  }, async ({ workspaceId, ...input }, extra) => {
@@ -1298,6 +1461,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1298
1461
  operation: async () => {
1299
1462
  const startedAt = performance.now();
1300
1463
  const readPath = workspaces.resolveReadPath(workspace, input.path);
1464
+ const discoveredInstructions = (await workspaces.discoverPathInstructions(workspace, readPath.absolutePath)).filter((file) => file.path !== readPath.absolutePath);
1301
1465
  const response = await readFileTool({ ...input, path: readPath.absolutePath }, {
1302
1466
  cwd: workspace.root,
1303
1467
  root: workspace.root,
@@ -1312,6 +1476,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1312
1476
  return response;
1313
1477
  }
1314
1478
  workspaces.markReadPathLoaded(workspace, readPath);
1479
+ const discoveredInstructionContent = discoveredInstructions.length > 0
1480
+ ? textBlock(formatDiscoveredWorkspaceInstructions(discoveredInstructions, workspace.root))
1481
+ : undefined;
1482
+ const content = discoveredInstructionContent
1483
+ ? [discoveredInstructionContent, ...response.content]
1484
+ : response.content;
1315
1485
  const summary = {
1316
1486
  ...textSummary(response.content),
1317
1487
  offset: input.offset ?? 1,
@@ -1326,6 +1496,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1326
1496
  });
1327
1497
  return {
1328
1498
  ...response,
1499
+ content,
1329
1500
  _meta: {
1330
1501
  tool: toolNames.read,
1331
1502
  card: {
@@ -1336,7 +1507,15 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1336
1507
  },
1337
1508
  },
1338
1509
  structuredContent: {
1339
- result: contentText(response.content),
1510
+ result: contentText(content),
1511
+ ...(discoveredInstructions.length > 0
1512
+ ? {
1513
+ agentsFiles: discoveredInstructions.map((file) => ({
1514
+ path: formatAgentsPath(file.path, workspace.root),
1515
+ content: file.content,
1516
+ })),
1517
+ }
1518
+ : {}),
1340
1519
  },
1341
1520
  };
1342
1521
  },
@@ -1368,6 +1547,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1368
1547
  changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
1369
1548
  operation: async () => {
1370
1549
  const startedAt = performance.now();
1550
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [input.path]);
1371
1551
  const response = await writeFileTool(input, {
1372
1552
  cwd: workspace.root,
1373
1553
  root: workspace.root,
@@ -1450,6 +1630,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1450
1630
  changedPaths: (result) => toolResultIsError(result) ? [] : [input.path],
1451
1631
  operation: async () => {
1452
1632
  const startedAt = performance.now();
1633
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [input.path]);
1453
1634
  const response = await editFileTool(input, {
1454
1635
  cwd: workspace.root,
1455
1636
  root: workspace.root,
@@ -1525,6 +1706,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1525
1706
  operation: async () => {
1526
1707
  const startedAt = performance.now();
1527
1708
  try {
1709
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [path, newPath]);
1528
1710
  await renamePath({ path, newPath }, {
1529
1711
  cwd: workspace.root,
1530
1712
  allowedRoots: workspaces.fileToolRoots(workspace),
@@ -1596,6 +1778,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1596
1778
  operation: async () => {
1597
1779
  const startedAt = performance.now();
1598
1780
  try {
1781
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [path]);
1599
1782
  const deleted = await deletePath({ path, recursive }, {
1600
1783
  cwd: workspace.root,
1601
1784
  allowedRoots: workspaces.fileToolRoots(workspace),
@@ -1806,6 +1989,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1806
1989
  operation: async () => {
1807
1990
  const startedAt = performance.now();
1808
1991
  const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
1992
+ await assertWorkspaceInstructionsLoadedBeforeSideEffect(workspaces, workspace, [cwd]);
1809
1993
  const snapshot = await processSessions.start({
1810
1994
  workspaceId,
1811
1995
  command,
@@ -1909,7 +2093,9 @@ export function createServer(config = loadConfig(), options = {}) {
1909
2093
  host: config.host,
1910
2094
  ...(allowedHosts ? { allowedHosts } : {}),
1911
2095
  });
1912
- const transports = new McpTransportRegistry();
2096
+ const transports = new McpTransportRegistry({
2097
+ maxTransports: MAX_MCP_TRANSPORT_SESSIONS,
2098
+ });
1913
2099
  const mcpUrl = new URL("/mcp", config.publicBaseUrl);
1914
2100
  const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
1915
2101
  const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir);
@@ -1939,7 +2125,7 @@ export function createServer(config = loadConfig(), options = {}) {
1939
2125
  continue;
1940
2126
  }
1941
2127
  closedCount += 1;
1942
- if (reason === "idle_timeout") {
2128
+ if (reason !== "server_shutdown") {
1943
2129
  logEvent(config.logging, "debug", "mcp_transport_session_closed", {
1944
2130
  reason,
1945
2131
  transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
@@ -1953,12 +2139,31 @@ export function createServer(config = loadConfig(), options = {}) {
1953
2139
  });
1954
2140
  }
1955
2141
  };
2142
+ const logRuntimeResources = () => {
2143
+ const memory = process.memoryUsage();
2144
+ const processStats = processSessions.stats();
2145
+ logEvent(config.logging, "debug", "runtime_resources", {
2146
+ rssBytes: memory.rss,
2147
+ heapUsedBytes: memory.heapUsed,
2148
+ heapTotalBytes: memory.heapTotal,
2149
+ externalBytes: memory.external,
2150
+ arrayBuffersBytes: memory.arrayBuffers,
2151
+ mcpTransports: transports.size,
2152
+ processesTotal: processStats.total,
2153
+ processesRunning: processStats.running,
2154
+ processesCompleted: processStats.completed,
2155
+ cachedWorkspaces: workspaces.cachedWorkspaceCount,
2156
+ reviewStates: reviewCheckpoints.stateCount,
2157
+ });
2158
+ };
1956
2159
  const transportCleanupTimer = setInterval(() => {
1957
2160
  void transports
1958
2161
  .closeIdle(MCP_TRANSPORT_IDLE_TIMEOUT_MS)
1959
- .then((results) => logTransportCloseResults("idle_timeout", results));
2162
+ .then((results) => logTransportCloseResults("idle_timeout", results))
2163
+ .finally(logRuntimeResources);
1960
2164
  }, MCP_TRANSPORT_CLEANUP_INTERVAL_MS);
1961
2165
  transportCleanupTimer.unref();
2166
+ logRuntimeResources();
1962
2167
  if (config.logging.trustProxy) {
1963
2168
  app.set("trust proxy", 1);
1964
2169
  }
@@ -2050,8 +2255,11 @@ export function createServer(config = loadConfig(), options = {}) {
2050
2255
  transport = new StreamableHTTPServerTransport({
2051
2256
  sessionIdGenerator: () => randomUUID(),
2052
2257
  onsessioninitialized: (newTransportSessionId) => {
2053
- if (transport)
2054
- transports.register(newTransportSessionId, transport);
2258
+ if (transport) {
2259
+ void transports
2260
+ .register(newTransportSessionId, transport)
2261
+ .then((results) => logTransportCloseResults("capacity_limit", results));
2262
+ }
2055
2263
  logEvent(config.logging, "debug", "mcp_transport_session_created", {
2056
2264
  requestId,
2057
2265
  transportSessionIdPrefix: transportSessionIdPrefix(newTransportSessionId),