@akira-tl/forgerelay 0.2.6 → 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.
@@ -14,57 +14,42 @@ export const toolNames = {
14
14
  writeStdin: "write_stdin",
15
15
  };
16
16
  export function buildShellMutationPolicy() {
17
- return "Shell commands may modify ordinary project files when that is a natural part of the user's requested development task. Never use shell commands to modify security- or privilege-sensitive operating-system files or credential material such as /etc/sudoers, /etc/passwd, /etc/shadow, PAM or authentication policy, SSH private keys, or equivalent privileged system files. Modify configuration files through shell only when the user's request explicitly calls for that configuration change; do not infer permission merely because changing configuration would be convenient.";
17
+ return "Shell commands may modify ordinary project files when that is a natural part of the user's requested development task. They may also perform external device or hardware mutations when the user's current request explicitly asks for the actual device-changing operation, including firmware flashing or equivalent persistent device updates; do not infer such authorization from a check, audit, probe, backup, verification, dry-run, or build-only request. Never use shell commands to modify security- or privilege-sensitive operating-system files or credential material such as /etc/sudoers, /etc/passwd, /etc/shadow, PAM or authentication policy, SSH private keys, or equivalent privileged system files. Modify configuration files through shell only when the user's request explicitly calls for that configuration change; do not infer permission merely because changing configuration would be convenient.";
18
18
  }
