@akira-tl/forgerelay 0.2.3 → 0.2.5

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
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { access, realpath } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
7
7
  import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
8
8
  import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";
9
9
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
@@ -21,12 +21,13 @@ import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
21
21
  import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
22
22
  import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
23
23
  import { logEvent, requestIp, requestPath, commandPreview, sessionIdPrefix, workspaceLogLabel, } from "./logger.js";
24
- import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, runShellTool, writeFileTool, } from "./pi-tools.js";
24
+ import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, writeFileTool, } from "./pi-tools.js";
25
25
  import { SingleUserOAuthProvider } from "./oauth-provider.js";
26
26
  import { McpSessionRegistry, } from "./mcp-sessions.js";
27
- import { ProcessSessionManager } from "./process-sessions.js";
27
+ import { ProcessSessionManager, } from "./process-sessions.js";
28
28
  import { createReviewCheckpointManager } from "./review-checkpoints.js";
29
29
  import { openAiConversationScopeId } from "./request-meta.js";
30
+ import { readWorkspaceAppManifestEntry, resolveWorkspaceAppIdentity, WORKSPACE_APP_LEGACY_URI, WORKSPACE_APP_URI_TEMPLATE, } from "./mcp-app-template.js";
30
31
  import { shutdownHttpServer } from "./server-shutdown.js";
31
32
  import { formatPathForPrompt } from "./skills.js";
32
33
  import { createWorkspaceStore } from "./workspace-store.js";
@@ -38,8 +39,6 @@ import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvail
38
39
  const MCP_SESSION_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
39
40
  const FORGERELAY_VERSION = readForgeRelayVersion();
40
41
  const MCP_SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
