@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/CHANGELOG.md +30 -0
- package/README.md +15 -0
- package/capabilities/artifacts-review/GUIDE.md +42 -0
- package/capabilities/host-integration/GUIDE.md +68 -0
- package/capabilities/lifecycle-hooks/GUIDE.md +39 -0
- package/capabilities/managed-worktrees/GUIDE.md +43 -0
- package/capabilities/shell-processes/GUIDE.md +51 -0
- package/capabilities/subagents/GUIDE.md +69 -0
- package/dist/advertised-files.js +23 -0
- package/dist/capabilities.js +99 -0
- package/dist/cli.js +8 -3
- package/dist/config.js +11 -3
- package/dist/hooks.js +30 -14
- package/dist/logger.js +1 -12
- package/dist/mcp/server-instructions.js +15 -30
- package/dist/server.js +47 -13
- package/dist/skills.js +11 -30
- package/dist/workspaces.js +16 -0
- package/docs/chatgpt-coding-workflow.md +55 -13
- package/docs/configuration.md +47 -4
- package/docs/debugging.md +1 -1
- package/docs/roadmap.md +77 -33
- package/docs/security.md +24 -7
- package/docs/setup.md +3 -1
- package/docs/versioning.md +3 -1
- package/package.json +4 -3
- package/scripts/debug/accept.mjs +65 -8
- package/scripts/ensure-cli-executable.mjs +12 -0
- package/scripts/release-version.mjs +24 -1
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
|
-
|
|
220
|
-
|
|
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,
|
|
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
|
|
453
|
+
function matchHookRule(matcher, invocation) {
|
|
451
454
|
if (!matcher)
|
|
452
|
-
return
|
|
455
|
+
return invocation;
|
|
453
456
|
if (matcher.workspaceMode && invocation.workspaceMode !== matcher.workspaceMode)
|
|
454
|
-
return
|
|
457
|
+
return undefined;
|
|
455
458
|
if (matcher.tool) {
|
|
456
459
|
if (typeof invocation.payload?.tool !== "string" || invocation.payload.tool !== matcher.tool) {
|
|
457
|
-
return
|
|
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"
|
|
463
|
-
return
|
|
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
|
|
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
|
|
495
|
+
return undefined;
|
|
480
496
|
}
|
|
481
497
|
}
|
|
482
|
-
return
|
|
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
|
|
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
|
|
20
|
-
return joinInstructions(capabilityContractInstructions(config
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
42
|
-
const
|
|
43
|
-
?
|
|
44
|
-
: `
|
|
45
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
787
|
-
: "Use this workspaceId in all subsequent tool calls for this project.
|
|
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
|
|
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: "
|
|
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
|
|
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",
|
|
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 {
|
|
4
|
+
import { markAdvertisedFileSourceActivated, resolveAdvertisedFileReadPath, } from "./advertised-files.js";
|
|
5
5
|
import { loadSkills, } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { expandHomePath
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
59
|
+
markAdvertisedFileSourceActivated(activatedSkillDirs, skill);
|
|
79
60
|
}
|
|
80
61
|
export function formatPathForPrompt(path) {
|
|
81
62
|
const home = resolve(homedir());
|
package/dist/workspaces.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|