19
- export function buildServerInstructions(config, context = {}) {
20
- return joinInstructions(capabilityContractInstructions(config, context), selectedWorkflowInstructions(config), config.appendInstructions);
19
+ export function buildServerInstructions(config) {
20
+ return joinInstructions(capabilityContractInstructions(config), selectedWorkflowInstructions(config), config.appendInstructions);
21
21
  }
22
22
  export function buildToolDescriptions(config) {
23
23
  const skillCapability = config.skillsEnabled
24
- ? " Advertised skill paths may be outside the workspace; only advertised SKILL.md files and files under already-loaded skill directories are readable."
24
+ ? " Advertised skill paths may also be outside the workspace."
25
25
  : "";
26
26
  const shellSurface = config.toolMode === "minimal"
27
27
  ? ` In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled, so shell commands may be used for equivalent search and directory inspection.`
28
28
  : "";
29
- const shellMutationPolicy = buildShellMutationPolicy();
30
29
  return {
31
- read: `Read a file inside an open workspace or the OS temp directory. Instruction files returned by ${toolNames.openWorkspace} and advertised skill files are also readable when applicable.${skillCapability} Call ${toolNames.openWorkspace} first and pass workspaceId.`,
30
+ read: `Read a file inside an open workspace or the OS temp directory. Instruction files and advertised capability guides returned by ${toolNames.openWorkspace} are also readable when applicable.${skillCapability} Only advertised entry files and files under already-loaded advertised directories are readable outside the normal roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
32
31
  write: `Create or completely overwrite a file inside an open workspace or the OS temp directory. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
33
32
  edit: `Edit one file inside an open workspace or the OS temp directory by replacing exact text blocks. Each oldText must match a unique, non-overlapping region of the original file. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
34
33
  rename: `Rename or move one file or directory inside an open workspace or the OS temp directory without overwriting an existing destination. Source and destination must both remain inside the permitted file roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
35
34
  delete: `Delete one file or directory inside an open workspace or the OS temp directory. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
36
35
  applyPatch: `Apply one Codex-style patch inside an open workspace or the OS temp directory. Supports adding, overwriting, updating, deleting, and moving files. Workspace paths must remain relative; absolute paths are accepted only inside the OS temp directory. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
37
- shell: `Run a shell command inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. ForgeRelay waits up to 300 seconds for bash, then returns a running process with a processId without killing it; use ${toolNames.writeStdin} with that processId to poll, keep waiting, interact, or send Ctrl-C. Completed background commands are also reported with a later tool result for the same workspaceId. ${shellMutationPolicy} Call ${toolNames.openWorkspace} first and pass workspaceId. This capability should only be exposed behind strong authentication.`,
36
+ shell: `Run a shell command inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. ForgeRelay waits up to 300 seconds, then returns a processId for a still-running command; use ${toolNames.writeStdin} to poll, interact, wait, or send Ctrl-C. Completed background commands may be reported later for the same workspaceId. Call ${toolNames.openWorkspace} first and pass workspaceId. Expose this capability only behind strong authentication.`,
38
37
  shellCommand: "Shell command to run with the local user's authority.",
39
38
  };
40
39
  }
41
- function capabilityContractInstructions(config, context) {
42
- const workspaceLifecycle = config.toolMode === "codex"
43
- ? `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Keep the workspaceId returned for this conversation stable. Different conversations normally receive separate logical workspaceIds even when they point at the same physical checkout or worktree; pass an existing workspaceId to ${toolNames.openWorkspace} only when the user wants to resume that logical workspace in this conversation. Only request a new logical workspace when the user explicitly asks. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`
44
- : `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Keep the workspaceId returned for this conversation stable for later file, search, edit, write, rename, delete, show-changes, shell, and process-polling tools. Different conversations normally receive separate logical workspaceIds even when they point at the same physical checkout or worktree; pass an existing workspaceId to ${toolNames.openWorkspace} only when the user wants to resume that logical workspace in this conversation. Only request a new logical workspace when the user explicitly asks. If ${toolNames.openWorkspace} reports logical workspaces idle for more than two days, tell the user each workspaceId and let them decide whether to resume it or explicitly clean it up with ${toolNames.closeWorkspace}; do not close it automatically. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`;
45
- const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it.`;
40
+ function capabilityContractInstructions(config) {
41
+ const staleWorkspacePolicy = config.toolMode === "codex"
42
+ ? ""
43
+ : ` If ${toolNames.openWorkspace} reports logical workspaces idle for more than two days, let the user choose whether to resume or close them with ${toolNames.closeWorkspace}; never close them automatically.`;
44
+ const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse the workspaceId returned by ${toolNames.openWorkspace} for this conversation; resume another logical workspaceId only when the user wants that workspace, and request a new logical workspace only when explicitly asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} releases a logical workspace; ${toolNames.closeWorktree} finalizes a managed worktree. Read the managed-worktrees capability guide for advanced worktree lifecycle and failure semantics.`;
45
+ const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
46
+ const capabilityGuides = `When ${toolNames.openWorkspace} returns capability guides, use ${toolNames.read} to load only a task-relevant guide; do not preload all guides.`;
46
47
  const skills = config.skillsEnabled
47
- ? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories.`
48
+ ? `When a task matches an available skill from ${toolNames.openWorkspace}, read its advertised path before proceeding. Outside normal file roots, ${toolNames.read} permits only advertised entry files and files under already-loaded advertised directories.`
48
49
  : "";
49
- const toolSurface = toolSurfaceInstructions(config);
50
50
  const shellMutationPolicy = buildShellMutationPolicy();
51
51
  const hooks = "When a ForgeRelay tool result reports Hook results, tell the user which meaningful hooks ran and whether they passed or blocked the operation. Do not claim the requested operation succeeded when a blocking hook prevented it.";
52
- const artifact = config.artifactsEnabled && context.artifactDownloadSupported
53
- ? "When the user supplies or generates a file that is not present on the ForgeRelay host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs."
54
- : "";
55
- const showChanges = config.widgets === "changes"
56
- ? "If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs."
57
- : "";
58
- return joinInstructions(workspaceLifecycle, agents, skills, toolSurface, shellMutationPolicy, hooks, artifact, showChanges);
59
- }
60
- function toolSurfaceInstructions(config) {
61
- if (config.toolMode === "codex") {
62
- return `In codex tool mode, workspace file and command operations use ${toolNames.read}, ${toolNames.rename}, ${toolNames.delete}, apply_patch, exec_command, and ${toolNames.writeStdin}.`;
63
- }
64
- if (config.toolMode === "full") {
65
- return `In full tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are available alongside the core workspace tools. ${toolNames.writeStdin} is available for running bash processes.`;
66
- }
67
- return `In minimal tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are disabled; the core workspace tools remain available, including ${toolNames.writeStdin} for running bash processes.`;
52
+ return joinInstructions(workspaceLifecycle, agents, capabilityGuides, skills, shellMutationPolicy, hooks);
68
53
  }
69
54
  function selectedWorkflowInstructions(config) {
70
55
  if (config.workflowInstructions === false)
package/dist/server.js CHANGED
@@ -14,6 +14,7 @@ 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";
@@ -112,6 +113,17 @@ const workspaceSkillOutputSchema = z.object({
112
113
  description: z.string(),
113
114
  path: z.string(),
114
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
+ });
115
127
  const workspaceAgentsFileOutputSchema = z.object({
116
128
  path: z.string(),
117
129
  content: z.string(),
@@ -154,7 +166,7 @@ function sendJsonRpcError(res, status, code, message) {
154
166
  }
155
167
  function requestLogFields(req, config) {
156
168
  return {
157
- ip: requestIp(req, config.logging.trustProxy),
169
+ ip: requestIp(req),
158
170
  host: req.header("host"),
159
171
  userAgent: req.header("user-agent"),
160
172
  origin: req.header("origin"),
@@ -639,9 +651,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
639
651
  version: FORGERELAY_VERSION,
640
652
  description: "Secure local coding workspace for MCP clients. Provides workspace-scoped file, search, edit, write, and shell tools.",
641
653
  }, {
642
- instructions: buildServerInstructions(config, {
643
- artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
644
- }),
654
+ instructions: buildServerInstructions(config),
645
655
  });
646
656
  const currentWorkspaceAppUri = currentWorkspaceAppIdentity().uri;
647
657
  const workspaceAppResourceMetadata = {
@@ -660,7 +670,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
660
670
  }, async (uri, _variables, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
661
671
  registerAppTool(server, "open_workspace", {
662
672
  title: "Open workspace",
663
- 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.",
664
674
  inputSchema: {
665
675
  path: z
666
676
  .string()
@@ -724,6 +734,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
724
734
  targetBranch: z.string().optional(),
725
735
  managed: z.boolean(),
726
736
  })),
737
+ capabilityFingerprint: capabilityFingerprintOutputSchema,
738
+ capabilityGuides: z.array(capabilityGuideOutputSchema).optional(),
727
739
  agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
728
740
  availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
729
741
  skills: z.array(workspaceSkillOutputSchema).optional(),
@@ -747,6 +759,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
747
759
  });
748
760
  const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
749
761
  const staleWorkspaces = await workspaces.listStaleWorkspaces(workspace);
762
+ const capabilityFingerprint = buildCapabilityFingerprint(config, FORGERELAY_VERSION, {
763
+ artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
764
+ });
750
765
  if (config.widgets === "changes") {
751
766
  await reviewCheckpoints.initializeWorkspace({
752
767
  workspaceId: workspace.id,
@@ -760,6 +775,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
760
775
  description: skill.description,
761
776
  path: formatPathForPrompt(skill.filePath),
762
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
+ }));
763
784
  const cardAgentProviders = config.subagents ? localAgentProviders : [];
764
785
  const cardAgents = workspace.agentProfiles.map((profile) => {
765
786
  const summary = summarizeLocalAgentProfile(profile);
@@ -778,13 +799,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
778
799
  path: formatAgentsPath(file.path, workspace.root),
779
800
  }));
780
801
  const visibleSkills = includeBootstrapContext ? cardSkills : [];
802
+ const visibleCapabilityGuides = includeBootstrapContext ? capabilityGuides : [];
781
803
  const visibleAgentProviders = includeBootstrapContext ? cardAgentProviders : [];
782
804
  const visibleAgents = includeBootstrapContext ? cardAgents : [];
783
805
  const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : [];
784
806
  const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : [];
785
807
  const cardInstruction = config.skillsEnabled
786
- ? "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."
787
- : "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.";
788
810
  const instruction = workspaceReused
789
811
  ? includeBootstrapContext
790
812
  ? [
@@ -795,7 +817,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
795
817
  : [
796
818
  `Workspace already open as ${workspace.id}.`,
797
819
  "Reuse this workspaceId for subsequent tool calls. This is the same directory previously opened in this conversation.",
798
- "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.",
799
821
  ].join("\n\n")
800
822
  : workspace.mode === "worktree"
801
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."
@@ -820,6 +842,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
820
842
  visibleSkills.length > 0
821
843
  ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}`
822
844
  : undefined,
845
+ visibleCapabilityGuides.length > 0
846
+ ? `Capability guides: ${visibleCapabilityGuides.map((guide) => guide.name).join(", ")}`
847
+ : undefined,
823
848
  visibleAgentProviders.some((provider) => provider.available)
824
849
  ? `Available subagent providers: ${visibleAgentProviders.filter((provider) => provider.available).map((provider) => provider.name).join(", ")}`
825
850
  : undefined,
@@ -835,6 +860,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
835
860
  staleWorkspaces.length > 0
836
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.`
837
862
  : undefined,
863
+ `ForgeRelay ${capabilityFingerprint.version} capabilities: ${capabilityFingerprint.capabilities.join(", ")}`,
838
864
  instruction,
839
865
  ].filter(Boolean).join("\n"),
840
866
  },
@@ -861,6 +887,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
861
887
  worktree: workspace.worktree,
862
888
  worktrees: knownWorktrees,
863
889
  staleWorkspaces,
890
+ capabilityFingerprint,
864
891
  agentsFiles: cardAgentsFiles,
865
892
  availableAgentsFiles: cardAvailableAgentsFiles,
866
893
  skills: cardSkills,
@@ -885,8 +912,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
885
912
  worktree: workspace.worktree,
886
913
  worktrees: knownWorktrees,
887
914
  staleWorkspaces,
915
+ capabilityFingerprint,
888
916
  ...(includeBootstrapContext
889
917
  ? {
918
+ capabilityGuides: visibleCapabilityGuides,
890
919
  agentsFiles: loadedAgentsFiles,
891
920
  availableAgentsFiles: availableAgentsFileOutputs,
892
921
  skills: visibleSkills,
@@ -901,7 +930,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
901
930
  });
902
931
  registerAppTool(server, toolNames.closeWorkspace, {
903
932
  title: "Close logical workspace",
904
- 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.",
905
934
  inputSchema: {
906
935
  workspaceId: z.string().describe("Logical workspace ID to release."),
907
936
  },
@@ -929,7 +958,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
929
958
  });
930
959
  registerAppTool(server, toolNames.closeWorktree, {
931
960
  title: "Close worktree",
932
- 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.",
933
962
  inputSchema: {
934
963
  workspaceId: z
935
964
  .string()
@@ -1010,8 +1039,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1010
1039
  path: z
1011
1040
  .string()
1012
1041
  .describe(config.skillsEnabled
1013
- ? "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."
1014
- : "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."),
1015
1044
  offset: z
1016
1045
  .number()
1017
1046
  .int()
@@ -1846,7 +1875,7 @@ export function createServer(config = loadConfig(), options = {}) {
1846
1875
  }, MCP_TRANSPORT_CLEANUP_INTERVAL_MS);
1847
1876
  transportCleanupTimer.unref();
1848
1877
  if (config.logging.trustProxy) {
1849
- app.set("trust proxy", true);
1878
+ app.set("trust proxy", 1);
1850
1879
  }
1851
1880
  app.use((req, res, next) => {
1852
1881
  const requestId = randomUUID();
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,
@@ -9,14 +9,14 @@ checkout.
9
9
  `open_workspace` returns a `workspaceId`. Continue using that ID for later tools
10
10
  in the same directory.
11
11
 
12
- Workspace identity follows the canonical opened directory rather than the
13
- conversation/request identity. Reopening the same checkout reuses the same
14
- active workspace even from another conversation. Conversation metadata is used
15
- only to decide whether bootstrap context such as project instructions should be
16
- repeated to that conversation.
12
+ `workspaceId` is a logical conversation handle, not the physical-directory
13
+ identity. Reopening the same checkout in the same conversation keeps that
14
+ logical ID stable. A different conversation normally receives a different
15
+ `workspaceId` even when it points at the same checkout or worktree; pass an
16
+ existing ID explicitly when the user wants to resume that logical workspace.
17
17
 
18
- A Git worktree directory is a separate workspace identity from its source
19
- checkout.
18
+ A Git worktree directory is a separate physical workspace target from its source
19
+ checkout, and each conversation can still have its own logical handle for it.
20
20
 
21
21
  ## Checkout-first behavior
22
22
 
@@ -108,6 +108,37 @@ being injected eagerly. Read the relevant nested file before working under that
108
108
  `FORGERELAY_AGENT_DIR` is not an instruction source; it remains only a compatibility
109
109
  skill-discovery path.
110
110
 
111
+ ## MCP capability loading
112
+
113
+ ForgeRelay keeps callable MCP tools and explanatory capability documentation
114
+ separate. `tools/list` remains the source of truth for what the current server
115
+ actually exposes; 0.3 does not hide callable tools behind documentation.
116
+
117
+ `open_workspace` adds two lightweight discovery surfaces:
118
+
119
+ - `capabilityFingerprint` is returned on every open/resume and includes the
120
+ ForgeRelay version, active tool mode, and stable semantic capability names;
121
+ - `capabilityGuides` is returned with bootstrap context and contains compact
122
+ descriptors for ForgeRelay-owned, versioned guides that can be loaded with
123
+ the normal `read` tool.
124
+
125
+ Do not preload every capability guide. Read a guide only when the current task
126
+ needs that domain. Built-in guides cover lifecycle Hooks, advanced managed
127
+ worktrees, subagents, artifact/change-review workflows, Host/OAuth/MCP App
128
+ integration, and long-running shell/PTY/process behavior. Optional guides are
129
+ advertised only when their feature is enabled; for example, disabled subagents
130
+ and artifact/change-review features do not add those descriptors to bootstrap
131
+ context. Reopening a workspace in the same Host context does not repeat the
132
+ descriptors, but the previously advertised guides remain valid.
133
+
134
+ The fingerprint is also a stale-Host-schema diagnostic. If `open_workspace`
135
+ reports a capability such as `filesystem.rename-move` but the Host's current
136
+ MCP tool snapshot does not expose `rename`, the server and Host metadata are out
137
+ of sync. Refresh/reconnect the MCP integration or start a Host context that
138
+ reloads `tools/list`; do not conclude that the running ForgeRelay server lacks
139
+ that capability. ForgeRelay can report its own version/capabilities but cannot
140
+ force the Host to discard a cached tool schema.
141
+
111
142
  ## Agent Skills
112
143
 
113
144
  ForgeRelay discovers standard Agent Skills from:
@@ -134,7 +165,11 @@ config directory plus:
134
165
  ```
135
166
 
136
167
  The workspace result exposes only compact profile metadata so the host can
137
- choose a provider/profile without loading full provider launch details.
168
+ choose a provider/profile without loading full provider launch details. Read the
169
+ ForgeRelay-owned `subagents` capability guide when delegation is actually needed;
170
+ 0.3 no longer auto-loads the historical bundled `subagent-delegation` Skill for
171
+ new setups. Existing user-authored or previously seeded Skills remain normal
172
+ user configuration and are not deleted.
138
173
 
139
174
  The current model-facing delegation workflow is:
140
175
 
@@ -178,7 +213,8 @@ same workspace ID. The former process `sessionId` remains a deprecated alias in
178
213
 
179
214
  Experimental `FORGERELAY_TOOL_MODE=codex` provides a smaller Codex-shaped
180
215
  surface including direct `rename`/`delete` path mutations alongside `apply_patch`,
181
- `exec_command`, and `write_stdin`.
216
+ `exec_command`, and `write_stdin`. `rename` is the unified move/rename primitive
217
+ for both files and directories; ForgeRelay does not expose a separate `move` tool.
182
218
 
183
219
  Workspace IDs are logical conversation handles rather than physical-directory
184
220
  identities. The same conversation keeps a stable ID for a project, while another
@@ -193,10 +229,16 @@ be released that way and must be finalized with `close_worktree`.
193
229
  Shell commands are allowed to modify ordinary project files when that is a
194
230
  natural part of the user's requested development task; ForgeRelay does not apply
195
231
  a blanket ban to package managers, generators, formatters, or similar commands
196
- that write files. The Agent contract still prohibits shell mutation of
197
- security- or privilege-sensitive operating-system files and credential material,
198
- and requires an explicit user request before changing configuration files
199
- through `bash` or `exec_command`.
232
+ that write files. They may also perform external device or hardware mutations
233
+ when the user's current request explicitly asks for the actual device-changing
234
+ operation. A check, audit, probe, backup, verification, dry-run, or build-only
235
+ request does not implicitly authorize a later persistent device write, and
236
+ ForgeRelay does not assume a particular flashing protocol or transport.
237
+
238
+ The Agent contract still prohibits shell mutation of security- or
239
+ privilege-sensitive operating-system files and credential material, and requires
240
+ an explicit user request before changing configuration files through `bash` or
241
+ `exec_command`.
200
242
 
201
243
  ## Change review UI
202
244
 
@@ -123,6 +123,35 @@ MCP clients discover metadata from:
123
123
  explicit tool mode is unset. The corresponding legacy `DEVSPACE_*` names are
124
124
  also accepted.
125
125
 
126
+ The selected mode controls the real `tools/list` surface. ForgeRelay does not
127
+ hide callable tools behind capability documentation. In every mode,
128
+ `open_workspace` returns a `capabilityFingerprint` containing the package
129
+ version, tool mode, and stable semantic capability names. The fingerprint also
130
+ reports enabled optional domains such as subagent profile discovery, native
131
+ artifact download, MCP App UI, or aggregate `show_changes` review when those
132
+ features are actually available; it remains a semantic summary rather than a
133
+ copy of `tools/list`.
134
+
135
+ Bootstrap responses also return `capabilityGuides`, which are compact descriptors
136
+ for built-in ForgeRelay documentation that the Agent can explicitly load with
137
+ `read` when a task needs that domain. Current built-in domains cover lifecycle
138
+ Hooks, managed worktrees, subagents, artifact/change-review workflows, Host/OAuth/
139
+ MCP App integration, and long-running shell/PTY/process behavior. Optional-domain
140
+ descriptors such as `subagents` and `artifacts-review` are advertised only when
141
+ the corresponding feature is enabled, so disabled features do not add bootstrap
142
+ context.
143
+
144
+ There is no separate progressive-disclosure configuration switch. Capability
145
+ Guide discovery is built in, while actual tool exposure continues to be
146
+ controlled by `FORGERELAY_TOOL_MODE` and feature-specific settings. If the
147
+ fingerprint reports a capability that is missing from the Host's current tool
148
+ snapshot, treat that as stale Host MCP metadata: reconnect/refresh the integration
149
+ or use a Host context that reloads `tools/list`. The ForgeRelay process cannot
150
+ force a Host to invalidate its cached schema.
151
+
152
+ `rename` is the canonical move/rename primitive for files and directories; there
153
+ is no separate `move` MCP tool.
154
+
126
155
  Codex-mode commands run without a PTY by default. `tty: true` enables interactive
127
156
  programs when the optional `node-pty` dependency is available.
128
157
 
@@ -316,7 +345,8 @@ When subagents are enabled, profiles are discovered from:
316
345
  - active legacy config directory `~/.devspace/agents/*.md` when reused;
317
346
  - project `.devspace/agents/*.md` for migration compatibility.
318
347
 
319
- The bundled `subagent-delegation` skill teaches the current CLI workflow:
348
+ The ForgeRelay-owned `subagents` capability guide teaches the current CLI
349
+ workflow on demand:
320
350
 
321
351
  ```bash
322
352
  forgerelay agents ls
@@ -324,6 +354,11 @@ forgerelay agents run <profile-or-provider-or-id> "<prompt>"
324
354
  forgerelay agents show <id>
325
355
  ```
326
356
 
357
+ 0.3 no longer auto-discovers or seeds the package's historical bundled
358
+ `subagent-delegation` Skill for new setups. An existing or user-authored Skill
359
+ with that name remains an ordinary Skill and is still discovered from the normal
360
+ Skill paths when subagents are enabled; ForgeRelay does not delete or rewrite it.
361
+
327
362
  ## Logging
328
363
 
329
364
  | Variable | Default |
@@ -334,7 +369,7 @@ forgerelay agents show <id>
334
369
  | `FORGERELAY_LOG_ASSETS` | `0` |
335
370
  | `FORGERELAY_LOG_TOOL_CALLS` | `1` |
336
371
  | `FORGERELAY_LOG_SHELL_COMMANDS` | `1` in `pretty`, `0` in `json` |
337
- | `FORGERELAY_TRUST_PROXY` | `0` |
372
+ | `FORGERELAY_TRUST_PROXY` | auto: `1` only for loopback bind + non-loopback public URL; otherwise `0` |
338
373
 
339
374
  `pretty` is the human-facing local console format. It uses terminal-aware color,
340
375
  short timestamps, workspace-first context, and compact operation results while
@@ -351,6 +386,14 @@ overridden, JSON mode preserves request logging and omits shell command previews
351
386
  `FORGERELAY_LOG_REQUESTS` and `FORGERELAY_LOG_SHELL_COMMANDS` always override
352
387
  these format-specific defaults when set.
353
388
 
389
+ When ForgeRelay binds to loopback (`127.0.0.1`, `::1`, or `localhost`) but is
390
+ configured with a non-loopback public URL, it automatically trusts exactly one
391
+ upstream proxy hop. This matches the normal tunnel/reverse-proxy topology and
392
+ keeps OAuth rate limiting aligned with Express client-IP resolution. Set
393
+ `FORGERELAY_TRUST_PROXY=0` to disable this inference, or `=1` to enable one-hop
394
+ trust explicitly. ForgeRelay never auto-enables proxy trust when binding to
395
+ `0.0.0.0` or another directly reachable interface.
396
+
354
397
  ## Environment-only example
355
398
 
356
399
  ```bash