41
- const WORKSPACE_APP_URI = "ui://forgerelay/workspace-app.html";
42
- const WORKSPACE_APP_MANIFEST_ENTRY = "workspace-app.html";
43
42
  const WRITE_TOOL_ANNOTATIONS = {
44
43
  readOnlyHint: false,
45
44
  destructiveHint: true,
@@ -71,20 +70,21 @@ function shouldAttachWidget(mode, kind) {
71
70
  function toolWidgetDescriptorMeta(config, kind) {
72
71
  if (!shouldAttachWidget(config.widgets, kind))
73
72
  return { _meta: {} };
73
+ const resourceUri = currentWorkspaceAppIdentity().uri;
74
74
  return {
75
75
  _meta: {
76
76
  ui: {
77
- resourceUri: WORKSPACE_APP_URI,
78
- visibility: ["model"],
77
+ resourceUri,
78
+ visibility: ["model", "app"],
79
79
  },
80
+ "openai/outputTemplate": resourceUri,
80
81
  },
81
82
  };
82
83
  }
83
- function workspaceLogContext(workspace, sessionId) {
84
+ function workspaceLogContext(workspace, _sessionId) {
84
85
  return {
85
86
  workspaceId: workspace.id,
86
87
  workspace: workspaceLogLabel(workspace.root, workspace.id),
87
- session: sessionIdPrefix(sessionId),
88
88
  };
89
89
  }
90
90
  function formatVisibleAgent(agent) {
@@ -161,6 +161,23 @@ function requestLogFields(req, config) {
161
161
  contentLength: req.header("content-length"),
162
162
  };
163
163
  }
164
+ function mcpRequestDebugFields(body) {
165
+ if (!body || typeof body !== "object" || Array.isArray(body))
166
+ return {};
167
+ const request = body;
168
+ const rpcMethod = typeof request.method === "string" ? request.method : undefined;
169
+ const params = request.params && typeof request.params === "object" && !Array.isArray(request.params)
170
+ ? request.params
171
+ : undefined;
172
+ let rpcTarget;
173
+ if (rpcMethod === "resources/read" && typeof params?.uri === "string") {
174
+ rpcTarget = params.uri;
175
+ }
176
+ else if (rpcMethod === "tools/call" && typeof params?.name === "string") {
177
+ rpcTarget = params.name;
178
+ }
179
+ return { rpcMethod, rpcTarget };
180
+ }
164
181
  function logToolCall(config, fields) {
165
182
  if (!config.logging.toolCalls)
166
183
  return;
@@ -247,16 +264,20 @@ function assetBaseUrl(config) {
247
264
  function uiManifestUrl() {
248
265
  return new URL("../dist/ui/.vite/manifest.json", import.meta.url);
249
266
  }
250
- function readWorkspaceAppManifest() {
251
- return JSON.parse(readFileSync(uiManifestUrl(), "utf8"));
267
+ function uiBuildDirectoryUrl() {
268
+ return new URL("../dist/ui/", import.meta.url);
269
+ }
270
+ let cachedWorkspaceAppIdentity;
271
+ function currentWorkspaceAppIdentity() {
272
+ cachedWorkspaceAppIdentity ??= resolveWorkspaceAppIdentity({
273
+ manifestUrl: uiManifestUrl(),
274
+ buildDirectoryUrl: uiBuildDirectoryUrl(),
275
+ fallbackRevision: FORGERELAY_VERSION,
276
+ });
277
+ return cachedWorkspaceAppIdentity;
252
278
  }
253
279
  function getWorkspaceAppManifestEntry() {
254
- const manifest = readWorkspaceAppManifest();
255
- const entry = manifest[WORKSPACE_APP_MANIFEST_ENTRY];
256
- if (!entry?.file) {
257
- throw new Error(`Missing ${WORKSPACE_APP_MANIFEST_ENTRY} in UI manifest.`);
258
- }
259
- return entry;
280
+ return readWorkspaceAppManifestEntry(uiManifestUrl());
260
281
  }
261
282
  function assetUrl(baseUrl, assetPath) {
262
283
  return `${baseUrl}/${assetPath.replace(/^\/+/, "")}`;
@@ -306,6 +327,51 @@ async function assertWorkspaceAppAssets() {
306
327
  await access(candidate);
307
328
  }
308
329
  }
330
+ function workspaceAppCompatibilityKind(requestedUri, currentUri) {
331
+ if (requestedUri === currentUri)
332
+ return "current";
333
+ if (requestedUri === WORKSPACE_APP_LEGACY_URI)
334
+ return "legacy";
335
+ return "historical";
336
+ }
337
+ async function readWorkspaceAppResource(config, requestedUri, transportSessionId) {
338
+ const currentUri = currentWorkspaceAppIdentity().uri;
339
+ const compatibility = workspaceAppCompatibilityKind(requestedUri, currentUri);
340
+ try {
341
+ await assertWorkspaceAppAssets();
342
+ const result = {
343
+ contents: [
344
+ {
345
+ uri: requestedUri,
346
+ mimeType: RESOURCE_MIME_TYPE,
347
+ text: workspaceAppHtml(config),
348
+ _meta: {
349
+ ui: {
350
+ csp: appCsp(config),
351
+ },
352
+ },
353
+ },
354
+ ],
355
+ };
356
+ logEvent(config.logging, "debug", "mcp_app_template_read", {
357
+ requestedUri,
358
+ currentUri,
359
+ compatibility,
360
+ sessionIdPrefix: sessionIdPrefix(transportSessionId),
361
+ });
362
+ return result;
363
+ }
364
+ catch (error) {
365
+ logEvent(config.logging, "warn", "mcp_app_template_read_failed", {
366
+ requestedUri,
367
+ currentUri,
368
+ compatibility,
369
+ error: error instanceof Error ? error.message : String(error),
370
+ sessionIdPrefix: sessionIdPrefix(transportSessionId),
371
+ });
372
+ throw error;
373
+ }
374
+ }
309
375
  function processResult(snapshot) {
310
376
  const status = snapshot.running
311
377
  ? `Process running with session ID ${snapshot.sessionId}.`
@@ -314,6 +380,45 @@ function processResult(snapshot) {
314
380
  : `Process exited with code ${snapshot.exitCode ?? "unknown"}.`;
315
381
  return snapshot.output ? `${snapshot.output.replace(/\n$/, "")}\n${status}` : status;
316
382
  }
383
+ function completedProcessResult(snapshot) {
384
+ const status = snapshot.signal
385
+ ? `Background process ${snapshot.sessionId} exited after signal ${snapshot.signal}.`
386
+ : `Background process ${snapshot.sessionId} exited with code ${snapshot.exitCode ?? "unknown"}.`;
387
+ const command = `Command: ${snapshot.command}`;
388
+ const output = snapshot.output ? `\n${snapshot.output.replace(/\n$/, "")}` : "";
389
+ return `${status}\n${command}${output}`;
390
+ }
391
+ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
392
+ if (result instanceof Error) {
393
+ const completed = processSessions.takeCompleted(workspaceId);
394
+ if (completed.length > 0) {
395
+ result.message = [
396
+ result.message,
397
+ ...completed.map((snapshot) => completedProcessResult(snapshot)),
398
+ ].join("\n\n");
399
+ }
400
+ return result;
401
+ }
402
+ if (typeof result !== "object" || result === null)
403
+ return result;
404
+ const content = result.content;
405
+ if (!Array.isArray(content))
406
+ return result;
407
+ const structured = result.structuredContent;
408
+ const currentSessionId = structured?.running === true && typeof structured.sessionId === "number"
409
+ ? structured.sessionId
410
+ : undefined;
411
+ const completed = processSessions.takeCompleted(workspaceId, undefined, currentSessionId);
412
+ if (completed.length === 0)
413
+ return result;
414
+ return {
415
+ ...result,
416
+ content: [
417
+ ...content,
418
+ ...completed.map((snapshot) => textBlock(completedProcessResult(snapshot))),
419
+ ],
420
+ };
421
+ }
317
422
  function processOutputSchema() {
318
423
  return resultOutputSchema({
319
424
  sessionId: z.number().optional(),
@@ -367,86 +472,89 @@ function workspaceHookInvocation(workspace) {
367
472
  function toolResultIsError(result) {
368
473
  return typeof result === "object" && result !== null && result.isError === true;
369
474
  }
370
- function registerCodexProcessTools(server, config, workspaces, processSessions, hooks) {
371
- registerAppTool(server, "exec_command", {
372
- title: "Execute command",
373
- description: `Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a sessionId 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.`,
374
- inputSchema: {
375
- workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
376
- cmd: z.string().min(1).describe("Shell command to execute."),
377
- tty: z
378
- .boolean()
379
- .optional()
380
- .describe("Allocate a pseudo-terminal for interactive commands. Defaults to false."),
381
- columns: z.number().int().min(1).max(1_000).optional().describe("Initial PTY width. Defaults to 80."),
382
- rows: z.number().int().min(1).max(1_000).optional().describe("Initial PTY height. Defaults to 24."),
383
- workingDirectory: z
384
- .string()
385
- .optional()
386
- .describe("Working directory relative to the workspace root. Defaults to the workspace root."),
387
- yieldTimeMs: z
388
- .number()
389
- .int()
390
- .min(0)
391
- .max(30_000)
392
- .optional()
393
- .describe("Milliseconds to wait before returning a running session. Defaults to 10000."),
394
- maxOutputTokens: z
395
- .number()
396
- .int()
397
- .positive()
398
- .max(100_000)
399
- .optional()
400
- .describe("Approximate output token budget. Defaults to 10000."),
401
- },
402
- outputSchema: processOutputSchema(),
403
- ...toolWidgetDescriptorMeta(config, "shell"),
404
- annotations: SHELL_TOOL_ANNOTATIONS,
405
- }, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }, extra) => {
406
- const workspace = workspaces.getWorkspace(workspaceId);
407
- return runToolWithHooks(hooks, {
408
- tool: "exec_command",
409
- invocation: workspaceHookInvocation(workspace),
410
- payload: { command: cmd, workingDirectory: workingDirectory ?? "." },
411
- operation: async () => {
412
- const startedAt = performance.now();
413
- const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
414
- const snapshot = await processSessions.start({
415
- workspaceId,
416
- command: cmd,
417
- cwd,
418
- workspaceRoot: workspace.root,
419
- tty,
420
- columns,
421
- rows,
422
- yieldTimeMs,
423
- maxOutputTokens,
424
- });
425
- logToolCall(config, {
426
- tool: "exec_command",
427
- ...workspaceLogContext(workspace, extra.sessionId),
428
- workingDirectory: workingDirectory ?? ".",
429
- command: cmd,
430
- commandLength: cmd.length,
431
- exitCode: snapshot.exitCode,
432
- running: snapshot.running,
433
- processSessionId: snapshot.sessionId,
434
- success: snapshot.running || snapshot.exitCode === 0,
435
- durationMs: Math.round(performance.now() - startedAt),
436
- });
437
- return processToolResponse("exec_command", workspaceId, snapshot, {
438
- command: cmd,
439
- workingDirectory: workingDirectory ?? ".",
440
- running: snapshot.running,
441
- exitCode: snapshot.exitCode,
442
- wallTimeMs: snapshot.wallTimeMs,
443
- });
475
+ function registerProcessTools(server, config, workspaces, processSessions, hooks) {
476
+ if (config.toolMode === "codex") {
477
+ registerAppTool(server, "exec_command", {
478
+ title: "Execute command",
479
+ description: `Run a command inside an open workspace. Returns its result when it exits during the yield window, otherwise returns a sessionId 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.`,
480
+ inputSchema: {
481
+ workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
482
+ cmd: z.string().min(1).describe("Shell command to execute."),
483
+ tty: z
484
+ .boolean()
485
+ .optional()
486
+ .describe("Allocate a pseudo-terminal for interactive commands. Defaults to false."),
487
+ columns: z.number().int().min(1).max(1_000).optional().describe("Initial PTY width. Defaults to 80."),
488
+ rows: z.number().int().min(1).max(1_000).optional().describe("Initial PTY height. Defaults to 24."),
489
+ workingDirectory: z
490
+ .string()
491
+ .optional()
492
+ .describe("Working directory relative to the workspace root. Defaults to the workspace root."),
493
+ yieldTimeMs: z
494
+ .number()
495
+ .int()
496
+ .min(0)
497
+ .max(30_000)
498
+ .optional()
499
+ .describe("Milliseconds to wait before returning a running session. Defaults to 10000."),
500
+ maxOutputTokens: z
501
+ .number()
502
+ .int()
503
+ .positive()
504
+ .max(100_000)
505
+ .optional()
506
+ .describe("Approximate output token budget. Defaults to 10000."),
444
507
  },
508
+ outputSchema: processOutputSchema(),
509
+ ...toolWidgetDescriptorMeta(config, "shell"),
510
+ annotations: SHELL_TOOL_ANNOTATIONS,
511
+ }, async ({ workspaceId, cmd, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens }, extra) => {
512
+ const workspace = workspaces.getWorkspace(workspaceId);
513
+ return runToolWithHooks(hooks, {
514
+ tool: "exec_command",
515
+ invocation: workspaceHookInvocation(workspace),
516
+ payload: { command: cmd, workingDirectory: workingDirectory ?? "." },
517
+ operation: async () => {
518
+ const startedAt = performance.now();
519
+ const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
520
+ const snapshot = await processSessions.start({
521
+ workspaceId,
522
+ command: cmd,
523
+ cwd,
524
+ workspaceRoot: workspace.root,
525
+ tty,
526
+ columns,
527
+ rows,
528
+ yieldTimeMs,
529
+ maxOutputTokens,
530
+ codexCi: true,
531
+ });
532
+ logToolCall(config, {
533
+ tool: "exec_command",
534
+ ...workspaceLogContext(workspace, extra.sessionId),
535
+ workingDirectory: workingDirectory ?? ".",
536
+ command: cmd,
537
+ commandLength: cmd.length,
538
+ exitCode: snapshot.exitCode,
539
+ running: snapshot.running,
540
+ processSessionId: snapshot.sessionId,
541
+ success: snapshot.running || snapshot.exitCode === 0,
542
+ durationMs: Math.round(performance.now() - startedAt),
543
+ });
544
+ return processToolResponse("exec_command", workspaceId, snapshot, {
545
+ command: cmd,
546
+ workingDirectory: workingDirectory ?? ".",
547
+ running: snapshot.running,
548
+ exitCode: snapshot.exitCode,
549
+ wallTimeMs: snapshot.wallTimeMs,
550
+ });
551
+ },
552
+ });
445
553
  });
446
- });
554
+ }
447
555
  registerAppTool(server, "write_stdin", {
448
556
  title: "Write to process",
449
- description: "Poll or write characters to a process returned by exec_command. Omit chars or pass an empty string to poll. Pass \\u0003 to send Ctrl-C.",
557
+ description: "Poll or write characters to a running process returned by bash or exec_command. Omit chars or pass an empty string to poll. Waiting never kills the process; pass \\u0003 to explicitly send Ctrl-C.",
450
558
  inputSchema: {
451
559
  workspaceId: z.string().describe("Workspace identifier used to start the process."),
452
560
  sessionId: z.number().describe("Process session identifier returned by exec_command."),
@@ -457,9 +565,9 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
457
565
  .number()
458
566
  .int()
459
567
  .min(0)
460
- .max(30_000)
568
+ .max(300_000)
461
569
  .optional()
462
- .describe("Milliseconds to wait for process output or completion. Defaults to 10000."),
570
+ .describe("Milliseconds to keep waiting before returning again, max 300000. Polling defaults to 5000; interaction defaults to 250."),
463
571
  maxOutputTokens: z
464
572
  .number()
465
573
  .int()
@@ -515,7 +623,7 @@ function registerCodexProcessTools(server, config, workspaces, processSessions,
515
623
  }
516
624
  export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
517
625
  const toolDescriptions = buildToolDescriptions(config);
518
- const hooks = new HookRunner(config.hooks, config.logging);
626
+ const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result));
519
627
  const server = new McpServer({
520
628
  name: "forgerelay",
521
629
  title: "ForgeRelay",
@@ -526,37 +634,33 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
526
634
  artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
527
635
  }),
528
636
  });
529
- registerAppResource(server, "ForgeRelay Diff Card", WORKSPACE_APP_URI, {
637
+ const currentWorkspaceAppUri = currentWorkspaceAppIdentity().uri;
638
+ const workspaceAppResourceMetadata = {
530
639
  description: "Interactive card for viewing ForgeRelay file diffs.",
531
640
  _meta: {
532
641
  ui: {
533
642
  csp: appCsp(config),
534
643
  },
535
644
  },
536
- }, async () => {
537
- await assertWorkspaceAppAssets();
538
- return {
539
- contents: [
540
- {
541
- uri: WORKSPACE_APP_URI,
542
- mimeType: RESOURCE_MIME_TYPE,
543
- text: workspaceAppHtml(config),
544
- _meta: {
545
- ui: {
546
- csp: appCsp(config),
547
- },
548
- },
549
- },
550
- ],
551
- };
552
- });
645
+ };
646
+ registerAppResource(server, "ForgeRelay Diff Card", currentWorkspaceAppUri, workspaceAppResourceMetadata, async (uri, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
647
+ registerAppResource(server, "ForgeRelay Diff Card legacy", WORKSPACE_APP_LEGACY_URI, workspaceAppResourceMetadata, async (uri, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
648
+ server.registerResource("ForgeRelay Diff Card compatibility", new ResourceTemplate(WORKSPACE_APP_URI_TEMPLATE, { list: undefined }), {
649
+ ...workspaceAppResourceMetadata,
650
+ mimeType: RESOURCE_MIME_TYPE,
651
+ }, async (uri, _variables, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
553
652
  registerAppTool(server, "open_workspace", {
554
653
  title: "Open workspace",
555
- description: "Open a local project directory as a coding workspace. The same directory reuses the same active workspaceId across requests. Default to checkout mode and only use mode=\"worktree\" when the user explicitly asks for isolated or parallel work. Managed worktrees use dedicated forgerelay/* branches and can later be safely closed into their original target branch with close_worktree. Existing managed worktree paths can also be reopened directly.",
654
+ description: "Open or resume a local coding workspace. A conversation keeps a stable workspaceId for a project, while different conversations normally receive different logical workspaceIds that may point at the same physical checkout or worktree. Pass workspaceId to explicitly resume an existing logical workspace in this conversation. Default to checkout mode and only use mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. Workspaces idle for more than two days are reported for user-directed cleanup or resumption.",
556
655
  inputSchema: {
557
656
  path: z
558
657
  .string()
559
- .describe("Absolute path, or a leading-tilde home path such as ~/project, to a local project directory inside an allowed root. With mode=\"worktree\", this may also be a managed worktree path previously returned by ForgeRelay."),
658
+ .optional()
659
+ .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."),
660
+ workspaceId: z
661
+ .string()
662
+ .optional()
663
+ .describe("Existing logical workspace ID to resume in this conversation. When supplied, ForgeRelay resumes that workspace rather than allocating another ID."),
560
664
  mode: z
561
665
  .enum(["checkout", "worktree"])
562
666
  .optional()
@@ -568,7 +672,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
568
672
  newWorktree: z
569
673
  .boolean()
570
674
  .optional()
571
- .describe("When true, create another isolated managed worktree instead of reusing the existing worktree for this project and baseRef. Use only when the user explicitly requests a separate worktree."),
675
+ .describe("When true, create another isolated managed Git worktree instead of reusing the existing physical worktree. Use only when the user explicitly requests separate Git isolation."),
676
+ newWorkspace: z
677
+ .boolean()
678
+ .optional()
679
+ .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."),
572
680
  },
573
681
  outputSchema: {
574
682
  workspaceId: z.string(),
@@ -597,6 +705,16 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
597
705
  managed: z.boolean(),
598
706
  current: z.boolean(),
599
707
  })),
708
+ staleWorkspaces: z.array(z.object({
709
+ workspaceId: z.string(),
710
+ root: z.string(),
711
+ mode: z.enum(["checkout", "worktree"]),
712
+ lastUsedAt: z.string(),
713
+ idleMs: z.number().nonnegative(),
714
+ branch: z.string().optional(),
715
+ targetBranch: z.string().optional(),
716
+ managed: z.boolean(),
717
+ })),
600
718
  agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
601
719
  availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
602
720
  skills: z.array(workspaceSkillOutputSchema).optional(),
@@ -612,10 +730,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
612
730
  idempotentHint: false,
613
731
  openWorldHint: false,
614
732
  },
615
- }, async ({ path, mode, baseRef, newWorktree }, { _meta, sessionId }) => {
733
+ }, async ({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace }, { _meta, sessionId }) => {
616
734
  const startedAt = performance.now();
617
- const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace({ path, mode, baseRef, newWorktree }, { conversationScopeId: openAiConversationScopeId(_meta) });
735
+ const { workspace, agentsFiles, availableAgentsFiles, hookReports, workspaceReused, includeBootstrapContext, } = await workspaces.openWorkspace({ path, workspaceId, mode, baseRef, newWorktree, newWorkspace }, {
736
+ conversationScopeId: openAiConversationScopeId(_meta),
737
+ protectedWorkspaceIds: processSessions.activeWorkspaceIds(),
738
+ });
618
739
  const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
740
+ const staleWorkspaces = await workspaces.listStaleWorkspaces(workspace);
619
741
  if (config.widgets === "changes") {
620
742
  await reviewCheckpoints.initializeWorkspace({
621
743
  workspaceId: workspace.id,
@@ -701,6 +823,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
701
823
  knownWorktrees.length > 0
702
824
  ? `Known worktrees: ${knownWorktrees.map((worktree) => `${worktree.path} [${worktree.workspaceId}]${worktree.branch ? ` branch=${worktree.branch}` : ""}${worktree.targetBranch ? ` target=${worktree.targetBranch}` : ""}${worktree.current ? " (current)" : ""}`).join(", ")}`
703
825
  : undefined,
826
+ staleWorkspaces.length > 0
827
+ ? `Idle logical workspaces for this same physical workspace (>2 days): ${staleWorkspaces.map((stale) => `${stale.workspaceId} last-used=${stale.lastUsedAt}`).join(", ")}. Tell the user these are available to resume or explicitly close; do not clean them up automatically.`
828
+ : undefined,
704
829
  instruction,
705
830
  ].filter(Boolean).join("\n"),
706
831
  },
@@ -712,7 +837,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
712
837
  success: true,
713
838
  durationMs: Math.round(performance.now() - startedAt),
714
839
  });
715
- return attachHookReports({
840
+ return hooks.decorateResult(workspace.id, attachHookReports({
716
841
  content: resultContent,
717
842
  _meta: {
718
843
  tool: "open_workspace",
@@ -726,6 +851,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
726
851
  sourceRoot: workspace.sourceRoot,
727
852
  worktree: workspace.worktree,
728
853
  worktrees: knownWorktrees,
854
+ staleWorkspaces,
729
855
  agentsFiles: cardAgentsFiles,
730
856
  availableAgentsFiles: cardAvailableAgentsFiles,
731
857
  skills: cardSkills,
@@ -749,6 +875,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
749
875
  sourceRoot: workspace.sourceRoot,
750
876
  worktree: workspace.worktree,
751
877
  worktrees: knownWorktrees,
878
+ staleWorkspaces,
752
879
  ...(includeBootstrapContext
753
880
  ? {
754
881
  agentsFiles: loadedAgentsFiles,
@@ -761,7 +888,35 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
761
888
  : {}),
762
889
  instruction,
763
890
  },
764
- }, hookReports);
891
+ }, hookReports));
892
+ });
893
+ registerAppTool(server, toolNames.closeWorkspace, {
894
+ title: "Close logical workspace",
895
+ description: "Release one logical ForgeRelay workspaceId after the user explicitly chooses to clean it up. This never deletes checkout files. A worktree handle can be released only when another logical handle still anchors the same physical worktree; use close_worktree to finalize and remove the last managed worktree. Running or unconsumed background processes prevent closure.",
896
+ inputSchema: {
897
+ workspaceId: z.string().describe("Logical workspace ID to release."),
898
+ },
899
+ outputSchema: resultOutputSchema({ workspaceId: z.string() }),
900
+ _meta: {},
901
+ annotations: WRITE_TOOL_ANNOTATIONS,
902
+ }, async ({ workspaceId }) => {
903
+ const workspace = workspaces.getWorkspace(workspaceId);
904
+ return runToolWithHooks(hooks, {
905
+ tool: toolNames.closeWorkspace,
906
+ invocation: workspaceHookInvocation(workspace),
907
+ payload: { workspaceId },
908
+ operation: async () => {
909
+ if (processSessions.activeWorkspaceIds().has(workspaceId)) {
910
+ throw new Error(`Workspace ${workspaceId} still owns a running process or an unconsumed process completion. Poll or consume it before closing this workspace.`);
911
+ }
912
+ workspaces.closeWorkspace(workspaceId);
913
+ const result = `Closed logical workspace ${workspaceId}. Physical project files were not removed.`;
914
+ return {
915
+ content: [textBlock(result)],
916
+ structuredContent: { result, workspaceId },
917
+ };
918
+ },
919
+ });
765
920
  });
766
921
  registerAppTool(server, toolNames.closeWorktree, {
767
922
  title: "Close worktree",
@@ -795,6 +950,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
795
950
  payload: { commitMessage },
796
951
  afterCwd: (response) => response.structuredContent.sourceRoot,
797
952
  operation: async () => {
953
+ const busyWorkspaceIds = workspaces
954
+ .workspaceIdsForPhysicalWorkspace(workspace)
955
+ .filter((id) => processSessions.activeWorkspaceIds().has(id));
956
+ if (busyWorkspaceIds.length > 0) {
957
+ throw new Error(`Cannot close this worktree while logical workspace processes are still running or awaiting completion delivery: ${busyWorkspaceIds.join(", ")}.`);
958
+ }
798
959
  const startedAt = performance.now();
799
960
  const closed = await workspaces.closeWorktree(workspaceId, commitMessage);
800
961
  const result = [
@@ -840,7 +1001,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
840
1001
  path: z
841
1002
  .string()
842
1003
  .describe(config.skillsEnabled
843
- ? "File path to read, relative to the workspace root or absolute inside the OS temp directory. May also be an advertised skill path from open_workspace skills."
1004
+ ? "File path to read, relative to the workspace root or absolute inside the OS temp directory. May also be an advertised skill path from open_workspace skills, including a ~/... home-relative path."
844
1005
  : "File path to read, relative to the workspace root or absolute inside the OS temp directory."),
845
1006
  offset: z
846
1007
  .number()
@@ -1554,80 +1715,57 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1554
1715
  .string()
1555
1716
  .optional()
1556
1717
  .describe("Optional working directory relative to the workspace root. Defaults to the workspace root."),
1557
- timeout: z
1558
- .number()
1559
- .positive()
1560
- .max(300)
1561
- .optional()
1562
- .describe("Timeout in seconds. Defaults to 30, max 300."),
1563
1718
  },
1564
- outputSchema: resultOutputSchema(),
1719
+ outputSchema: processOutputSchema(),
1565
1720
  ...toolWidgetDescriptorMeta(config, "shell"),
1566
1721
  annotations: SHELL_TOOL_ANNOTATIONS,
1567
- }, async ({ workspaceId, workingDirectory, ...input }, extra) => {
1722
+ }, async ({ workspaceId, command, workingDirectory }, extra) => {
1568
1723
  const workspace = workspaces.getWorkspace(workspaceId);
1569
1724
  return runToolWithHooks(hooks, {
1570
1725
  tool: toolNames.shell,
1571
1726
  invocation: workspaceHookInvocation(workspace),
1572
1727
  payload: {
1573
- command: input.command,
1728
+ command,
1574
1729
  workingDirectory: workingDirectory ?? ".",
1575
- timeoutSeconds: input.timeout,
1576
1730
  },
1577
1731
  isFailure: toolResultIsError,
1578
1732
  operation: async () => {
1579
1733
  const startedAt = performance.now();
1580
1734
  const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
1581
- const response = await runShellTool(input, {
1735
+ const snapshot = await processSessions.start({
1736
+ workspaceId,
1737
+ command,
1582
1738
  cwd,
1583
- root: workspace.root,
1739
+ workspaceRoot: workspace.root,
1740
+ yieldTimeMs: 300_000,
1584
1741
  });
1585
- if (response.isError) {
1586
- logFailedToolResponse(config, {
1587
- tool: toolNames.shell,
1588
- ...workspaceLogContext(workspace, extra.sessionId),
1589
- workingDirectory: workingDirectory ?? ".",
1590
- command: input.command,
1591
- commandLength: input.command.length,
1592
- }, response.content, startedAt);
1593
- return response;
1594
- }
1595
- const summary = {
1596
- command: input.command,
1597
- workingDirectory: workingDirectory ?? ".",
1598
- ...textSummary(response.content),
1599
- };
1600
1742
  logToolCall(config, {
1601
1743
  tool: toolNames.shell,
1602
1744
  ...workspaceLogContext(workspace, extra.sessionId),
1603
1745
  workingDirectory: workingDirectory ?? ".",
1604
- command: input.command,
1605
- commandLength: input.command.length,
1606
- success: true,
1746
+ command,
1747
+ commandLength: command.length,
1748
+ exitCode: snapshot.exitCode,
1749
+ running: snapshot.running,
1750
+ processSessionId: snapshot.sessionId,
1751
+ success: snapshot.running || (snapshot.exitCode === 0 && !snapshot.signal),
1607
1752
  durationMs: Math.round(performance.now() - startedAt),
1608
1753
  });
1609
- return {
1610
- ...response,
1611
- _meta: {
1612
- tool: toolNames.shell,
1613
- card: {
1614
- workspaceId,
1615
- path: workingDirectory,
1616
- summary,
1617
- payload: { content: response.content },
1618
- },
1619
- },
1620
- structuredContent: {
1621
- result: contentText(response.content),
1622
- },
1623
- };
1754
+ const response = processToolResponse(toolNames.shell, workspaceId, snapshot, {
1755
+ command,
1756
+ workingDirectory: workingDirectory ?? ".",
1757
+ running: snapshot.running,
1758
+ exitCode: snapshot.exitCode,
1759
+ wallTimeMs: snapshot.wallTimeMs,
1760
+ });
1761
+ return !snapshot.running && (snapshot.signal || snapshot.exitCode !== 0)
1762
+ ? { ...response, isError: true }
1763
+ : response;
1624
1764
  },
1625
1765
  });
1626
1766
  });
1627
1767
  }
1628
- if (config.toolMode === "codex") {
1629
- registerCodexProcessTools(server, config, workspaces, processSessions, hooks);
1630
- }
1768
+ registerProcessTools(server, config, workspaces, processSessions, hooks);
1631
1769
  if (config.artifactsEnabled && isArtifactDownloadSupportedPlatform()) {
1632
1770
  registerArtifactTools(server, {
1633
1771
  config,
@@ -1665,6 +1803,7 @@ export function createServer(config = loadConfig(), options = {}) {
1665
1803
  ? getLocalAgentProviderAvailabilitySnapshot()
1666
1804
  : [];
1667
1805
  const logSessionCloseResults = (reason, results) => {
1806
+ let closedCount = 0;
1668
1807
  for (const result of results) {
1669
1808
  if (result.error) {
1670
1809
  logEvent(config.logging, "warn", "mcp_session_close_failed", {
@@ -1676,9 +1815,18 @@ export function createServer(config = loadConfig(), options = {}) {
1676
1815
  });
1677
1816
  continue;
1678
1817
  }
1679
- logEvent(config.logging, "info", "mcp_session_closed", {
1818
+ closedCount += 1;
1819
+ if (reason === "idle_timeout") {
1820
+ logEvent(config.logging, "debug", "mcp_session_closed", {
1821
+ reason,
1822
+ sessionIdPrefix: sessionIdPrefix(result.sessionId),
1823
+ });
1824
+ }
1825
+ }
1826
+ if (reason === "server_shutdown" && closedCount > 0) {
1827
+ logEvent(config.logging, "debug", "mcp_sessions_closed", {
1680
1828
  reason,
1681
- sessionIdPrefix: sessionIdPrefix(result.sessionId),
1829
+ count: closedCount,
1682
1830
  });
1683
1831
  }
1684
1832
  };
@@ -1760,10 +1908,11 @@ export function createServer(config = loadConfig(), options = {}) {
1760
1908
  }
1761
1909
  logEvent(config.logging, "debug", "mcp_request", {
1762
1910
  requestId,
1763
- method: req.method,
1911
+ httpMethod: req.method,
1764
1912
  sessionIdPresent: Boolean(sessionId),
1765
1913
  sessionIdPrefix: sessionIdPrefix(sessionId),
1766
1914
  isInitialize: initializeRequest,
1915
+ ...mcpRequestDebugFields(req.body),
1767
1916
  });
1768
1917
  try {
1769
1918
  let transport;
@@ -1780,7 +1929,7 @@ export function createServer(config = loadConfig(), options = {}) {
1780
1929
  onsessioninitialized: (newSessionId) => {
1781
1930
  if (transport)
1782
1931
  transports.register(newSessionId, transport);
1783
- logEvent(config.logging, "info", "mcp_session_created", {
1932
+ logEvent(config.logging, "debug", "mcp_session_created", {
1784
1933
  requestId,
1785
1934
  sessionIdPrefix: sessionIdPrefix(newSessionId),
1786
1935
  ...requestLogFields(req, config),
@@ -1790,7 +1939,7 @@ export function createServer(config = loadConfig(), options = {}) {
1790
1939
  transport.onclose = () => {
1791
1940
  const closedSessionId = transport?.sessionId;
1792
1941
  if (closedSessionId && transports.remove(closedSessionId)) {
1793
- logEvent(config.logging, "info", "mcp_session_closed", {
1942
+ logEvent(config.logging, "debug", "mcp_session_closed", {
1794
1943
  reason: "transport_close",
1795
1944
  sessionIdPrefix: sessionIdPrefix(closedSessionId),
1796
1945
  });