@akira-tl/forgerelay 0.2.6 → 0.3.1

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/hooks.js CHANGED
@@ -215,9 +215,12 @@ export class HookRunner {
215
215
  const handlers = [
216
216
  ...(this.hooks[event] ?? []).map((rule) => ({ scope: "global", rule })),
217
217
  ...(project.hooks[event] ?? []).map((rule) => ({ scope: "project", rule })),
218
- ]
219
- .filter(({ rule }) => hookRuleMatches(rule.matcher, invocation))
220
- .flatMap(({ scope, rule }) => rule.handlers.map((handler) => ({ scope, handler })));
218
+ ].flatMap(({ scope, rule }) => {
219
+ const matchedInvocation = matchHookRule(rule.matcher, invocation);
220
+ if (!matchedInvocation)
221
+ return [];
222
+ return rule.handlers.map((handler) => ({ scope, handler, invocation: matchedInvocation }));
223
+ });
221
224
  const blocking = BLOCKING_EVENTS.has(event);
222
225
  const executions = project.diagnostic
223
226
  ? [{
@@ -230,8 +233,8 @@ export class HookRunner {
230
233
  error: project.diagnostic,
231
234
  }]
232
235
  : [];
233
- for (const [index, { scope, handler }] of handlers.entries()) {
234
- const execution = await this.runHandler(event, handler, index, invocation, scope);
236
+ for (const [index, { scope, handler, invocation: matchedInvocation }] of handlers.entries()) {
237
+ const execution = await this.runHandler(event, handler, index, matchedInvocation, scope);
235
238
  executions.push(execution);
236
239
  logEvent(this.logging, execution.status === "passed" ? "info" : "warn", "hook_call", {
237
240
  hookEvent: event,
@@ -447,20 +450,33 @@ export async function loadProjectHookConfig(workspaceRoot) {
447
450
  ...(diagnostics.length > 0 ? { diagnostic: diagnostics.join(" | ") } : {}),
448
451
  };
449
452
  }
450
- function hookRuleMatches(matcher, invocation) {
453
+ function matchHookRule(matcher, invocation) {
451
454
  if (!matcher)
452
- return true;
455
+ return invocation;
453
456
  if (matcher.workspaceMode && invocation.workspaceMode !== matcher.workspaceMode)
454
- return false;
457
+ return undefined;
455
458
  if (matcher.tool) {
456
459
  if (typeof invocation.payload?.tool !== "string" || invocation.payload.tool !== matcher.tool) {
457
- return false;
460
+ return undefined;
458
461
  }
459
462
  }
463
+ let matchedInvocation = invocation;
460
464
  if (matcher.commandRegex) {
461
465
  const command = invocation.payload?.command;
462
- if (typeof command !== "string" || !new RegExp(matcher.commandRegex).test(command)) {
463
- return false;
466
+ if (typeof command !== "string")
467
+ return undefined;
468
+ const commandMatch = new RegExp(matcher.commandRegex).exec(command);
469
+ if (!commandMatch)
470
+ return undefined;
471
+ if (commandMatch[0] !== command) {
472
+ matchedInvocation = {
473
+ ...invocation,
474
+ payload: {
475
+ ...invocation.payload,
476
+ command: commandMatch[0],
477
+ originalCommand: command,
478
+ },
479
+ };
464
480
  }
465
481
  }
466
482
  if (matcher.pathRegex) {
@@ -471,15 +487,15 @@ function hookRuleMatches(matcher, invocation) {
471
487
  const matchesPath = typeof path === "string" && pathPattern.test(path);
472
488
  const matchesPaths = Array.isArray(paths) && paths.some((entry) => typeof entry === "string" && new RegExp(pathRegex).test(entry));
473
489
  if (!matchesPath && !matchesPaths)
474
- return false;
490
+ return undefined;
475
491
  }
476
492
  if (matcher.provider) {
477
493
  if (typeof invocation.payload?.provider !== "string" ||
478
494
  invocation.payload.provider !== matcher.provider) {
479
- return false;
495
+ return undefined;
480
496
  }
481
497
  }
482
- return true;
498
+ return matchedInvocation;
483
499
  }
484
500
  function hookEnvironment(baseEnv, event, invocation) {
485
501
  return {
package/dist/logger.js CHANGED
@@ -47,15 +47,7 @@ export function logEvent(config, level, event, fields = {}) {
47
47
  console.log(line);
48
48
  }
49
49
  }
50
- export function requestIp(req, trustProxy) {
51
- if (trustProxy) {
52
- const cfConnectingIp = firstHeaderValue(req.header("cf-connecting-ip"));
53
- if (cfConnectingIp)
54
- return cfConnectingIp;
55
- const forwardedFor = firstHeaderValue(req.header("x-forwarded-for"));
56
- if (forwardedFor)
57
- return forwardedFor;
58
- }
50
+ export function requestIp(req) {
59
51
  return req.ip ?? req.socket.remoteAddress;
60
52
  }
61
53
  export function requestPath(req) {
@@ -94,9 +86,6 @@ export function formatPrettyLogEntry(entry, options = {}) {
94
86
  ].filter((value) => Boolean(value)).join(" ");
95
87
  return `${prefix} ${formatPrettyMessage(entry, options)}`;
96
88
  }
97
- function firstHeaderValue(value) {
98
- return value?.split(",")[0]?.trim() || undefined;
99
- }
100
89
  function formatPrettyMessage(entry, options) {
101
90
  switch (String(entry.event)) {
102
91
  case "tool_call":
@@ -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"),
@@ -305,6 +317,9 @@ ${stylesheets}
305
317
  </body>
306
318
  </html>`;
307
319
  }
320
+ function appDomain(config) {
321
+ return new URL(config.publicBaseUrl).origin;
322
+ }
308
323
  function appCsp(config) {
309
324
  const publicBaseUrl = config.publicBaseUrl.replace(/\/+$/, "");
310
325
  return {
@@ -348,6 +363,7 @@ async function readWorkspaceAppResource(config, requestedUri, transportSessionId
348
363
  text: workspaceAppHtml(config),
349
364
  _meta: {
350
365
  ui: {
366
+ domain: appDomain(config),
351
367
  csp: appCsp(config),
352
368
  },
353
369
  },
@@ -639,15 +655,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
639
655
  version: FORGERELAY_VERSION,
640
656
  description: "Secure local coding workspace for MCP clients. Provides workspace-scoped file, search, edit, write, and shell tools.",
641
657
  }, {
642
- instructions: buildServerInstructions(config, {
643
- artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
644
- }),
658
+ instructions: buildServerInstructions(config),
645
659
  });
646
660
  const currentWorkspaceAppUri = currentWorkspaceAppIdentity().uri;
647
661
  const workspaceAppResourceMetadata = {
648
662
  description: "Interactive card for viewing ForgeRelay file diffs.",
649
663
  _meta: {
650
664
  ui: {
665
+ domain: appDomain(config),
651
666
  csp: appCsp(config),
652
667
  },
653
668
  },
@@ -660,7 +675,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
660
675
  }, async (uri, _variables, extra) => readWorkspaceAppResource(config, uri.toString(), extra.sessionId));
661
676
  registerAppTool(server, "open_workspace", {
662
677
  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.",
678
+ 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
679
  inputSchema: {
665
680
  path: z
666
681
  .string()
@@ -724,6 +739,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
724
739
  targetBranch: z.string().optional(),
725
740
  managed: z.boolean(),
726
741
  })),
742
+ capabilityFingerprint: capabilityFingerprintOutputSchema,
743
+ capabilityGuides: z.array(capabilityGuideOutputSchema).optional(),
727
744
  agentsFiles: z.array(workspaceAgentsFileOutputSchema).optional(),
728
745
  availableAgentsFiles: z.array(workspaceAvailableAgentsFileOutputSchema).optional(),
729
746
  skills: z.array(workspaceSkillOutputSchema).optional(),
@@ -747,6 +764,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
747
764
  });
748
765
  const knownWorktrees = await workspaces.listKnownWorktrees(workspace);
749
766
  const staleWorkspaces = await workspaces.listStaleWorkspaces(workspace);
767
+ const capabilityFingerprint = buildCapabilityFingerprint(config, FORGERELAY_VERSION, {
768
+ artifactDownloadSupported: isArtifactDownloadSupportedPlatform(),
769
+ });
750
770
  if (config.widgets === "changes") {
751
771
  await reviewCheckpoints.initializeWorkspace({
752
772
  workspaceId: workspace.id,
@@ -760,6 +780,12 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
760
780
  description: skill.description,
761
781
  path: formatPathForPrompt(skill.filePath),
762
782
  }));
783
+ const capabilityGuides = workspace.capabilityGuides.map((guide) => ({
784
+ name: guide.name,
785
+ description: guide.description,
786
+ whenToRead: guide.whenToRead,
787
+ path: formatPathForPrompt(guide.filePath),
788
+ }));
763
789
  const cardAgentProviders = config.subagents ? localAgentProviders : [];
764
790
  const cardAgents = workspace.agentProfiles.map((profile) => {
765
791
  const summary = summarizeLocalAgentProfile(profile);
@@ -778,13 +804,14 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
778
804
  path: formatAgentsPath(file.path, workspace.root),
779
805
  }));
780
806
  const visibleSkills = includeBootstrapContext ? cardSkills : [];
807
+ const visibleCapabilityGuides = includeBootstrapContext ? capabilityGuides : [];
781
808
  const visibleAgentProviders = includeBootstrapContext ? cardAgentProviders : [];
782
809
  const visibleAgents = includeBootstrapContext ? cardAgents : [];
783
810
  const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : [];
784
811
  const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : [];
785
812
  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.";
813
+ ? "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."
814
+ : "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
815
  const instruction = workspaceReused
789
816
  ? includeBootstrapContext
790
817
  ? [
@@ -795,7 +822,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
795
822
  : [
796
823
  `Workspace already open as ${workspace.id}.`,
797
824
  "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.",
825
+ "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
826
  ].join("\n\n")
800
827
  : workspace.mode === "worktree"
801
828
  ? "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 +847,9 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
820
847
  visibleSkills.length > 0
821
848
  ? `Available skills: ${visibleSkills.map((skill) => skill.name).join(", ")}`
822
849
  : undefined,
850
+ visibleCapabilityGuides.length > 0
851
+ ? `Capability guides: ${visibleCapabilityGuides.map((guide) => guide.name).join(", ")}`
852
+ : undefined,
823
853
  visibleAgentProviders.some((provider) => provider.available)
824
854
  ? `Available subagent providers: ${visibleAgentProviders.filter((provider) => provider.available).map((provider) => provider.name).join(", ")}`
825
855
  : undefined,
@@ -835,6 +865,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
835
865
  staleWorkspaces.length > 0
836
866
  ? `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
867
  : undefined,
868
+ `ForgeRelay ${capabilityFingerprint.version} capabilities: ${capabilityFingerprint.capabilities.join(", ")}`,
838
869
  instruction,
839
870
  ].filter(Boolean).join("\n"),
840
871
  },
@@ -861,6 +892,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
861
892
  worktree: workspace.worktree,
862
893
  worktrees: knownWorktrees,
863
894
  staleWorkspaces,
895
+ capabilityFingerprint,
864
896
  agentsFiles: cardAgentsFiles,
865
897
  availableAgentsFiles: cardAvailableAgentsFiles,
866
898
  skills: cardSkills,
@@ -885,8 +917,10 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
885
917
  worktree: workspace.worktree,
886
918
  worktrees: knownWorktrees,
887
919
  staleWorkspaces,
920
+ capabilityFingerprint,
888
921
  ...(includeBootstrapContext
889
922
  ? {
923
+ capabilityGuides: visibleCapabilityGuides,
890
924
  agentsFiles: loadedAgentsFiles,
891
925
  availableAgentsFiles: availableAgentsFileOutputs,
892
926
  skills: visibleSkills,
@@ -901,7 +935,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
901
935
  });
902
936
  registerAppTool(server, toolNames.closeWorkspace, {
903
937
  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.",
938
+ 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
939
  inputSchema: {
906
940
  workspaceId: z.string().describe("Logical workspace ID to release."),
907
941
  },
@@ -929,7 +963,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
929
963
  });
930
964
  registerAppTool(server, toolNames.closeWorktree, {
931
965
  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.",
966
+ 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
967
  inputSchema: {
934
968
  workspaceId: z
935
969
  .string()
@@ -1010,8 +1044,8 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
1010
1044
  path: z
1011
1045
  .string()
1012
1046
  .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."),
1047
+ ? "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."
1048
+ : "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
1049
  offset: z
1016
1050
  .number()
1017
1051
  .int()
@@ -1846,7 +1880,7 @@ export function createServer(config = loadConfig(), options = {}) {
1846
1880
  }, MCP_TRANSPORT_CLEANUP_INTERVAL_MS);
1847
1881
  transportCleanupTimer.unref();
1848
1882
  if (config.logging.trustProxy) {
1849
- app.set("trust proxy", true);
1883
+ app.set("trust proxy", 1);
1850
1884
  }
1851
1885
  app.use((req, res, next) => {
1852
1886
  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