@akira-tl/forgerelay 0.2.5 → 0.3.0

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
@@ -14,17 +14,18 @@ import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from "@mode
14
14
  import express from "express";
15
15
  import * as z from "zod/v4";
16
16
  import { applyPatch } from "./apply-patch.js";
17
+ import { buildCapabilityFingerprint } from "./capabilities.js";
17
18
  import { deletePath, renamePath } from "./file-mutations.js";
18
19
  import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js";
19
20
  import { loadConfig } from "./config.js";
20
21
  import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
21
22
  import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
22
23
  import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
23
- import { logEvent, requestIp, requestPath, commandPreview, sessionIdPrefix, workspaceLogLabel, } from "./logger.js";
24
+ import { logEvent, requestIp, requestPath, commandPreview, transportSessionIdPrefix, workspaceLogLabel, } from "./logger.js";
24
25
  import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, writeFileTool, } from "./pi-tools.js";
25
26
  import { SingleUserOAuthProvider } from "./oauth-provider.js";
26
- import { McpSessionRegistry, } from "./mcp-sessions.js";
27
- import { ProcessSessionManager, } from "./process-sessions.js";
27
+ import { McpTransportRegistry, } from "./mcp-sessions.js";
28
+ import { ProcessManager, resolveProcessId, } from "./process-sessions.js";
28
29
  import { createReviewCheckpointManager } from "./review-checkpoints.js";
29
30
  import { openAiConversationScopeId } from "./request-meta.js";
30
31
  import { readWorkspaceAppManifestEntry, resolveWorkspaceAppIdentity, WORKSPACE_APP_LEGACY_URI, WORKSPACE_APP_URI_TEMPLATE, } from "./mcp-app-template.js";
@@ -34,11 +35,12 @@ import { createWorkspaceStore } from "./workspace-store.js";
34
35
  import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
35
36
  import { summarizeLocalAgentProfile } from "./local-agent-profiles.js";
36
37
  import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js";
37
- // MCP clients can reconnect without closing the previous transport. Bound stale
38
- // session retention so abandoned MCP servers do not accumulate for the life of the process.
39
- const MCP_SESSION_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
38
+ // Legacy MCP Streamable HTTP clients can reconnect without closing the previous
39
+ // transport. Bound stale transport-session retention so abandoned transports do
40
+ // not accumulate for the life of the process.
41
+ const MCP_TRANSPORT_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
40
42
  const FORGERELAY_VERSION = readForgeRelayVersion();
41
- const MCP_SESSION_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
43
+ const MCP_TRANSPORT_CLEANUP_INTERVAL_MS = 5 * 60 * 1_000;
42
44
  const WRITE_TOOL_ANNOTATIONS = {
43
45
  readOnlyHint: false,
44
46
  destructiveHint: true,
@@ -81,7 +83,7 @@ function toolWidgetDescriptorMeta(config, kind) {
81
83
  },
82
84
  };
83
85
  }
