@akira-tl/forgerelay 0.8.9 → 0.8.10
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 +20 -0
- package/capabilities/code-intelligence/GUIDE.md +7 -3
- package/capabilities/subagents/GUIDE.md +9 -0
- package/capabilities/workspace/workspace-tasks/GUIDE.md +6 -0
- package/dist/cli/init.js +180 -0
- package/dist/cli/setup-support.js +65 -0
- package/dist/cli.js +9 -94
- package/dist/lsp/code-intelligence.js +17 -3
- package/dist/lsp/runtime/diagnostic-snapshots.js +52 -1
- package/dist/lsp/runtime/managed-language-servers.js +172 -0
- package/dist/lsp/runtime/manager.js +37 -2
- package/dist/lsp/runtime/process-launch.js +3 -0
- package/dist/lsp/test-support/server-fixture.js +5 -2
- package/dist/mcp/process/process-platform.js +21 -9
- package/dist/mcp/process/process-sessions.js +3 -3
- package/dist/mcp/process/tools.js +6 -6
- package/dist/mcp/server/core/capability-registry.js +7 -2
- package/dist/mcp/server/core/tool-support.js +8 -5
- package/dist/mcp/server/operations/runtime/filesystem-tools.js +74 -19
- package/dist/mcp/server/operations/runtime/mutation-diagnostics.js +54 -0
- package/dist/mcp/server/operations/runtime/operation-runtime.js +8 -7
- package/dist/mcp/server/workspace/runtime/workspace-open-presentation.js +28 -8
- package/dist/mcp/server/workspace/runtime/workspace-open.js +4 -1
- package/dist/mcp/server/workspace/runtime/workspace-tools.js +3 -3
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/runtime/config/config.js +1 -0
- package/dist/runtime/config/user-config.js +1 -10
- package/dist/server.js +36 -6
- package/dist/workspaces/context.js +35 -0
- package/dist/workspaces/relay/transport/remote-transport.js +30 -3
- package/dist/workspaces/resources/resource-monitor.js +377 -0
- package/dist/workspaces/resources/skills.js +13 -15
- package/dist/workspaces/sessions.js +3 -0
- package/dist/workspaces.js +11 -0
- package/docs/chatgpt-coding-workflow.md +4 -3
- package/docs/configuration.md +14 -9
- package/package.json +2 -3
- package/scripts/release/publish.mjs +12 -9
- package/scripts/release/release-gate.test.mjs +3 -1
- package/scripts/release/release-version.test.mjs +13 -1
- package/scripts/release-parity.mjs +1 -1
- package/scripts/release-proof.mjs +15 -0
- package/scripts/release-proof.test.mjs +27 -1
- package/scripts/release-version.mjs +16 -1
- package/skills/subagent-delegation/SKILL.md +0 -132
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.10] - 2026-09-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Added user-controlled managed Language Server installation for TypeScript/JavaScript code intelligence, including private TypeScript SDK wiring, live runtime refresh, and post-mutation diagnostics without restarting ForgeRelay.
|
|
12
|
+
- Added explicit setup flows for local/SSH-only use versus network-exposed ForgeRelay deployments.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Workspace instructions and Skills now stream bounded live deltas after a Workspace is opened, while Agents are prompted to maintain `workspace.tasks` proactively for long-running work instead of querying Tasks mechanically on every open.
|
|
17
|
+
- Agent Bash execution now uses an isolated non-interactive shell environment, and SSH-assisted authentication no longer sources the user's interactive shell configuration.
|
|
18
|
+
- Retired the bundled `subagent-delegation` Skill; required delegation behavior remains in the Subagent capability contract and guide.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Increased the default foreground process wait and stabilized cancellation/concurrency behavior around Language Server diagnostics.
|
|
23
|
+
- Fixed Windows `.cmd`/`.bat` Language Server launchers and kept ordinary external LSP interoperability independent of ForgeRelay's managed-LSP config directory.
|
|
24
|
+
- Preserved Windows `cmd.exe` command quoting for isolated Agent Bash execution, including commands whose executable path is already quoted.
|
|
25
|
+
- Hardened release verification with pinned publishing tooling and dedicated per-version release notes.
|
|
26
|
+
|
|
7
27
|
## [0.8.9] - 2026-09-02
|
|
8
28
|
|
|
9
29
|
### Changed
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Code Intelligence
|
|
2
2
|
|
|
3
|
-
Use the `code.intelligence` Capability for
|
|
3
|
+
Use the `code.intelligence` Capability for semantic code navigation, diagnostics, and optional ForgeRelay-managed Language Server installation.
|
|
4
4
|
|
|
5
|
-
ForgeRelay
|
|
5
|
+
ForgeRelay discovers supported executables from project/global configuration, its private managed Language Server directory, and the inherited process `PATH`. Project definitions override global definitions, while built-in discovery can use ForgeRelay-managed executables before falling back to `PATH`. An explicit definition may disable discovery with `enabled: false`.
|
|
6
|
+
|
|
7
|
+
`managed.status` reports which ForgeRelay-managed servers are supported and currently installed. `managed.install` can install `typescript` or `pyright` into ForgeRelay's private config directory, but only when the user has explicitly enabled Agent-managed installation during `forgerelay init`. Installation is persistent and network-active, so it is disabled by default. A successful install requires no ForgeRelay restart: the next semantic request resolves executables again, and any changed Language-service command/fingerprint replaces the prior idle service automatically.
|
|
6
8
|
|
|
7
9
|
ForgeRelay 0.4 LSP v1 exposes `definition`, `hover`, `references`, `documentSymbols`, `workspaceSymbols`, and `diagnostics`. Position-based operations accept a workspace-relative source `path` plus 1-based `line` and `column` values. `documentSymbols` needs only `path` and an optional bounded `limit`. `workspaceSymbols` uses `path` to select the Language project/service and accepts a `query` plus optional `limit`; it does not merge multiple nested Language services. `diagnostics` uses `path` plus an optional bounded `limit`. Columns are Unicode code-point positions; ForgeRelay converts them to the position encoding negotiated with the Language server.
|
|
8
10
|
|
|
@@ -12,6 +14,8 @@ Document symbols preserve hierarchical server responses as a tree and keep flat
|
|
|
12
14
|
|
|
13
15
|
Diagnostics use the same normalized result contract for push and pull providers. When a Language server advertises LSP pull diagnostics, ForgeRelay prefers pull, supplies the previous `resultId` when available, and treats an `unchanged` report as a refresh of the same bounded snapshot for the newly synchronized filesystem version. Otherwise ForgeRelay uses traditional `publishDiagnostics` snapshots. Push diagnostics retain only the latest snapshot for each synchronized document: no diagnostic history is accumulated. A result reports `provider`, bounded normalized diagnostics, `returned`/`truncated`/`total`, and `freshness`. `freshness.state` distinguishes fresh, stale, missing, and unknown snapshots; filesystem changes advance ForgeRelay's synchronized document version, making an older push snapshot detectably stale until the Language server publishes a replacement. Pull and push state are bounded independently so a mixed-capability server cannot overwrite pull `resultId` state with an asynchronous push. All diagnostic state is released with the Language service.
|
|
14
16
|
|
|
17
|
+
Successful `write`, `edit`, `rename`, and Codex `apply_patch` mutations automatically request diagnostics for the affected code files and append non-empty results to the same mutation response. This post-mutation validation does not create a second Activity. Files with no matching Language Server are skipped silently; a Language Server validation failure never rolls back an already-successful file mutation, but ForgeRelay appends a bounded validation warning so the Agent can react instead of forgetting to check.
|
|
18
|
+
|
|
15
19
|
Semantic locations may identify External code locations outside the Workspace, but that does not expand ForgeRelay's allowed roots or grant the file tools permission to read those paths.
|
|
16
20
|
|
|
17
21
|
Language-server definitions use structured process configuration rather than shell command strings. A project configuration entry may contain `command`, `args`, `env`, `languages`, `extensions`, `languageIdByExtension`, `projectMarkers`, and `enabled` fields. Use `languageIdByExtension` when one server definition covers multiple language IDs whose extensions do not map one-to-one by array position. The server command is launched directly without a shell.
|
|
@@ -22,4 +26,4 @@ An unexpected Language-server process exit fails its pending JSON-RPC work immed
|
|
|
22
26
|
|
|
23
27
|
Language services are shared by physical Language project identity rather than logical Workspace id, so multiple conversations over the same checkout do not multiply server processes. Idle services are reclaimed after a bounded TTL, and the global service cap evicts the least-recently-used truly idle service; a request that has returned to the Host but whose server ignored cancellation still counts as active until the underlying LSP request settles. Managed-worktree finalization releases idle Language services rooted in that worktree before removing it and refuses finalization while semantic work is still active. Debug runtime telemetry reports only aggregate service/process/request/document/diagnostic/stderr counts and never source contents.
|
|
24
28
|
|
|
25
|
-
The built-in discovery candidates are `typescript-language-server`, `pyright-langserver`, `rust-analyzer`, `gopls`, and `clangd`.
|
|
29
|
+
The built-in discovery candidates are `typescript-language-server`, `pyright-langserver`, `rust-analyzer`, `gopls`, and `clangd`. `forgerelay init` can install TypeScript/JavaScript (`typescript-language-server` plus TypeScript) and Python (Pyright) into the ForgeRelay config directory without modifying global npm. Rust Analyzer, `gopls`, and `clangd` remain external toolchain/system installations. Contributors can run `npm run lsp:interop` to exercise detected executables through real stdio LSP; the interoperability check itself never downloads or installs a Language server.
|
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
当任务需要委派给另一个本地 coding agent、获取第二意见、并行调查,或用户明确点名 Subagent/provider 时读取本指南。
|
|
4
4
|
|
|
5
|
+
## 委派策略与透明性
|
|
6
|
+
|
|
7
|
+
- 只在用户明确要求委派、点名 Subagent/provider、要求第二意见或并行调查,或任务本身明确需要另一个独立 coding agent 时使用 Subagent;不要因为 profile 存在就静默委派普通工作。
|
|
8
|
+
- 使用 Subagent 时应让用户知道正在委派给哪个 profile/provider;不要隐藏 Subagent 的参与。
|
|
9
|
+
- 不要通过 `bash` 直接启动 `codex`、`claude`、`opencode`、`pi`、`cursor-agent` 或 `copilot` 来绕过 ForgeRelay 的 Session ownership、审计与结果交付。正常 Host 委派使用 `subagent.session`。
|
|
10
|
+
- delegated prompt 必须自包含:说明目标、相关 Workspace/文件、关键约束与验收标准。Subagent 不会自动继承主 Agent 未显式传递的隐含上下文。
|
|
11
|
+
- `reviewer` 适合第二意见/风险/测试缺口,`explorer` 适合只读调查,`implementer` 只在用户允许委派写入工作时使用。
|
|
12
|
+
- Subagent 返回值不是已经验证的最终答案。主 Agent 必须核对关键事实;写入任务检查真实 diff 并运行相关测试,只读调查核对关键路径/符号证据。最终向用户说明使用的 profile/provider、结论、已做验证与剩余风险。
|
|
13
|
+
|
|
5
14
|
## 当前接口
|
|
6
15
|
|
|
7
16
|
启用 Subagent 后,`open_workspace` 会在 `capabilityCatalog` 中公开 `subagent.session`,同时返回紧凑的 provider/profile 元数据。不要为 Subagent 寻找或假设额外的 Core MCP tool;长期可见 Core tool surface 保持不变。
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
- Task 状态只有 `pending`、`in_progress`、`completed`。`content` 保存继续工作真正需要的要求、阻塞点、结论或下一步,而不是日志或对话转录。
|
|
11
11
|
- Task/List ID 创建后保持稳定。完成 Task 不会删除它;删除必须显式执行。
|
|
12
12
|
|
|
13
|
+
## 主动维护
|
|
14
|
+
|
|
15
|
+
把 `workspace.tasks` 当作需要跨多个步骤、较长工作过程或跨会话续接时的轻量工作记忆:当存在值得保留的下一步、阻塞点或当前进展时,主动创建或更新 Task;完成后及时标记 `completed`。不要因为刚执行了 `open_workspace` 就机械查询 Task。
|
|
16
|
+
|
|
17
|
+
需要续接相关未完成工作时,再从 summary 开始渐进读取;只有 summary 表明确有相关未完成 Task,才继续读取 headers/detail。
|
|
18
|
+
|
|
13
19
|
## 渐进式读取
|
|
14
20
|
|
|
15
21
|
Task 读取默认使用渐进式披露,不会一次返回所有 `content`:
|
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import * as prompts from "@clack/prompts";
|
|
3
|
+
import { publicEndpointUrl } from "../mcp/oauth/public-url.js";
|
|
4
|
+
import { expandHomePath } from "../mcp/filesystem/roots.js";
|
|
5
|
+
import { installManagedLanguageServers, installedManagedLanguageServers, managedLanguageServerOptions, } from "../lsp/runtime/managed-language-servers.js";
|
|
6
|
+
import { generateInstanceId, generateOwnerToken, loadForgeRelayFiles, resolveSubagentsFlag, writeForgeRelayAuth, writeForgeRelayConfig, } from "../runtime/config/user-config.js";
|
|
7
|
+
import { classifyClientFacingBaseUrl, compactPublicBaseUrlConfig, hasInsecureLanBaseUrl, isLoopbackBindAddress, normalizePublicBaseUrlsInput, SetupCancelledError, textPrompt, validateBindAddress, validateClientFacingBaseUrls, validatePort, } from "./setup-support.js";
|
|
8
|
+
export async function runInit({ force }) {
|
|
9
|
+
const files = loadForgeRelayFiles();
|
|
10
|
+
if (!force && files.configExists && files.authExists) {
|
|
11
|
+
prompts.log.info(`ForgeRelay is already configured at ${files.dir}`);
|
|
12
|
+
prompts.log.info("Run `forgerelay init --force` to update it.");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
prompts.intro("ForgeRelay setup");
|
|
17
|
+
const defaultRoots = files.config.allowedRoots?.join(", ") || process.cwd();
|
|
18
|
+
const rootsAnswer = await textPrompt({
|
|
19
|
+
message: `Where are your projects located? Press Enter to use ${defaultRoots}`,
|
|
20
|
+
placeholder: defaultRoots,
|
|
21
|
+
defaultValue: defaultRoots,
|
|
22
|
+
validate: (value) => value?.trim() ? undefined : "Enter at least one project root.",
|
|
23
|
+
});
|
|
24
|
+
const allowedRoots = rootsAnswer
|
|
25
|
+
.split(",")
|
|
26
|
+
.map((root) => resolve(expandHomePath(root.trim())))
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
const defaultPort = String(files.config.port ?? 7676);
|
|
29
|
+
const portAnswer = await textPrompt({
|
|
30
|
+
message: `Which local port should ForgeRelay use? Press Enter to use ${defaultPort}`,
|
|
31
|
+
placeholder: defaultPort,
|
|
32
|
+
defaultValue: defaultPort,
|
|
33
|
+
validate: validatePort,
|
|
34
|
+
});
|
|
35
|
+
const port = Number(portAnswer);
|
|
36
|
+
const existingPublicBaseUrls = Array.isArray(files.config.publicBaseUrl)
|
|
37
|
+
? files.config.publicBaseUrl
|
|
38
|
+
: files.config.publicBaseUrl ? [files.config.publicBaseUrl] : [];
|
|
39
|
+
const defaultNetworkMode = !isLoopbackBindAddress(files.config.host ?? "127.0.0.1") || existingPublicBaseUrls.length > 0
|
|
40
|
+
? "network"
|
|
41
|
+
: "local";
|
|
42
|
+
const selectedMode = await prompts.select({
|
|
43
|
+
message: "How should clients reach this ForgeRelay instance?",
|
|
44
|
+
initialValue: defaultNetworkMode,
|
|
45
|
+
options: [
|
|
46
|
+
{
|
|
47
|
+
value: "local",
|
|
48
|
+
label: "Local only",
|
|
49
|
+
hint: "Bind to loopback; local clients only. Client-facing URL is derived automatically.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
value: "ssh",
|
|
53
|
+
label: "SSH relay",
|
|
54
|
+
hint: "Bind to loopback; another ForgeRelay reaches it through an SSH tunnel.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
value: "network",
|
|
58
|
+
label: "LAN / HTTPS proxy",
|
|
59
|
+
hint: "Expose through a LAN address or an HTTPS reverse proxy/tunnel.",
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
if (prompts.isCancel(selectedMode))
|
|
64
|
+
throw new SetupCancelledError();
|
|
65
|
+
const networkMode = selectedMode;
|
|
66
|
+
let host = "127.0.0.1";
|
|
67
|
+
let publicBaseUrl = null;
|
|
68
|
+
let clientFacingBaseUrls = [`http://127.0.0.1:${port}`];
|
|
69
|
+
if (networkMode === "network") {
|
|
70
|
+
const defaultHost = files.config.host && !isLoopbackBindAddress(files.config.host)
|
|
71
|
+
? files.config.host
|
|
72
|
+
: "0.0.0.0";
|
|
73
|
+
host = await textPrompt({
|
|
74
|
+
message: "Which address should ForgeRelay bind to? Use 0.0.0.0 for direct LAN, or 127.0.0.1 behind a local reverse proxy.",
|
|
75
|
+
placeholder: defaultHost,
|
|
76
|
+
defaultValue: defaultHost,
|
|
77
|
+
validate: validateBindAddress,
|
|
78
|
+
});
|
|
79
|
+
const defaultClientFacing = existingPublicBaseUrls.join(", ");
|
|
80
|
+
clientFacingBaseUrls = normalizePublicBaseUrlsInput(await textPrompt({
|
|
81
|
+
message: defaultClientFacing
|
|
82
|
+
? `What client-facing base URLs should clients use? Press Enter to keep ${defaultClientFacing}`
|
|
83
|
+
: "What client-facing base URL should clients use?",
|
|
84
|
+
placeholder: defaultClientFacing || `http://192.168.1.20:${port} or https://forge.example.com`,
|
|
85
|
+
defaultValue: defaultClientFacing,
|
|
86
|
+
validate: validateClientFacingBaseUrls,
|
|
87
|
+
}));
|
|
88
|
+
for (const baseUrl of clientFacingBaseUrls)
|
|
89
|
+
classifyClientFacingBaseUrl(baseUrl);
|
|
90
|
+
if (hasInsecureLanBaseUrl(clientFacingBaseUrls)) {
|
|
91
|
+
prompts.note([
|
|
92
|
+
"Plain HTTP does not encrypt the ForgeRelay Owner approval flow or MCP bearer tokens.",
|
|
93
|
+
"Use this only on a trusted private LAN. Prefer SSH relay or HTTPS when the network is not fully trusted.",
|
|
94
|
+
].join("\n"), "Unencrypted LAN access");
|
|
95
|
+
const approved = await prompts.confirm({
|
|
96
|
+
message: "Allow unencrypted HTTP access on this private network?",
|
|
97
|
+
initialValue: false,
|
|
98
|
+
});
|
|
99
|
+
if (prompts.isCancel(approved) || approved !== true)
|
|
100
|
+
throw new SetupCancelledError();
|
|
101
|
+
}
|
|
102
|
+
publicBaseUrl = compactPublicBaseUrlConfig(clientFacingBaseUrls);
|
|
103
|
+
}
|
|
104
|
+
const installedManaged = installedManagedLanguageServers(files.dir);
|
|
105
|
+
const selectedManaged = await prompts.multiselect({
|
|
106
|
+
message: "Which Language Servers should ForgeRelay manage with npm?",
|
|
107
|
+
options: managedLanguageServerOptions(),
|
|
108
|
+
initialValues: installedManaged,
|
|
109
|
+
required: false,
|
|
110
|
+
});
|
|
111
|
+
if (prompts.isCancel(selectedManaged))
|
|
112
|
+
throw new SetupCancelledError();
|
|
113
|
+
const managedIds = selectedManaged;
|
|
114
|
+
const allowAgentInstallAnswer = await prompts.confirm({
|
|
115
|
+
message: "Allow Agents to install or update ForgeRelay-managed Language Servers on demand?",
|
|
116
|
+
initialValue: files.config.allowAgentLanguageServerInstall === true,
|
|
117
|
+
});
|
|
118
|
+
if (prompts.isCancel(allowAgentInstallAnswer))
|
|
119
|
+
throw new SetupCancelledError();
|
|
120
|
+
const allowAgentLanguageServerInstall = allowAgentInstallAnswer === true;
|
|
121
|
+
if (managedIds.length > 0) {
|
|
122
|
+
prompts.note([
|
|
123
|
+
"ForgeRelay installs these optional Language Servers into its private config directory, not global npm.",
|
|
124
|
+
"Rust Analyzer, gopls, and clangd remain external toolchain/system installations and are auto-detected when available.",
|
|
125
|
+
].join("\n"), "Managed Language Servers");
|
|
126
|
+
const spinner = prompts.spinner();
|
|
127
|
+
spinner.start("Installing managed Language Servers with npm");
|
|
128
|
+
try {
|
|
129
|
+
const installed = await installManagedLanguageServers(managedIds, files.dir);
|
|
130
|
+
spinner.stop(`Installed ${installed.installed.join(", ")} under ${installed.root}`);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
spinner.stop("Managed Language Server installation failed");
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const config = {
|
|
138
|
+
host,
|
|
139
|
+
port,
|
|
140
|
+
allowedRoots,
|
|
141
|
+
publicBaseUrl,
|
|
142
|
+
allowedHosts: files.config.allowedHosts,
|
|
143
|
+
workflowInstructions: files.config.workflowInstructions,
|
|
144
|
+
appendInstructions: files.config.appendInstructions,
|
|
145
|
+
subagents: resolveSubagentsFlag(files.config),
|
|
146
|
+
languageServers: files.config.languageServers,
|
|
147
|
+
allowAgentLanguageServerInstall,
|
|
148
|
+
};
|
|
149
|
+
const auth = {
|
|
150
|
+
...files.auth,
|
|
151
|
+
ownerToken: files.auth.ownerToken ?? generateOwnerToken(),
|
|
152
|
+
instanceId: files.auth.instanceId ?? generateInstanceId(),
|
|
153
|
+
};
|
|
154
|
+
const configPath = writeForgeRelayConfig(config);
|
|
155
|
+
const authPath = await writeForgeRelayAuth(auth);
|
|
156
|
+
const lines = [
|
|
157
|
+
`Config: ${configPath}`,
|
|
158
|
+
`Auth: ${authPath}`,
|
|
159
|
+
`Bind: http://${config.host}:${config.port}`,
|
|
160
|
+
...clientFacingBaseUrls.map((baseUrl, index) => `${index === 0 ? "Client-facing MCP URL" : "Client-facing MCP alias"}: ${publicEndpointUrl(baseUrl, "mcp").toString()}`),
|
|
161
|
+
];
|
|
162
|
+
if (networkMode === "ssh") {
|
|
163
|
+
lines.push(`SSH relay: forgerelay auth -J <ssh-host> 127.0.0.1:${port} --ssh-auth`);
|
|
164
|
+
}
|
|
165
|
+
prompts.note(lines.join("\n"), "ForgeRelay configured");
|
|
166
|
+
prompts.note([
|
|
167
|
+
`Owner password: ${auth.ownerToken}`,
|
|
168
|
+
"Use this when ChatGPT or Claude asks you to approve ForgeRelay access.",
|
|
169
|
+
`Stored at: ${authPath}`,
|
|
170
|
+
].join("\n"), "Owner password");
|
|
171
|
+
prompts.outro("Run `forgerelay serve` to start the MCP server.");
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error instanceof SetupCancelledError) {
|
|
175
|
+
prompts.cancel("Setup cancelled");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
1
2
|
import { createRequire } from "node:module";
|
|
2
3
|
import * as prompts from "@clack/prompts";
|
|
3
4
|
import { satisfies } from "semver";
|
|
@@ -33,6 +34,57 @@ export function normalizePublicBaseUrl(value) {
|
|
|
33
34
|
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
34
35
|
return parsed.toString().replace(/\/$/, "");
|
|
35
36
|
}
|
|
37
|
+
export function classifyClientFacingBaseUrl(value) {
|
|
38
|
+
const parsed = new URL(normalizePublicBaseUrl(value));
|
|
39
|
+
if (parsed.protocol === "https:")
|
|
40
|
+
return "secure";
|
|
41
|
+
if (parsed.protocol !== "http:") {
|
|
42
|
+
throw new Error("Client-facing base URLs must use http:// or https://.");
|
|
43
|
+
}
|
|
44
|
+
if (!isPrivateNetworkHost(parsed.hostname)) {
|
|
45
|
+
throw new Error("Plain HTTP is allowed only for local/LAN addresses. Use HTTPS for public addresses.");
|
|
46
|
+
}
|
|
47
|
+
return "insecure-lan";
|
|
48
|
+
}
|
|
49
|
+
export function validateClientFacingBaseUrls(value) {
|
|
50
|
+
const trimmed = value?.trim() ?? "";
|
|
51
|
+
if (!trimmed)
|
|
52
|
+
return "Enter at least one client-facing base URL.";
|
|
53
|
+
try {
|
|
54
|
+
for (const baseUrl of normalizePublicBaseUrlsInput(trimmed))
|
|
55
|
+
classifyClientFacingBaseUrl(baseUrl);
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return error instanceof Error ? error.message : String(error);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function hasInsecureLanBaseUrl(baseUrls) {
|
|
63
|
+
return baseUrls.some((baseUrl) => classifyClientFacingBaseUrl(baseUrl) === "insecure-lan");
|
|
64
|
+
}
|
|
65
|
+
function isPrivateNetworkHost(hostname) {
|
|
66
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
67
|
+
if (host === "localhost" || !host.includes(".") || host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".home.arpa")) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const family = isIP(host);
|
|
71
|
+
if (family === 4) {
|
|
72
|
+
const [a, b] = host.split(".").map(Number);
|
|
73
|
+
return a === 10
|
|
74
|
+
|| a === 127
|
|
75
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
76
|
+
|| (a === 192 && b === 168)
|
|
77
|
+
|| (a === 169 && b === 254)
|
|
78
|
+
|| (a === 100 && b >= 64 && b <= 127);
|
|
79
|
+
}
|
|
80
|
+
if (family === 6) {
|
|
81
|
+
return host === "::1"
|
|
82
|
+
|| host.startsWith("fc")
|
|
83
|
+
|| host.startsWith("fd")
|
|
84
|
+
|| /^fe[89ab]/.test(host);
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
36
88
|
export async function textPrompt(options) {
|
|
37
89
|
const result = await prompts.text({
|
|
38
90
|
...options,
|
|
@@ -49,6 +101,19 @@ export function validatePort(value) {
|
|
|
49
101
|
? undefined
|
|
50
102
|
: "Enter a port between 1 and 65535.";
|
|
51
103
|
}
|
|
104
|
+
export function isLoopbackBindAddress(value) {
|
|
105
|
+
const host = value.trim().toLowerCase();
|
|
106
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
107
|
+
}
|
|
108
|
+
export function validateBindAddress(value) {
|
|
109
|
+
const host = value?.trim() ?? "";
|
|
110
|
+
if (!host)
|
|
111
|
+
return "Enter a bind address.";
|
|
112
|
+
if (/\s|:\/\//.test(host) || host.includes("/")) {
|
|
113
|
+
return "Enter only a host or IP address, not a URL.";
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
52
117
|
export function validateRequiredPublicBaseUrls(value) {
|
|
53
118
|
const trimmed = value?.trim() ?? "";
|
|
54
119
|
if (!trimmed)
|
package/dist/cli.js
CHANGED
|
@@ -9,19 +9,19 @@ import { join, resolve } from "node:path";
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import * as prompts from "@clack/prompts";
|
|
11
11
|
import { loadConfig } from "./runtime/config/config.js";
|
|
12
|
+
import { runInit } from "./cli/init.js";
|
|
12
13
|
import { runHooksCommand } from "./mcp/hooks/hook-cli.js";
|
|
13
14
|
import { executeSubagentSession } from "./subagents/sessions/execution.js";
|
|
14
15
|
import { SubagentDeliveryMailbox } from "./subagents/sessions/delivery-mailbox.js";
|
|
15
16
|
import { SubagentSessionManager } from "./subagents/sessions/manager.js";
|
|
16
17
|
import { formatSubagentProviderAvailabilitySummary } from "./subagents/providers/availability.js";
|
|
17
18
|
import { parseSubagentRunArgs } from "./subagents/cli-target.js";
|
|
18
|
-
import { ensureForgeRelayInstanceId,
|
|
19
|
-
import { expandHomePath } from "./mcp/filesystem/roots.js";
|
|
19
|
+
import { ensureForgeRelayInstanceId, loadForgeRelayFiles, removeForgeRelayRemote, renameForgeRelayRemote, writeForgeRelayConfig, writeForgeRelayRemote, } from "./runtime/config/user-config.js";
|
|
20
20
|
import { shutdownHttpServer } from "./mcp/server/transport/server-shutdown.js";
|
|
21
21
|
import { publicEndpointUrl } from "./mcp/oauth/public-url.js";
|
|
22
22
|
import { authenticateRemote, defaultRemoteAlias, isRemoteMcpUnauthorized, normalizeRemoteServiceTarget, refreshRemoteAuthentication, verifyRemoteMcp, } from "./workspaces/relay/auth/remote-auth.js";
|
|
23
23
|
import { defaultSshRouteAlias, parseSshRoute, readRemoteOwnerToken, withRemoteServiceEndpoint, } from "./workspaces/relay/transport/remote-transport.js";
|
|
24
|
-
import {
|
|
24
|
+
import { assertSupportedNode, checkBashShell, checkGitAvailable, checkSqliteNative, nodeVersionStatus, normalizeOptionalPublicBaseUrl, } from "./cli/setup-support.js";
|
|
25
25
|
const require = createRequire(import.meta.url);
|
|
26
26
|
async function main(argv) {
|
|
27
27
|
assertSupportedNode();
|
|
@@ -91,92 +91,6 @@ async function ensureConfigured() {
|
|
|
91
91
|
}
|
|
92
92
|
await runInit({ force: false });
|
|
93
93
|
}
|
|
94
|
-
async function runInit({ force }) {
|
|
95
|
-
const files = loadForgeRelayFiles();
|
|
96
|
-
if (!force && files.configExists && files.authExists) {
|
|
97
|
-
prompts.log.info(`ForgeRelay is already configured at ${files.dir}`);
|
|
98
|
-
prompts.log.info("Run `forgerelay init --force` to update it.");
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
try {
|
|
102
|
-
prompts.intro("ForgeRelay setup");
|
|
103
|
-
const defaultRoots = files.config.allowedRoots?.join(", ") || process.cwd();
|
|
104
|
-
const rootsAnswer = await textPrompt({
|
|
105
|
-
message: `Where are your projects located? Press Enter to use ${defaultRoots}`,
|
|
106
|
-
placeholder: defaultRoots,
|
|
107
|
-
defaultValue: defaultRoots,
|
|
108
|
-
validate: (value) => value?.trim() ? undefined : "Enter at least one project root.",
|
|
109
|
-
});
|
|
110
|
-
const allowedRoots = rootsAnswer
|
|
111
|
-
.split(",")
|
|
112
|
-
.map((root) => resolve(expandHomePath(root.trim())))
|
|
113
|
-
.filter(Boolean);
|
|
114
|
-
const defaultPort = String(files.config.port ?? 7676);
|
|
115
|
-
const portAnswer = await textPrompt({
|
|
116
|
-
message: `Which local port should ForgeRelay use? Press Enter to use ${defaultPort}`,
|
|
117
|
-
placeholder: defaultPort,
|
|
118
|
-
defaultValue: defaultPort,
|
|
119
|
-
validate: validatePort,
|
|
120
|
-
});
|
|
121
|
-
const port = Number(portAnswer);
|
|
122
|
-
prompts.note([
|
|
123
|
-
"ForgeRelay needs one or more public base URLs so ChatGPT or Claude can reach this MCP server.",
|
|
124
|
-
"Create a tunnel or reverse proxy with Cloudflare Tunnel, ngrok, Pinggy, Tailscale Funnel, or your own HTTPS proxy.",
|
|
125
|
-
"Each URL may include its own route prefix. Separate multiple URLs with commas; the first is canonical.",
|
|
126
|
-
"",
|
|
127
|
-
"Example: https://forge.example.com/forgerelay/main, https://alias.example.com/relay",
|
|
128
|
-
].join("\n"), "Public URL required");
|
|
129
|
-
const defaultPublicBaseUrls = Array.isArray(files.config.publicBaseUrl)
|
|
130
|
-
? files.config.publicBaseUrl.join(", ")
|
|
131
|
-
: files.config.publicBaseUrl ?? "";
|
|
132
|
-
const publicBaseUrls = normalizePublicBaseUrlsInput(await textPrompt({
|
|
133
|
-
message: defaultPublicBaseUrls
|
|
134
|
-
? `What are the public base URLs? Press Enter to keep ${defaultPublicBaseUrls}`
|
|
135
|
-
: "What are the public base URLs?",
|
|
136
|
-
placeholder: defaultPublicBaseUrls || "https://your-tunnel-host.example.com",
|
|
137
|
-
defaultValue: defaultPublicBaseUrls,
|
|
138
|
-
validate: validateRequiredPublicBaseUrls,
|
|
139
|
-
}));
|
|
140
|
-
const publicBaseUrl = compactPublicBaseUrlConfig(publicBaseUrls);
|
|
141
|
-
const config = {
|
|
142
|
-
host: files.config.host ?? "127.0.0.1",
|
|
143
|
-
port,
|
|
144
|
-
allowedRoots,
|
|
145
|
-
publicBaseUrl,
|
|
146
|
-
allowedHosts: files.config.allowedHosts,
|
|
147
|
-
workflowInstructions: files.config.workflowInstructions,
|
|
148
|
-
appendInstructions: files.config.appendInstructions,
|
|
149
|
-
subagents: resolveSubagentsFlag(files.config),
|
|
150
|
-
};
|
|
151
|
-
const auth = {
|
|
152
|
-
...files.auth,
|
|
153
|
-
ownerToken: files.auth.ownerToken ?? generateOwnerToken(),
|
|
154
|
-
instanceId: files.auth.instanceId ?? generateInstanceId(),
|
|
155
|
-
};
|
|
156
|
-
const configPath = writeForgeRelayConfig(config);
|
|
157
|
-
const authPath = await writeForgeRelayAuth(auth);
|
|
158
|
-
const lines = [
|
|
159
|
-
`Config: ${configPath}`,
|
|
160
|
-
`Auth: ${authPath}`,
|
|
161
|
-
`Local MCP URL: http://${config.host}:${config.port}/mcp`,
|
|
162
|
-
...publicBaseUrls.map((baseUrl, index) => `${index === 0 ? "Public MCP URL" : "Public MCP alias"}: ${publicEndpointUrl(baseUrl, "mcp").toString()}`),
|
|
163
|
-
];
|
|
164
|
-
prompts.note(lines.join("\n"), "ForgeRelay configured");
|
|
165
|
-
prompts.note([
|
|
166
|
-
`Owner password: ${auth.ownerToken}`,
|
|
167
|
-
"Use this when ChatGPT or Claude asks you to approve ForgeRelay access.",
|
|
168
|
-
`Stored at: ${authPath}`,
|
|
169
|
-
].join("\n"), "Owner password");
|
|
170
|
-
prompts.outro("Run `forgerelay serve` to start the MCP server.");
|
|
171
|
-
}
|
|
172
|
-
catch (error) {
|
|
173
|
-
if (error instanceof SetupCancelledError) {
|
|
174
|
-
prompts.cancel("Setup cancelled");
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
throw error;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
94
|
async function serve() {
|
|
181
95
|
const sqliteStatus = checkSqliteNative();
|
|
182
96
|
if (sqliteStatus !== "ok") {
|
|
@@ -193,7 +107,7 @@ async function serve() {
|
|
|
193
107
|
const { app, close, subagentProviders } = createServer(config);
|
|
194
108
|
const httpServer = app.listen(config.port, config.host, () => {
|
|
195
109
|
console.log(`forgerelay listening on http://${config.host}:${config.port}/mcp`);
|
|
196
|
-
console.log(`
|
|
110
|
+
console.log(`client-facing base url: ${config.publicBaseUrl}`);
|
|
197
111
|
console.log(`allowed roots: ${config.allowedRoots.join(", ")}`);
|
|
198
112
|
console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`);
|
|
199
113
|
if (config.allowedHosts.includes("*")) {
|
|
@@ -394,16 +308,17 @@ async function runDoctor() {
|
|
|
394
308
|
console.log(`SQLite native dependency: ${checkSqliteNative()}`);
|
|
395
309
|
try {
|
|
396
310
|
const config = loadConfig();
|
|
397
|
-
console.log(`
|
|
398
|
-
console.log(`
|
|
399
|
-
console.log(`
|
|
400
|
-
console.log(`
|
|
311
|
+
console.log(`Bind MCP URL: http://${config.host}:${config.port}/mcp`);
|
|
312
|
+
console.log(`Client-facing base URLs: ${config.publicBaseUrls.join(", ")}`);
|
|
313
|
+
console.log(`Client-facing base URL: ${config.publicBaseUrl}`);
|
|
314
|
+
console.log(`Client-facing MCP URL: ${publicEndpointUrl(config.publicBaseUrl, "mcp").toString()}`);
|
|
401
315
|
console.log(`Tool mode: ${config.toolMode}`);
|
|
402
316
|
console.log(`Widgets: ${config.widgets}`);
|
|
403
317
|
console.log(`Trust proxy: ${config.logging.trustProxy ? "one hop" : "off"}`);
|
|
404
318
|
console.log(`Artifacts: ${config.artifactsEnabled ? "enabled" : "disabled"}`);
|
|
405
319
|
console.log(`Subagents: ${config.subagents ? "enabled" : "disabled"}`);
|
|
406
320
|
console.log(`Skills: ${config.skillsEnabled ? "enabled" : "disabled"}`);
|
|
321
|
+
console.log(`Agent-managed Language Server install: ${config.allowAgentLanguageServerInstall ? "enabled" : "disabled"}`);
|
|
407
322
|
console.log(`Allowed roots: ${config.allowedRoots.join(", ")}`);
|
|
408
323
|
console.log(`Allowed hosts: ${config.allowedHosts.join(", ")}`);
|
|
409
324
|
}
|
|
@@ -3,7 +3,7 @@ import { readFile, realpath } from "node:fs/promises";
|
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
5
|
import { createMessageConnection } from "vscode-jsonrpc/node";
|
|
6
|
-
import { DefinitionRequest, DocumentDiagnosticReportKind, DocumentDiagnosticRequest, DocumentSymbolRequest, ExitNotification, HoverRequest, InitializeRequest, ReferencesRequest, InitializedNotification, MarkupKind, PositionEncodingKind, PublishDiagnosticsNotification, ShutdownRequest, WorkspaceSymbolRequest, } from "vscode-languageserver-protocol";
|
|
6
|
+
import { DefinitionRequest, DiagnosticTag, DocumentDiagnosticReportKind, DocumentDiagnosticRequest, DocumentSymbolRequest, ExitNotification, HoverRequest, InitializeRequest, ReferencesRequest, InitializedNotification, MarkupKind, PositionEncodingKind, PublishDiagnosticsNotification, ShutdownRequest, WorkspaceSymbolRequest, } from "vscode-languageserver-protocol";
|
|
7
7
|
import { LanguageServerConfigurationError, } from "./language-server-config.js";
|
|
8
8
|
import { terminateProcessTree } from "../mcp/process/process-platform.js";
|
|
9
9
|
import { CodeIntelligenceError } from "./code-intelligence-error.js";
|
|
@@ -14,9 +14,11 @@ import { isWithin, locationEntries, normalizeLocations, workspaceDisplayPath, }
|
|
|
14
14
|
import { lspPositionFromUser, rangeFromLsp } from "./position-encoding.js";
|
|
15
15
|
import { DiagnosticSnapshotStore } from "./runtime/diagnostic-snapshots.js";
|
|
16
16
|
import { DocumentSynchronizer } from "./runtime/document-synchronizer.js";
|
|
17
|
+
import { languageServerCommandRequiresShell } from "./runtime/process-launch.js";
|
|
17
18
|
import { SemanticRequestCoordinator } from "./runtime/semantic-requests.js";
|
|
18
19
|
export { CodeIntelligenceError } from "./code-intelligence-error.js";
|
|
19
20
|
const STDERR_TAIL_BYTES = 64 * 1024;
|
|
21
|
+
const PUSH_DIAGNOSTIC_WAIT_MS = 1_000;
|
|
20
22
|
export class LanguageService {
|
|
21
23
|
workspaceRoot;
|
|
22
24
|
project;
|
|
@@ -258,7 +260,10 @@ export class LanguageService {
|
|
|
258
260
|
...this.diagnosticSnapshots.readPull(document, limit),
|
|
259
261
|
};
|
|
260
262
|
}
|
|
261
|
-
const snapshot = this.diagnosticSnapshots.
|
|
263
|
+
const snapshot = await this.diagnosticSnapshots.waitForFreshPush(document, limit, Math.min(PUSH_DIAGNOSTIC_WAIT_MS, this.policy.requestTimeoutMs), signal);
|
|
264
|
+
if (signal?.aborted) {
|
|
265
|
+
throw new CodeIntelligenceError("code.request_cancelled", `Diagnostic request for ${input.path} was cancelled by the Host.`);
|
|
266
|
+
}
|
|
262
267
|
if (snapshot.freshness.state === "missing" && !this.diagnosticSnapshots.hasObservedPushDiagnostics()) {
|
|
263
268
|
throw new CodeIntelligenceError("code.operation_unsupported", `Language server ${this.project.definition.id} does not provide pull diagnostics and has not published push diagnostics.`);
|
|
264
269
|
}
|
|
@@ -336,7 +341,7 @@ export class LanguageService {
|
|
|
336
341
|
stdio: "pipe",
|
|
337
342
|
windowsHide: true,
|
|
338
343
|
detached,
|
|
339
|
-
shell:
|
|
344
|
+
shell: languageServerCommandRequiresShell(definition.command),
|
|
340
345
|
});
|
|
341
346
|
}
|
|
342
347
|
catch (error) {
|
|
@@ -370,6 +375,9 @@ export class LanguageService {
|
|
|
370
375
|
clientInfo: { name: "forgerelay" },
|
|
371
376
|
rootUri,
|
|
372
377
|
workspaceFolders: [{ uri: rootUri, name: basename(this.project.projectRoot) }],
|
|
378
|
+
...(definition.initializationOptions === undefined
|
|
379
|
+
? {}
|
|
380
|
+
: { initializationOptions: definition.initializationOptions }),
|
|
373
381
|
capabilities: {
|
|
374
382
|
general: {
|
|
375
383
|
positionEncodings: [
|
|
@@ -405,6 +413,12 @@ export class LanguageService {
|
|
|
405
413
|
dynamicRegistration: false,
|
|
406
414
|
hierarchicalDocumentSymbolSupport: true,
|
|
407
415
|
},
|
|
416
|
+
publishDiagnostics: {
|
|
417
|
+
relatedInformation: true,
|
|
418
|
+
tagSupport: {
|
|
419
|
+
valueSet: [DiagnosticTag.Unnecessary, DiagnosticTag.Deprecated],
|
|
420
|
+
},
|
|
421
|
+
},
|
|
408
422
|
diagnostic: {
|
|
409
423
|
dynamicRegistration: false,
|
|
410
424
|
relatedDocumentSupport: false,
|