84
- function workspaceLogContext(workspace, _sessionId) {
86
+ function workspaceLogContext(workspace, _transportSessionId) {
85
87
  return {
86
88
  workspaceId: workspace.id,
87
89
  workspace: workspaceLogLabel(workspace.root, workspace.id),
@@ -111,6 +113,17 @@ const workspaceSkillOutputSchema = z.object({
111
113
  description: z.string(),
112
114
  path: z.string(),
113
115
  });
116
+ const capabilityFingerprintOutputSchema = z.object({
117
+ version: z.string(),
118
+ toolMode: z.enum(["minimal", "full", "codex"]),
119
+ capabilities: z.array(z.string()),
120
+ });
121
+ const capabilityGuideOutputSchema = z.object({
122
+ name: z.string(),
123
+ description: z.string(),
124
+ whenToRead: z.string(),
125
+ path: z.string(),
126
+ });
114
127
  const workspaceAgentsFileOutputSchema = z.object({
115
128
  path: z.string(),
116
129
  content: z.string(),
@@ -153,7 +166,7 @@ function sendJsonRpcError(res, status, code, message) {
153
166
  }
154
167
  function requestLogFields(req, config) {
155
168
  return {
156
- ip: requestIp(req, config.logging.trustProxy),
169
+ ip: requestIp(req),
157
170
  host: req.header("host"),
158
171
  userAgent: req.header("user-agent"),
159
172
  origin: req.header("origin"),
@@ -357,7 +370,7 @@ async function readWorkspaceAppResource(config, requestedUri, transportSessionId
357
370
  requestedUri,
358
371
  currentUri,
359
372
  compatibility,
360
- sessionIdPrefix: sessionIdPrefix(transportSessionId),
373
+ transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
361
374
  });
362
375
  return result;
363
376
  }
@@ -367,14 +380,14 @@ async function readWorkspaceAppResource(config, requestedUri, transportSessionId
367
380
  currentUri,
368
381
  compatibility,
369
382
  error: error instanceof Error ? error.message : String(error),
370
- sessionIdPrefix: sessionIdPrefix(transportSessionId),
383
+ transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
371
384
  });
372
385
  throw error;
373
386
  }
374
387
  }
375
388
  function processResult(snapshot) {
376
389
  const status = snapshot.running
377
- ? `Process running with session ID ${snapshot.sessionId}.`
390
+ ? `Process running with process ID ${snapshot.processId}.`
378
391
  : snapshot.signal
379
392
  ? `Process exited after signal ${snapshot.signal}.`
380
393
  : `Process exited with code ${snapshot.exitCode ?? "unknown"}.`;
@@ -382,8 +395,8 @@ function processResult(snapshot) {
382
395
  }
383
396
  function completedProcessResult(snapshot) {
384
397
  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"}.`;
398
+ ? `Background process ${snapshot.processId} exited after signal ${snapshot.signal}.`
399
+ : `Background process ${snapshot.processId} exited with code ${snapshot.exitCode ?? "unknown"}.`;
387
400
  const command = `Command: ${snapshot.command}`;
388
401
  const output = snapshot.output ? `\n${snapshot.output.replace(/\n$/, "")}` : "";
389
402
  return `${status}\n${command}${output}`;
@@ -405,10 +418,14 @@ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
405
418
  if (!Array.isArray(content))
406
419
  return result;
407
420
  const structured = result.structuredContent;
408
- const currentSessionId = structured?.running === true && typeof structured.sessionId === "number"
409
- ? structured.sessionId
421
+ const currentProcessId = structured?.running === true
422
+ ? typeof structured.processId === "number"
423
+ ? structured.processId
424
+ : typeof structured.sessionId === "number"
425
+ ? structured.sessionId
426
+ : undefined
410
427
  : undefined;
411
- const completed = processSessions.takeCompleted(workspaceId, undefined, currentSessionId);
428
+ const completed = processSessions.takeCompleted(workspaceId, undefined, currentProcessId);
412
429
  if (completed.length === 0)
413
430
  return result;
414
431
  return {
@@ -421,7 +438,8 @@ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
421
438
  }
422
439
  function processOutputSchema() {
423
440
  return resultOutputSchema({
424
- sessionId: z.number().optional(),
441
+ processId: z.number().int().positive().optional().describe("Canonical process handle for write_stdin."),
442
+ sessionId: z.number().int().positive().optional().describe("Deprecated alias of processId for compatibility."),
425
443
  running: z.boolean(),
426
444
  exitCode: z.number().int().optional(),
427
445
  signal: z.string().optional(),
@@ -452,6 +470,7 @@ function processToolResponse(tool, workspaceId, snapshot, summary) {
452
470
  },
453
471
  structuredContent: {
454
472
  result,
473
+ processId: snapshot.processId,
455
474
  sessionId: snapshot.sessionId,
456
475
  running: snapshot.running,
457
476
  exitCode: snapshot.exitCode,
@@ -476,7 +495,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
476
495
  if (config.toolMode === "codex") {
477
496
  registerAppTool(server, "exec_command", {
478
497
  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.`,
498
+ 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.`,
480
499
  inputSchema: {
481
500
  workspaceId: z.string().describe("Workspace identifier returned by open_workspace."),
482
501
  cmd: z.string().min(1).describe("Shell command to execute."),
@@ -496,7 +515,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
496
515
  .min(0)
497
516
  .max(30_000)
498
517
  .optional()
499
- .describe("Milliseconds to wait before returning a running session. Defaults to 10000."),
518
+ .describe("Milliseconds to wait before returning a running process. Defaults to 10000."),
500
519
  maxOutputTokens: z
501
520
  .number()
502
521
  .int()
@@ -537,7 +556,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
537
556
  commandLength: cmd.length,
538
557
  exitCode: snapshot.exitCode,
539
558
  running: snapshot.running,
540
- processSessionId: snapshot.sessionId,
559
+ processId: snapshot.processId,
541
560
  success: snapshot.running || snapshot.exitCode === 0,
542
561
  durationMs: Math.round(performance.now() - startedAt),
543
562
  });
@@ -557,7 +576,8 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
557
576
  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.",
558
577
  inputSchema: {
559
578
  workspaceId: z.string().describe("Workspace identifier used to start the process."),
560
- sessionId: z.number().describe("Process session identifier returned by exec_command."),
579
+ processId: z.number().int().positive().optional().describe("Canonical process identifier returned by bash or exec_command."),
580
+ sessionId: z.number().int().positive().optional().describe("Deprecated alias for processId. Retained for compatibility."),
561
581
  chars: z.string().optional().describe("Characters to write. Omit or pass an empty string to poll."),
562
582
  columns: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this width."),
563
583
  rows: z.number().int().min(1).max(1_000).optional().describe("Resize a PTY to this height."),
@@ -579,13 +599,14 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
579
599
  outputSchema: processOutputSchema(),
580
600
  ...toolWidgetDescriptorMeta(config, "shell"),
581
601
  annotations: SHELL_TOOL_ANNOTATIONS,
582
- }, async ({ workspaceId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
602
+ }, async ({ workspaceId, processId, sessionId, chars, columns, rows, yieldTimeMs, maxOutputTokens }, extra) => {
583
603
  const workspace = workspaces.getWorkspace(workspaceId);
604
+ const resolvedProcessId = resolveProcessId(processId, sessionId);
584
605
  return runToolWithHooks(hooks, {
585
606
  tool: "write_stdin",
586
607
  invocation: workspaceHookInvocation(workspace),
587
608
  payload: {
588
- sessionId,
609
+ processId: resolvedProcessId,
589
610
  charactersWritten: chars?.length ?? 0,
590
611
  columns,
591
612
  rows,
@@ -594,7 +615,7 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
594
615
  const startedAt = performance.now();
595
616
  const snapshot = await processSessions.write({
596
617
  workspaceId,
597
- sessionId,
618
+ processId: resolvedProcessId,
598
619
  chars,
599
620
  columns,
600
621
  rows,
@@ -606,12 +627,12 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
606
627
  ...workspaceLogContext(workspace, extra.sessionId),
607
628
  exitCode: snapshot.exitCode,
608
629
  running: snapshot.running,
609
- processSessionId: snapshot.sessionId,
630
+ processId: snapshot.processId,
610
631
  success: snapshot.running || snapshot.exitCode === 0,
611
632
  durationMs: Math.round(performance.now() - startedAt),
612
633
  });
613
634
  return processToolResponse("write_stdin", workspaceId, snapshot, {
614
- sessionId,
635
+ processId: resolvedProcessId,
615
636
  charactersWritten: chars?.length ?? 0,
616
637
  running: snapshot.running,
617
638
  exitCode: snapshot.exitCode,
@@ -630,9 +651,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
630
651
  version: FORGERELAY_VERSION,
631
652
  description: "Secure local coding workspace for MCP clients. Provides workspace-scoped file, search, edit, write, and shell tools.",
632
653
  }, {
633
- instructions: buildServerInstructions(config, {
634
- artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
635
- }),
654
+ instructions: buildServerInstructions(config),
636
655
  });
637
656
  const currentWorkspaceAppUri = currentWorkspaceAppIdentity().uri;
638
657
  const workspaceAppResourceMetadata = {
@@ -651,7 +670,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
651
670
  }, async (uri, _variables, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
652
671
  registerAppTool(server, "open_workspace", {
653
672
  title: "Open workspace",
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.",
673
+ 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.",
655
674
  inputSchema: {
656
675
  path: z
657
676
  .string()
@@ -715,6 +734,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
715
734
  targetBranch: z.string().optional(),
716
735
  managed: z.boolean(),
717
736
  })),
737
+ capabilityFingerprint: capabilityFingerprintOutputSchema,
738
+ capabilityGuides: z.array(capabilityGuideOutputSchema).optional(),
718
739
  agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
719
740
  availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
720
741
  skills: z.array(workspaceSkillOutputSchema).optional(),
@@ -738,6 +759,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
738
759
  });
739
760
  const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
740
761
  const staleWorkspaces = await workspaces.listStaleWorkspaces(workspace);
762
+ const capabilityFingerprint = buildCapabilityFingerprint(config, FORGERELAY_VERSION, {
763
+ artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
764
+ });
741
765
  if (config.widgets === "changes") {
742
766
  await reviewCheckpoints.initializeWorkspace({
743
767
  workspaceId: workspace.id,
@@ -751,6 +775,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
751
775
  description: skill.description,
752
776
  path: formatPathForPrompt(skill.filePath),
753
777
  }));
778
+ const capabilityGuides = workspace.capabilityGuides.map((guide) => ({
779
+ name: guide.name,
780
+ description: guide.description,
781
+ whenToRead: guide.whenToRead,
782
+ path: formatPathForPrompt(guide.filePath),
783
+ }));
754
784
  const cardAgentProviders = config.subagents ? localAgentProviders : [];
755
785
  const cardAgents = workspace.agentProfiles.map((profile) => {
756
786
  const summary = summarizeLocalAgentProfile(profile);
@@ -769,13 +799,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
769
799
  path: formatAgentsPath(file.path, workspace.root),
770
800
  }));
771
801
  const visibleSkills = includeBootstrapContext ? cardSkills : [];
802
+ const visibleCapabilityGuides = includeBootstrapContext ? capabilityGuides : [];
772
803
  const visibleAgentProviders = includeBootstrapContext ? cardAgentProviders : [];
773
804
  const visibleAgents = includeBootstrapContext ? cardAgents : [];
774
805
  const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : [];
775
806
  const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : [];
776
807
  const cardInstruction = config.skillsEnabled
777
- ? "Use this workspaceId in all subsequent tool calls for this project. Default to the user's checkout; only create a worktree when the user explicitly requests isolated or parallel work. Managed worktrees are branch-backed. When a managed worktree task is complete and verified, close it with close_worktree so ForgeRelay can commit, fast-forward the target branch when safe, and clean up the worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding."
778
- : "Use this workspaceId in all subsequent tool calls for this project. Default to the user's checkout; only create a worktree when the user explicitly requests isolated or parallel work. Managed worktrees are branch-backed. When a managed worktree task is complete and verified, close it with close_worktree so ForgeRelay can commit, fast-forward the target branch when safe, and clean up the worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file.";
808
+ ? "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."
809
+ : "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.";
779
810
  const instruction = workspaceReused
780
811
  ? includeBootstrapContext
781
812
  ? [
@@ -786,7 +817,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
786
817
  : [
787
818
  `Workspace already open as ${workspace.id}.`,
788
819
  "Reuse this workspaceId for subsequent tool calls. This is the same directory previously opened in this conversation.",
789
- "Continue following the project instructions, nested instruction files, skills, agent profiles, and diagnostics previously provided for this workspace. They remain active and are not repeated here.",
820
+ "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.",
790
821
  ].join("\n\n")
791
822
  : workspace.mode === "worktree"
792
823
  ? "Use this workspaceId for subsequent tool calls. Follow the project instructions, nested instruction files, skills, agent profiles, and diagnostics returned for this isolated worktree."
@@ -811,6 +842,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
811
842
  visibleSkills.length > 0
812
843
  ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}`
813
844
  : undefined,
845
+ visibleCapabilityGuides.length > 0
846
+ ? `Capability guides: ${visibleCapabilityGuides.map((guide) => guide.name).join(", ")}`
847
+ : undefined,
814
848
  visibleAgentProviders.some((provider) => provider.available)
815
849
  ? `Available subagent providers: ${visibleAgentProviders.filter((provider) => provider.available).map((provider) => provider.name).join(", ")}`
816
850
  : undefined,
@@ -826,6 +860,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
826
860
  staleWorkspaces.length > 0
827
861
  ? `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
862
  : undefined,
863
+ `ForgeRelay ${capabilityFingerprint.version} capabilities: ${capabilityFingerprint.capabilities.join(", ")}`,
829
864
  instruction,
830
865
  ].filter(Boolean).join("\n"),
831
866
  },
@@ -852,6 +887,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
852
887
  worktree: workspace.worktree,
853
888
  worktrees: knownWorktrees,
854
889
  staleWorkspaces,
890
+ capabilityFingerprint,
855
891
  agentsFiles: cardAgentsFiles,
856
892
  availableAgentsFiles: cardAvailableAgentsFiles,
857
893
  skills: cardSkills,
@@ -876,8 +912,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
876
912
  worktree: workspace.worktree,
877
913
  worktrees: knownWorktrees,
878
914
  staleWorkspaces,
915
+ capabilityFingerprint,
879
916
  ...(includeBootstrapContext
880
917
  ? {
918
+ capabilityGuides: visibleCapabilityGuides,
881
919
  agentsFiles: loadedAgentsFiles,
882
920
  availableAgentsFiles: availableAgentsFileOutputs,
883
921
  skills: visibleSkills,
@@ -892,7 +930,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
892
930
  });
893
931
  registerAppTool(server, toolNames.closeWorkspace, {
894
932
  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.",
933
+ description: "Release one logical workspaceId after the user chooses cleanup. This does not delete checkout files. Use close_worktree to finalize and remove a managed worktree. Running or unconsumed processes prevent closure.",
896
934
  inputSchema: {
897
935
  workspaceId: z.string().describe("Logical workspace ID to release."),
898
936
  },
@@ -920,7 +958,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
920
958
  });
921
959
  registerAppTool(server, toolNames.closeWorktree, {
922
960
  title: "Close worktree",
923
- description: "Finish a managed ForgeRelay worktree after its task has been completed and verified. ForgeRelay commits any remaining worktree changes, fast-forwards the original target branch only when the source checkout is clean and the histories have not diverged, then removes the worktree and its forgerelay/* branch. If safe fast-forward is not possible, the source checkout is left out of a merge-conflict state and the worktree is preserved.",
961
+ description: "Finalize a managed worktree after its task is complete and verified. ForgeRelay may commit remaining changes, integrate the target branch when safe, and clean up the managed worktree. Read the managed-worktrees capability guide for advanced close, safety, and failure semantics.",
924
962
  inputSchema: {
925
963
  workspaceId: z
926
964
  .string()
@@ -1001,8 +1039,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1001
1039
  path: z
1002
1040
  .string()
1003
1041
  .describe(config.skillsEnabled
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."
1005
- : "File path to read, relative to the workspace root or absolute inside the OS temp directory."),
1042
+ ? "File path to read, relative to the workspace root or absolute inside the OS temp directory. May also be an advertised skill or capability-guide path from open_workspace, including a ~/... home-relative path."
1043
+ : "File path to read, relative to the workspace root or absolute inside the OS temp directory. May also be an advertised capability-guide path from open_workspace."),
1006
1044
  offset: z
1007
1045
  .number()
1008
1046
  .int()
@@ -1747,7 +1785,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1747
1785
  commandLength: command.length,
1748
1786
  exitCode: snapshot.exitCode,
1749
1787
  running: snapshot.running,
1750
- processSessionId: snapshot.sessionId,
1788
+ processId: snapshot.processId,
1751
1789
  success: snapshot.running || (snapshot.exitCode === 0 && !snapshot.signal),
1752
1790
  durationMs: Math.round(performance.now() - startedAt),
1753
1791
  });
@@ -1786,7 +1824,7 @@ export function createServer(config = loadConfig(), options = {}) {
1786
1824
  host: config.host,
1787
1825
  ...(allowedHosts ? { allowedHosts } : {}),
1788
1826
  });
1789
- const transports = new McpSessionRegistry();
1827
+ const transports = new McpTransportRegistry();
1790
1828
  const mcpUrl = new URL("/mcp", config.publicBaseUrl);
1791
1829
  const resourceServerUrl = resourceUrlFromServerUrl(mcpUrl);
1792
1830
  const oauthProvider = new SingleUserOAuthProvider(config.oauth, mcpUrl, config.stateDir);
@@ -1798,17 +1836,17 @@ export function createServer(config = loadConfig(), options = {}) {
1798
1836
  const workspaceStore = createWorkspaceStore(config.stateDir);
1799
1837
  const workspaces = new WorkspaceRegistry(config, workspaceStore);
1800
1838
  const reviewCheckpoints = createReviewCheckpointManager();
1801
- const processSessions = new ProcessSessionManager();
1839
+ const processSessions = new ProcessManager();
1802
1840
  const localAgentProviders = config.subagents
1803
1841
  ? getLocalAgentProviderAvailabilitySnapshot()
1804
1842
  : [];
1805
- const logSessionCloseResults = (reason, results) => {
1843
+ const logTransportCloseResults = (reason, results) => {
1806
1844
  let closedCount = 0;
1807
1845
  for (const result of results) {
1808
1846
  if (result.error) {
1809
- logEvent(config.logging, "warn", "mcp_session_close_failed", {
1847
+ logEvent(config.logging, "warn", "mcp_transport_session_close_failed", {
1810
1848
  reason,
1811
- sessionIdPrefix: sessionIdPrefix(result.sessionId),
1849
+ transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
1812
1850
  error: result.error instanceof Error
1813
1851
  ? result.error.message
1814
1852
  : String(result.error),
@@ -1817,27 +1855,27 @@ export function createServer(config = loadConfig(), options = {}) {
1817
1855
  }
1818
1856
  closedCount += 1;
1819
1857
  if (reason === "idle_timeout") {
1820
- logEvent(config.logging, "debug", "mcp_session_closed", {
1858
+ logEvent(config.logging, "debug", "mcp_transport_session_closed", {
1821
1859
  reason,
1822
- sessionIdPrefix: sessionIdPrefix(result.sessionId),
1860
+ transportSessionIdPrefix: transportSessionIdPrefix(result.transportSessionId),
1823
1861
  });
1824
1862
  }
1825
1863
  }
1826
1864
  if (reason === "server_shutdown" && closedCount > 0) {
1827
- logEvent(config.logging, "debug", "mcp_sessions_closed", {
1865
+ logEvent(config.logging, "debug", "mcp_transport_sessions_closed", {
1828
1866
  reason,
1829
1867
  count: closedCount,
1830
1868
  });
1831
1869
  }
1832
1870
  };
1833
- const sessionCleanupTimer = setInterval(() => {
1871
+ const transportCleanupTimer = setInterval(() => {
1834
1872
  void transports
1835
- .closeIdle(MCP_SESSION_IDLE_TIMEOUT_MS)
1836
- .then((results) => logSessionCloseResults("idle_timeout", results));
1837
- }, MCP_SESSION_CLEANUP_INTERVAL_MS);
1838
- sessionCleanupTimer.unref();
1873
+ .closeIdle(MCP_TRANSPORT_IDLE_TIMEOUT_MS)
1874
+ .then((results) => logTransportCloseResults("idle_timeout", results));
1875
+ }, MCP_TRANSPORT_CLEANUP_INTERVAL_MS);
1876
+ transportCleanupTimer.unref();
1839
1877
  if (config.logging.trustProxy) {
1840
- app.set("trust proxy", true);
1878
+ app.set("trust proxy", 1);
1841
1879
  }
1842
1880
  app.use((req, res, next) => {
1843
1881
  const requestId = randomUUID();
@@ -1883,7 +1921,7 @@ export function createServer(config = loadConfig(), options = {}) {
1883
1921
  });
1884
1922
  app.all("/mcp", async (req, res) => {
1885
1923
  const requestId = res.locals.requestId;
1886
- const sessionId = req.header("mcp-session-id");
1924
+ const transportSessionId = req.header("mcp-session-id");
1887
1925
  const initializeRequest = req.method === "POST" && isInitializeRequest(req.body);
1888
1926
  await new Promise((resolve, reject) => {
1889
1927
  bearerAuth(req, res, (error) => {
@@ -1909,39 +1947,39 @@ export function createServer(config = loadConfig(), options = {}) {
1909
1947
  logEvent(config.logging, "debug", "mcp_request", {
1910
1948
  requestId,
1911
1949
  httpMethod: req.method,
1912
- sessionIdPresent: Boolean(sessionId),
1913
- sessionIdPrefix: sessionIdPrefix(sessionId),
1950
+ transportSessionIdPresent: Boolean(transportSessionId),
1951
+ transportSessionIdPrefix: transportSessionIdPrefix(transportSessionId),
1914
1952
  isInitialize: initializeRequest,
1915
1953
  ...mcpRequestDebugFields(req.body),
1916
1954
  });
1917
1955
  try {
1918
1956
  let transport;
1919
- if (sessionId) {
1920
- transport = transports.get(sessionId);
1957
+ if (transportSessionId) {
1958
+ transport = transports.get(transportSessionId);
1921
1959
  if (!transport) {
1922
- sendJsonRpcError(res, 404, -32000, "Unknown MCP session");
1960
+ sendJsonRpcError(res, 404, -32000, "Unknown MCP transport session");
1923
1961
  return;
1924
1962
  }
1925
1963
  }
1926
1964
  else if (initializeRequest) {
1927
1965
  transport = new StreamableHTTPServerTransport({
1928
1966
  sessionIdGenerator: () => randomUUID(),
1929
- onsessioninitialized: (newSessionId) => {
1967
+ onsessioninitialized: (newTransportSessionId) => {
1930
1968
  if (transport)
1931
- transports.register(newSessionId, transport);
1932
- logEvent(config.logging, "debug", "mcp_session_created", {
1969
+ transports.register(newTransportSessionId, transport);
1970
+ logEvent(config.logging, "debug", "mcp_transport_session_created", {
1933
1971
  requestId,
1934
- sessionIdPrefix: sessionIdPrefix(newSessionId),
1972
+ transportSessionIdPrefix: transportSessionIdPrefix(newTransportSessionId),
1935
1973
  ...requestLogFields(req, config),
1936
1974
  });
1937
1975
  },
1938
1976
  });
1939
1977
  transport.onclose = () => {
1940
- const closedSessionId = transport?.sessionId;
1941
- if (closedSessionId && transports.remove(closedSessionId)) {
1942
- logEvent(config.logging, "debug", "mcp_session_closed", {
1978
+ const closedTransportSessionId = transport?.sessionId;
1979
+ if (closedTransportSessionId && transports.remove(closedTransportSessionId)) {
1980
+ logEvent(config.logging, "debug", "mcp_transport_session_closed", {
1943
1981
  reason: "transport_close",
1944
- sessionIdPrefix: sessionIdPrefix(closedSessionId),
1982
+ transportSessionIdPrefix: transportSessionIdPrefix(closedTransportSessionId),
1945
1983
  });
1946
1984
  }
1947
1985
  };
@@ -1949,7 +1987,7 @@ export function createServer(config = loadConfig(), options = {}) {
1949
1987
  await server.connect(transport);
1950
1988
  }
1951
1989
  else {
1952
- sendJsonRpcError(res, 400, -32000, "No valid MCP session");
1990
+ sendJsonRpcError(res, 400, -32000, "No valid MCP transport session");
1953
1991
  return;
1954
1992
  }
1955
1993
  await transport.handleRequest(req, res, req.body);
@@ -1971,9 +2009,9 @@ export function createServer(config = loadConfig(), options = {}) {
1971
2009
  localAgentProviders,
1972
2010
  close: () => {
1973
2011
  closePromise ??= (async () => {
1974
- clearInterval(sessionCleanupTimer);
2012
+ clearInterval(transportCleanupTimer);
1975
2013
  const results = await transports.closeAll();
1976
- logSessionCloseResults("server_shutdown", results);
2014
+ logTransportCloseResults("server_shutdown", results);
1977
2015
  processSessions.shutdown();
1978
2016
  oauthProvider.close();
1979
2017
  workspaceStore.close?.();
package/dist/skills.js CHANGED
@@ -1,27 +1,16 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve, sep } from "node:path";
4
- import { fileURLToPath } from "node:url";
4
+ import { markAdvertisedFileSourceActivated, resolveAdvertisedFileReadPath, } from "./advertised-files.js";
5
5
  import { loadSkills, } from "@earendil-works/pi-coding-agent";
6
- import { expandHomePath, isPathInsideRoot } from "./roots.js";
6
+ import { expandHomePath } from "./roots.js";
7
7
  const SUBAGENT_DELEGATION_NAME = "subagent-delegation";
8
- const SUBAGENT_DELEGATION_SKILL = join(SUBAGENT_DELEGATION_NAME, "SKILL.md");
9
- function bundledSkillsDir() {
10
- return fileURLToPath(new URL("../skills", import.meta.url));
11
- }
12
- function hasSubagentDelegationSkill(skillDir) {
13
- return existsSync(join(skillDir, SUBAGENT_DELEGATION_SKILL));
14
- }
15
8
  export function effectiveSkillPaths(config, cwd) {
16
- const bundledSkills = bundledSkillsDir();
17
9
  const defaultPathCandidates = [
18
10
  join(homedir(), ".agents", "skills"),
19
11
  resolve(cwd, ".agents", "skills"),
20
12
  config.devspaceSkillsDir,
21
13
  join(config.agentDir, "skills"),
22
- config.subagents && !hasSubagentDelegationSkill(config.devspaceSkillsDir)
23
- ? bundledSkills
24
- : undefined,
25
14
  ];
26
15
  const defaultPaths = defaultPathCandidates.filter((path) => path !== undefined && existsSync(path));
27
16
  const seen = new Set();
@@ -57,25 +46,17 @@ export function loadWorkspaceSkills(config, cwd) {
57
46
  };
58
47
  }
59
48
  export function resolveSkillReadPath(skills, activatedSkillDirs, inputPath) {
60
- const absolutePath = resolve(expandHomePath(inputPath));
61
- for (const skill of skills) {
62
- const skillFilePath = resolve(skill.filePath);
63
- if (absolutePath === skillFilePath) {
64
- return { absolutePath, skill, isSkillFile: true };
65
- }
66
- }
67
- for (const skill of skills) {
68
- const baseDir = resolve(skill.baseDir);
69
- if (!activatedSkillDirs.has(baseDir))
70
- continue;
71
- if (!isPathInsideRoot(absolutePath, baseDir))
72
- continue;
73
- return { absolutePath, skill, isSkillFile: false };
74
- }
75
- return undefined;
49
+ const resolution = resolveAdvertisedFileReadPath(skills, activatedSkillDirs, inputPath);
50
+ if (!resolution)
51
+ return undefined;
52
+ return {
53
+ absolutePath: resolution.absolutePath,
54
+ skill: resolution.source,
55
+ isSkillFile: resolution.isEntryFile,
56
+ };
76
57
  }
77
58
  export function markSkillActivated(activatedSkillDirs, skill) {
78
- activatedSkillDirs.add(resolve(skill.baseDir));
59
+ markAdvertisedFileSourceActivated(activatedSkillDirs, skill);
79
60
  }
80
61
  export function formatPathForPrompt(path) {
81
62
  const home = resolve(homedir());
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import { mkdir, opendir, readFile, realpath, stat } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
+ import { loadCapabilityGuides, markCapabilityGuideActivated, resolveCapabilityGuideReadPath, } from "./capabilities.js";
5
6
  import { HookRunner } from "./hooks.js";
6
7
  import { closeManagedWorktree, createManagedWorktree, resolveManagedWorktreeBase, } from "./git-worktrees.js";
7
8
  import { AccessDeniedError, assertAllowedPath, isPathInsideRoot, resolveAllowedPath, } from "./roots.js";
@@ -548,8 +549,10 @@ export class WorkspaceRegistry {
548
549
  }
549
550
  : undefined,
550
551
  ...this.loadSkillsForWorkspace(root),
552
+ capabilityGuides: loadCapabilityGuides(this.config),
551
553
  agentProfiles: [],
552
554
  activatedSkillDirs: new Set(),
555
+ activatedCapabilityGuideDirs: new Set(),
553
556
  };
554
557
  if (touch)
555
558
  this.store?.touchSession(session.id);
@@ -582,6 +585,14 @@ export class WorkspaceRegistry {
582
585
  skillRead,
583
586
  };
584
587
  }
588
+ const capabilityGuideRead = resolveCapabilityGuideReadPath(workspace.capabilityGuides, workspace.activatedCapabilityGuideDirs, inputPath);
589
+ if (capabilityGuideRead) {
590
+ return {
591
+ absolutePath: capabilityGuideRead.absolutePath,
592
+ readRoots: [workspace.root, capabilityGuideRead.guide.baseDir],
593
+ capabilityGuideRead,
594
+ };
595
+ }
585
596
  try {
586
597
  return {
587
598
  absolutePath: resolveAllowedPath(inputPath, workspace.root, [tmpdir()]),
@@ -597,6 +608,9 @@ export class WorkspaceRegistry {
597
608
  if (readPath.skillRead?.isSkillFile) {
598
609
  markSkillActivated(workspace.activatedSkillDirs, readPath.skillRead.skill);
599
610
  }
611
+ if (readPath.capabilityGuideRead?.isGuideFile) {
612
+ markCapabilityGuideActivated(workspace.activatedCapabilityGuideDirs, readPath.capabilityGuideRead.guide);
613
+ }
600
614
  }
601
615
  resolveWorkingDirectory(workspace, workingDirectory) {
602
616
  const directory = workingDirectory ? this.resolvePath(workspace, workingDirectory) : workspace.root;
@@ -631,8 +645,10 @@ export class WorkspaceRegistry {
631
645
  sourceRoot: input.sourceRoot,
632
646
  worktree: input.worktree,
633
647
  ...this.loadSkillsForWorkspace(input.root),
648
+ capabilityGuides: loadCapabilityGuides(this.config),
634
649
  agentProfiles: await loadLocalAgentProfiles(this.config, input.root),
635
650
  activatedSkillDirs: new Set(),
651
+ activatedCapabilityGuideDirs: new Set(),
636
652
  };
637
653
  this.store?.createSession({
638
654
  id: workspace.id,