@akira-tl/forgerelay 0.3.2 → 0.3.4
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 +19 -0
- package/README.md +2 -2
- package/capabilities/artifacts-review/GUIDE.md +9 -7
- package/capabilities/managed-worktrees/GUIDE.md +12 -7
- package/capabilities/shell-processes/GUIDE.md +10 -8
- package/dist/artifact-tools.js +3 -2
- package/dist/capabilities.js +2 -2
- package/dist/capability-registry.js +63 -5
- package/dist/mcp/server-instructions.js +3 -4
- package/dist/server.js +313 -117
- package/docs/chatgpt-coding-workflow.md +24 -22
- package/docs/configuration.md +17 -12
- package/docs/debugging.md +3 -3
- package/docs/gotchas.md +6 -5
- package/docs/security.md +3 -3
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +30 -16
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.3.4] - 2026-08-10
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Regular MCP tool modes now use one `bash` interface for both command start and long-process interaction. `action="run"` preserves normal shell execution while `action="process"` polls/waits, writes input, resizes PTYs, or interrupts an existing workspace-owned `processId`; top-level `write_stdin` is no longer exposed in regular modes.
|
|
12
|
+
- `close_workspace` is now the single public Workspace close operation. Checkout-backed workspaces release their logical handle, while managed-worktree-backed workspaces require `commitMessage` and run the existing Hook/commit/fast-forward/cleanup lifecycle; top-level `close_worktree` is no longer exposed.
|
|
13
|
+
- Shell/process and managed-worktree Capability Guides, Host instructions, configuration docs, debugging acceptance, and workflow docs now use the unified process and Workspace lifecycle model.
|
|
14
|
+
|
|
15
|
+
## [0.3.3] - 2026-08-10
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Added `review.changes` and `artifact.download` as registered Capability Gateway actions. Review preserves the existing Git-backed checkpoint and diff-card metadata; native artifact ingress preserves Host-native file transport, workspace-relative no-overwrite publication, size limits, and `AfterFileChange` lifecycle reporting.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- The Capability catalog now advertises only capabilities that are actually available in the current runtime. Explicit calls to known-but-disabled capabilities still return stable `capability_unavailable` diagnostics.
|
|
24
|
+
- `show_changes` and `download_artifact` remain compatibility aliases for the 0.3.3 migration window, but the artifacts/review guide now treats the Capability Gateway as the canonical Agent workflow.
|
|
25
|
+
|
|
7
26
|
## [0.3.2] - 2026-08-10
|
|
8
27
|
|
|
9
28
|
### Added
|
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ git worktree list
|
|
|
122
122
|
git branch
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
When `
|
|
125
|
+
When `close_workspace` succeeds for a managed-worktree-backed workspace, ForgeRelay:
|
|
126
126
|
|
|
127
127
|
1. checks that the source checkout is clean and still on the expected target branch;
|
|
128
128
|
2. commits any remaining worktree changes;
|
|
@@ -145,7 +145,7 @@ Hook 是 ForgeRelay 的自动生命周期规则。首选方式是一个 Hook 一
|
|
|
145
145
|
"event": "BeforeTool",
|
|
146
146
|
"matcher": {
|
|
147
147
|
"tool": "bash",
|
|
148
|
-
"commandRegex": "
|
|
148
|
+
"commandRegex": "git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+"
|
|
149
149
|
},
|
|
150
150
|
"command": "npm run release:verify",
|
|
151
151
|
"timeoutSeconds": 300,
|
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
|
|
5
5
|
## Native artifact transfer
|
|
6
6
|
|
|
7
|
-
Native artifact download
|
|
7
|
+
Native artifact download 默认关闭,且当前安全发布实现主要面向 Linux。只有 `open_workspace` 的 Capability catalog 广告 `artifact.download` 时才执行它;不熟悉 contract 时先 `capability(action="describe")`,其中会明确 native file 通过 Gateway 顶层 `file` transport slot 传入。
|
|
8
8
|
|
|
9
9
|
典型流程:
|
|
10
10
|
|
|
11
11
|
1. 先用 `open_workspace` 打开目标项目;
|
|
12
|
-
2. 调用 `
|
|
12
|
+
2. 调用 `capability`,`name="artifact.download"`、`action="run"`,把 Host 原样提供的 native `file` 值放在 Gateway 顶层 `file`,并在 `arguments.path` 传 workspace-relative 目标路径;
|
|
13
13
|
3. 成功后使用返回的 normalized relative path 配合普通 `read` / `rename` / `delete` / shell 等工具继续处理。
|
|
14
14
|
|
|
15
|
-
`download_artifact`
|
|
15
|
+
0.3.3 仍保留 `download_artifact` 作为迁移期 compatibility alias,但新的 Agent workflow 不应把它视为长期 public surface。
|
|
16
|
+
|
|
17
|
+
`artifact.download` 会创建缺失的父目录,但拒绝覆盖已有目标。不要把 native file 值改造成:
|
|
16
18
|
|
|
17
19
|
- 任意 URL 字符串;
|
|
18
20
|
- 猜测的本地主机路径;
|
|
@@ -28,15 +30,15 @@ Native artifact download 默认关闭。只有 `tools/list` 实际暴露 `downlo
|
|
|
28
30
|
`FORGERELAY_WIDGETS`:
|
|
29
31
|
|
|
30
32
|
- `full`:默认,为常用 workspace/file/edit/shell tools 附加 MCP App UI;
|
|
31
|
-
- `changes`:聚焦 `open_workspace` + `
|
|
33
|
+
- `changes`:聚焦 `open_workspace` + `review.changes` 聚合 review;
|
|
32
34
|
- `off`:不附加 widget UI。
|
|
33
35
|
|
|
34
36
|
Plain MCP Host 可以忽略这些 UI metadata;不要把 UI 是否显示当成文件操作是否成功的判据。
|
|
35
37
|
|
|
36
|
-
当
|
|
38
|
+
当 Capability catalog 广告 `review.changes` 时,本轮成功修改文件后,在最后一个相关 file mutation 之后、final response 之前通过 `capability` 调用一次,让用户看到聚合 diff;不要每改一个文件就调用一次。0.3.3 仍保留 `show_changes` compatibility alias,但它和 `review.changes` 共用同一套 checkpoint,不是两套 review 状态。
|
|
37
39
|
|
|
38
|
-
`
|
|
40
|
+
`review.changes` 使用 Git-backed review checkpoint。它按 workspace 跟踪 open/baseline 状态,展示自上次 review checkpoint 以来的 coherent diff,并在成功 review 后推进 baseline。当前版本要求可用的 Git workspace;checkpoint 缺失或 root 不匹配时会明确失败或使用受控 fallback,而不是凭空重建历史。
|
|
39
41
|
|
|
40
42
|
## Capability ownership
|
|
41
43
|
|
|
42
|
-
Artifact transfer 和 change review 都属于 ForgeRelay capability,而不是 Agent
|
|
44
|
+
Artifact transfer 和 change review 都属于 ForgeRelay registered capability,而不是 Agent 自己的文件搬运协议。`tools/list` 只负责暴露稳定 Gateway 与当前兼容 aliases;真正可用的低频能力以当前 workspace 的 Capability catalog 为准。本指南提供流程和边界,不额外创造隐藏执行入口。
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
## 基本模型
|
|
6
6
|
|
|
7
|
-
- `workspaceId`
|
|
7
|
+
- `workspaceId` 是 Agent 的工作身份;managed worktree 是该 Workspace 的一种物理 Git backing mode,不是 Host 需要管理的第二套 lifecycle。
|
|
8
8
|
- managed worktree 使用 ForgeRelay 管理的 `forgerelay/*` 分支,不使用 detached HEAD。
|
|
9
9
|
- 创建时会记录 source checkout、base ref/base SHA、managed branch 和 target branch。
|
|
10
|
-
- 同一个物理 worktree 可以存在多个逻辑 workspace handle
|
|
10
|
+
- 同一个物理 worktree 可以存在多个逻辑 workspace handle;finalize 一个 managed-worktree-backed Workspace 时,ForgeRelay 会统一处理同一物理 worktree 的 alias/session invalidation。
|
|
11
11
|
|
|
12
12
|
## 打开与复用
|
|
13
13
|
|
|
@@ -22,18 +22,23 @@
|
|
|
22
22
|
|
|
23
23
|
不要为了“更安全”自动选择 worktree,也不要在用户没有要求时创建额外 Git 分支。
|
|
24
24
|
|
|
25
|
-
## `close_workspace`
|
|
25
|
+
## `close_workspace`
|
|
26
26
|
|
|
27
|
-
`close_workspace`
|
|
27
|
+
`close_workspace` 是唯一公开关闭入口,行为由 Workspace backing mode 决定:
|
|
28
28
|
|
|
29
|
-
`
|
|
29
|
+
- checkout-backed Workspace:只释放逻辑 `workspaceId`,不会删除 checkout 文件;
|
|
30
|
+
- managed-worktree-backed Workspace:要求提供 `commitMessage`,并完成下面的安全 finalize lifecycle。
|
|
31
|
+
|
|
32
|
+
Managed worktree finalize:
|
|
30
33
|
|
|
31
34
|
1. 要求该 worktree 的工作已经完成并验证;
|
|
32
|
-
2. 若仍有未提交修改,ForgeRelay
|
|
35
|
+
2. 若仍有未提交修改,ForgeRelay 使用 `close_workspace` 提供的 commit message 提交;
|
|
33
36
|
3. 只有 source checkout 干净、目标历史没有分叉且能够安全 fast-forward 时,才把 managed branch 集成到原 target branch;
|
|
34
|
-
4. 成功后移除 worktree 目录和 ForgeRelay
|
|
37
|
+
4. 成功后移除 worktree 目录和 ForgeRelay 管理分支,并关闭该物理 worktree 的逻辑 aliases;
|
|
35
38
|
5. 若安全 fast-forward 不成立,不把 source checkout 留在 merge-conflict 状态,而是拒绝关闭并保留 worktree 供用户/Agent 处理。
|
|
36
39
|
|
|
40
|
+
如果因为缺少 `commitMessage`、dirty source、divergence、Hook blocking 或 busy process 关闭失败,修正对应条件后继续使用**原 workspaceId** 重试;不要另开一个 worktree 来逃避失败状态。
|
|
41
|
+
|
|
37
42
|
运行中的 process 或尚未消费的 process completion 也会阻止相关逻辑 workspace/worktree 被关闭。
|
|
38
43
|
|
|
39
44
|
## 外部变化与恢复
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# ForgeRelay Shell and Processes
|
|
2
2
|
|
|
3
|
-
当命令长时间运行、需要交互式 TTY
|
|
3
|
+
当命令长时间运行、需要交互式 TTY、需要继续操作已有 `processId`,或遇到 shell/process 平台边界问题时读取本指南。
|
|
4
4
|
|
|
5
5
|
## Core process model
|
|
6
6
|
|
|
7
|
-
`bash
|
|
7
|
+
常规 tool mode 使用一个 `bash` 入口管理命令和后续 process lifecycle;Codex tool mode 仍可使用其兼容 command adapter。命令拥有本地用户权限;workspace path containment 不等于 OS sandbox。
|
|
8
8
|
|
|
9
9
|
普通 `bash` 最多在前台等待 300 秒。如果进程仍存活,ForgeRelay 不会因为 wait window 到期而杀掉它,而是返回:
|
|
10
10
|
|
|
@@ -15,17 +15,19 @@ processId: <number>
|
|
|
15
15
|
|
|
16
16
|
`processId` 是 canonical process handle。旧 `sessionId` 仅为 0.2.x compatibility alias,不应作为新代码或新 Agent workflow 的首选名称。
|
|
17
17
|
|
|
18
|
-
##
|
|
18
|
+
## `bash(action="process")`
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
普通命令使用 `bash(action="run")`,其中 `action` 可省略;如果返回 `running: true` 和 `processId`,后续仍通过同一个 `bash` tool 操作该 process:
|
|
21
21
|
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
22
|
+
- 只传 `workspaceId`、`action="process"`、`processId`:poll / wait;
|
|
23
|
+
- `input`:向正在运行的进程写入字符;
|
|
24
|
+
- `interrupt: true`:显式发送 SIGINT / Ctrl-C;
|
|
25
25
|
- `yieldTimeMs`:继续等待,单次最多 300000 ms;
|
|
26
26
|
- `maxOutputTokens`:限制本次返回的近似输出 token;
|
|
27
27
|
- `columns` / `rows`:调整已经分配 PTY 的终端尺寸。
|
|
28
28
|
|
|
29
|
+
`action="run"` 与 `action="process"` 的参数不要混用。Process ownership 始终绑定原 `workspaceId`;未知或跨 workspace 的 `processId` 会被拒绝。
|
|
30
|
+
|
|
29
31
|
等待超时不会隐式 kill process。若没有必要立即等待,可以继续其他工作;进程完成后,ForgeRelay 会把 completion notice 一次性附加到同一 logical workspace 的后续 tool result。
|
|
30
32
|
|
|
31
33
|
不要因为暂时没有输出就重复启动相同长进程;先用返回的 `processId` poll。
|
|
@@ -42,7 +44,7 @@ rows: 24
|
|
|
42
44
|
|
|
43
45
|
PTY 依赖 optional `node-pty`。缺少该依赖时 ForgeRelay 会明确报错;不要把它误诊成命令本身失败。对非 PTY process 使用 `columns` / `rows` resize 也会失败。
|
|
44
46
|
|
|
45
|
-
对需要 prompt/REPL 的程序,用 `tty
|
|
47
|
+
对需要 prompt/REPL 的程序,用 `bash(action="run", tty=true)` 启动,再通过 `bash(action="process", processId=...)` 输入或 resize;对 tests/builds/formatters 等非交互命令保持默认非 PTY,以获得更稳定的 CI-style 输出。
|
|
46
48
|
|
|
47
49
|
## Platform notes
|
|
48
50
|
|
package/dist/artifact-tools.js
CHANGED
|
@@ -32,8 +32,9 @@ const openAIFileReferenceInputSchema = z.strictObject({
|
|
|
32
32
|
export function isArtifactDownloadSupportedPlatform(platform = process.platform) {
|
|
33
33
|
return ARTIFACT_DOWNLOAD_PLATFORMS.has(platform);
|
|
34
34
|
}
|
|
35
|
-
export function registerArtifactTools(server, { config, workspaces, hooks, incomingArtifactAdapters = [], }) {
|
|
36
|
-
const incomingRegistry =
|
|
35
|
+
export function registerArtifactTools(server, { config, workspaces, hooks, incomingArtifactAdapters = [], incomingArtifactRegistry, }) {
|
|
36
|
+
const incomingRegistry = incomingArtifactRegistry
|
|
37
|
+
?? new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
|
|
37
38
|
registerAppTool(server, "download_artifact", {
|
|
38
39
|
title: "Download attached or generated file",
|
|
39
40
|
description: "Stream one MCP-host-provided native file to a requested relative path inside an already-open workspace. Existing destinations, arbitrary URLs, absolute paths, traversal, symlinked parents, local source paths, and malformed file objects are rejected.",
|
package/dist/capabilities.js
CHANGED
|
@@ -31,7 +31,7 @@ const CAPABILITY_GUIDE_DEFINITIONS = [
|
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
name: "shell-processes",
|
|
34
|
-
description: "Long-running processes,
|
|
34
|
+
description: "Long-running bash processes, processId interaction, PTY, and platform edges.",
|
|
35
35
|
whenToRead: "Read for running or interactive command issues.",
|
|
36
36
|
},
|
|
37
37
|
];
|
|
@@ -72,7 +72,7 @@ export function buildCapabilityFingerprint(config, version, context = {}) {
|
|
|
72
72
|
"worktree.managed",
|
|
73
73
|
"filesystem.rename-move",
|
|
74
74
|
"filesystem.delete",
|
|
75
|
-
"process.
|
|
75
|
+
"process.lifecycle",
|
|
76
76
|
"hooks.lifecycle",
|
|
77
77
|
"capability-guides.read",
|
|
78
78
|
];
|
|
@@ -31,7 +31,7 @@ export class CapabilityRegistry {
|
|
|
31
31
|
readBeforeFirstUse: definition.readGuideBeforeFirstUse,
|
|
32
32
|
},
|
|
33
33
|
};
|
|
34
|
-
});
|
|
34
|
+
}).filter((entry) => entry.available);
|
|
35
35
|
}
|
|
36
36
|
describe(name, context) {
|
|
37
37
|
const definition = this.requireDefinition(name);
|
|
@@ -48,15 +48,29 @@ export class CapabilityRegistry {
|
|
|
48
48
|
whenToRead: guide.whenToRead,
|
|
49
49
|
},
|
|
50
50
|
inputSchema: z.toJSONSchema(definition.inputSchema, { target: "draft-7" }),
|
|
51
|
+
...(definition.nativeFileArgument
|
|
52
|
+
? {
|
|
53
|
+
transport: {
|
|
54
|
+
nativeFileArgument: definition.nativeFileArgument,
|
|
55
|
+
gatewayParameter: "file",
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
51
59
|
};
|
|
52
60
|
}
|
|
53
|
-
async run(name, argumentsValue, context) {
|
|
61
|
+
async run(name, argumentsValue, context, options = {}) {
|
|
54
62
|
const definition = this.requireDefinition(name);
|
|
55
63
|
const catalogEntry = this.catalogEntry(definition, context);
|
|
56
64
|
if (!catalogEntry.available) {
|
|
57
65
|
throw new CapabilityError("capability_unavailable", `Capability ${name} is unavailable${catalogEntry.unavailableReason ? `: ${catalogEntry.unavailableReason}` : "."}`);
|
|
58
66
|
}
|
|
59
|
-
|
|
67
|
+
if (options.nativeFile !== undefined && !definition.nativeFileArgument) {
|
|
68
|
+
throw new CapabilityError("invalid_arguments", `Capability ${name} does not accept a Host-native file value.`);
|
|
69
|
+
}
|
|
70
|
+
const input = definition.nativeFileArgument && options.nativeFile !== undefined
|
|
71
|
+
? { ...(isRecord(argumentsValue) ? argumentsValue : {}), [definition.nativeFileArgument]: options.nativeFile }
|
|
72
|
+
: argumentsValue ?? {};
|
|
73
|
+
const parsed = definition.inputSchema.safeParse(input);
|
|
60
74
|
if (!parsed.success) {
|
|
61
75
|
const details = parsed.error.issues
|
|
62
76
|
.map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "arguments"}: ${issue.message}`)
|
|
@@ -110,9 +124,53 @@ export function createCapabilityRegistry(dependencies) {
|
|
|
110
124
|
inputSchema: hooksCheckInput,
|
|
111
125
|
availability: () => ({ available: true }),
|
|
112
126
|
run: async (_input, context) => ({
|
|
113
|
-
|
|
114
|
-
|
|
127
|
+
value: {
|
|
128
|
+
ok: true,
|
|
129
|
+
...await dependencies.inspectHooks(context.workspaceRoot),
|
|
130
|
+
},
|
|
115
131
|
}),
|
|
116
132
|
},
|
|
133
|
+
...(dependencies.reviewChanges
|
|
134
|
+
? [{
|
|
135
|
+
name: "review.changes",
|
|
136
|
+
description: "Review accumulated workspace changes from the Git-backed review checkpoint.",
|
|
137
|
+
guideName: "artifacts-review",
|
|
138
|
+
readGuideBeforeFirstUse: true,
|
|
139
|
+
inputSchema: z.object({}).strict(),
|
|
140
|
+
availability: () => ({
|
|
141
|
+
available: dependencies.reviewChanges?.available ?? false,
|
|
142
|
+
reason: dependencies.reviewChanges?.unavailableReason,
|
|
143
|
+
}),
|
|
144
|
+
run: async (_input, context) => dependencies.reviewChanges.run(context),
|
|
145
|
+
}]
|
|
146
|
+
: []),
|
|
147
|
+
...(dependencies.downloadArtifact
|
|
148
|
+
? [{
|
|
149
|
+
name: "artifact.download",
|
|
150
|
+
description: "Save one Host-native file into a workspace-relative destination without overwriting.",
|
|
151
|
+
guideName: "artifacts-review",
|
|
152
|
+
readGuideBeforeFirstUse: true,
|
|
153
|
+
inputSchema: z.object({
|
|
154
|
+
file: z.strictObject({
|
|
155
|
+
download_url: z.string(),
|
|
156
|
+
file_id: z.string(),
|
|
157
|
+
mime_type: z.string().nullable().optional(),
|
|
158
|
+
file_name: z.string().nullable().optional(),
|
|
159
|
+
name: z.string().nullable().optional(),
|
|
160
|
+
size: z.number().int().nonnegative().nullable().optional(),
|
|
161
|
+
}),
|
|
162
|
+
path: z.string().min(1),
|
|
163
|
+
}).strict(),
|
|
164
|
+
nativeFileArgument: "file",
|
|
165
|
+
availability: () => ({
|
|
166
|
+
available: dependencies.downloadArtifact?.available ?? false,
|
|
167
|
+
reason: dependencies.downloadArtifact?.unavailableReason,
|
|
168
|
+
}),
|
|
169
|
+
run: async (input, context) => dependencies.downloadArtifact.run(input, context),
|
|
170
|
+
}]
|
|
171
|
+
: []),
|
|
117
172
|
]);
|
|
118
173
|
}
|
|
174
|
+
function isRecord(value) {
|
|
175
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
176
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export const toolNames = {
|
|
2
2
|
openWorkspace: "open_workspace",
|
|
3
3
|
closeWorkspace: "close_workspace",
|
|
4
|
-
closeWorktree: "close_worktree",
|
|
5
4
|
read: "read",
|
|
6
5
|
write: "write",
|
|
7
6
|
edit: "edit",
|
|
@@ -34,7 +33,7 @@ export function buildToolDescriptions(config) {
|
|
|
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
|
|
36
|
+
shell: `Run or manage a shell process inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. action=run (default) starts a command and waits up to 300 seconds; action=process uses its processId to poll, wait, write input, resize a PTY, or interrupt it. Completed background commands may also 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
|
}
|
|
@@ -42,7 +41,7 @@ function capabilityContractInstructions(config) {
|
|
|
42
41
|
const staleWorkspacePolicy = config.toolMode === "codex"
|
|
43
42
|
? ""
|
|
44
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.`;
|
|
45
|
-
const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse the workspaceId
|
|
44
|
+
const workspaceLifecycle = `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Reuse the workspaceId from ${toolNames.openWorkspace}; resume or create another logical workspace only when the user asks.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} releases checkout-backed workspaces or safely finalizes managed-worktree-backed ones; managed close requires commitMessage. Read the managed-worktrees capability guide for advanced failure semantics.`;
|
|
46
45
|
const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Read an availableAgentsFiles path before working under it.`;
|
|
47
46
|
const capabilityGuides = `For optional capabilities from ${toolNames.openWorkspace}, use ${toolNames.capability}; if unfamiliar, describe first and read its advertised capability guide with ${toolNames.read}.`;
|
|
48
47
|
const skills = config.skillsEnabled
|
|
@@ -66,7 +65,7 @@ function defaultWorkflowInstructions(config) {
|
|
|
66
65
|
const inspection = config.toolMode === "full"
|
|
67
66
|
? `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection.`
|
|
68
67
|
: `Use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection.`;
|
|
69
|
-
return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, git inspection, package scripts, generators, formatters, and commands that are better executed by the shell. If ${toolNames.shell} returns a running process with a processId,
|
|
68
|
+
return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, git inspection, package scripts, generators, formatters, and commands that are better executed by the shell. If ${toolNames.shell} returns a running process with a processId, call ${toolNames.shell} again with action=\"process\" when you need to poll, wait, interact, resize, or interrupt it; otherwise you may continue other work and consume its completion notice from a later tool result.`);
|
|
70
69
|
}
|
|
71
70
|
function joinInstructions(...parts) {
|
|
72
71
|
return parts
|
package/dist/server.js
CHANGED
|
@@ -17,12 +17,13 @@ import { applyPatch } from "./apply-patch.js";
|
|
|
17
17
|
import { buildCapabilityFingerprint } from "./capabilities.js";
|
|
18
18
|
import { CapabilityError, createCapabilityRegistry, } from "./capability-registry.js";
|
|
19
19
|
import { deletePath, renamePath } from "./file-mutations.js";
|
|
20
|
-
import { isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js";
|
|
20
|
+
import { downloadIncomingArtifact, isArtifactDownloadSupportedPlatform, registerArtifactTools, } from "./artifact-tools.js";
|
|
21
|
+
import { ArtifactError } from "./artifact-error.js";
|
|
21
22
|
import { loadConfig } from "./config.js";
|
|
22
23
|
import { attachHookReports, HookRunner, runToolWithHooks } from "./hooks.js";
|
|
23
24
|
import { checkHookConfiguration } from "./hook-cli.js";
|
|
24
25
|
import { buildServerInstructions, buildShellMutationPolicy, buildToolDescriptions, toolNames, } from "./mcp/server-instructions.js";
|
|
25
|
-
import { createOpenAIIncomingArtifactAdapter, } from "./incoming-artifacts.js";
|
|
26
|
+
import { createOpenAIIncomingArtifactAdapter, IncomingArtifactAdapterRegistry, } from "./incoming-artifacts.js";
|
|
26
27
|
import { logEvent, requestIp, requestPath, commandPreview, transportSessionIdPrefix, workspaceLogLabel, } from "./logger.js";
|
|
27
28
|
import { editFileTool, findFilesTool, grepFilesTool, listDirectoryTool, readFileTool, writeFileTool, } from "./pi-tools.js";
|
|
28
29
|
import { SingleUserOAuthProvider } from "./oauth-provider.js";
|
|
@@ -66,7 +67,7 @@ function shouldAttachWidget(mode, kind) {
|
|
|
66
67
|
case "off":
|
|
67
68
|
return false;
|
|
68
69
|
case "changes":
|
|
69
|
-
return kind === "workspace" || kind === "show_changes";
|
|
70
|
+
return kind === "workspace" || kind === "show_changes" || kind === "capability";
|
|
70
71
|
case "full":
|
|
71
72
|
return true;
|
|
72
73
|
}
|
|
@@ -460,7 +461,7 @@ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
|
|
|
460
461
|
}
|
|
461
462
|
function processOutputSchema() {
|
|
462
463
|
return resultOutputSchema({
|
|
463
|
-
processId: z.number().int().positive().optional().describe("Canonical process handle for
|
|
464
|
+
processId: z.number().int().positive().optional().describe("Canonical process handle for bash(action=\"process\") or the active command adapter."),
|
|
464
465
|
sessionId: z.number().int().positive().optional().describe("Deprecated alias of processId for compatibility."),
|
|
465
466
|
running: z.boolean(),
|
|
466
467
|
exitCode: z.number().int().optional(),
|
|
@@ -522,6 +523,13 @@ function capabilityContextFor(workspace) {
|
|
|
522
523
|
})),
|
|
523
524
|
};
|
|
524
525
|
}
|
|
526
|
+
async function reviewWorkspaceChanges(reviewCheckpoints, workspace) {
|
|
527
|
+
return reviewCheckpoints.reviewChanges({
|
|
528
|
+
workspaceId: workspace.id,
|
|
529
|
+
root: workspace.root,
|
|
530
|
+
markReviewed: true,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
525
533
|
function toolResultIsError(result) {
|
|
526
534
|
return typeof result === "object" && result !== null && result.isError === true;
|
|
527
535
|
}
|
|
@@ -605,6 +613,8 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
605
613
|
});
|
|
606
614
|
});
|
|
607
615
|
}
|
|
616
|
+
if (config.toolMode !== "codex")
|
|
617
|
+
return;
|
|
608
618
|
registerAppTool(server, "write_stdin", {
|
|
609
619
|
title: "Write to process",
|
|
610
620
|
description: "Poll or write characters to a running process returned by bash or exec_command. Omit chars or pass an empty string to poll. Waiting never kills the process; pass \\u0003 to explicitly send Ctrl-C.",
|
|
@@ -679,8 +689,66 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
679
689
|
export function createMcpServer(config, workspaces, reviewCheckpoints, processSessions, localAgentProviders, incomingArtifactAdapters) {
|
|
680
690
|
const toolDescriptions = buildToolDescriptions(config);
|
|
681
691
|
const hooks = new HookRunner(config.hooks, config.logging, process.env, (workspaceId, result) => attachCompletedProcessNotices(processSessions, workspaceId, result));
|
|
692
|
+
const incomingArtifactRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
|
|
693
|
+
const artifactDownloadAvailable = config.artifactsEnabled && isArtifactDownloadSupportedPlatform();
|
|
694
|
+
const reviewChangesAvailable = config.widgets === "changes";
|
|
682
695
|
const capabilityRegistry = createCapabilityRegistry({
|
|
683
696
|
inspectHooks: (workspaceRoot) => checkHookConfiguration(workspaceRoot, config.hooks),
|
|
697
|
+
reviewChanges: {
|
|
698
|
+
available: reviewChangesAvailable,
|
|
699
|
+
unavailableReason: reviewChangesAvailable
|
|
700
|
+
? undefined
|
|
701
|
+
: "Aggregate change review is disabled; start ForgeRelay with widgets=changes.",
|
|
702
|
+
run: async (context) => {
|
|
703
|
+
const review = await reviewWorkspaceChanges(reviewCheckpoints, {
|
|
704
|
+
id: context.workspaceId,
|
|
705
|
+
root: context.workspaceRoot,
|
|
706
|
+
});
|
|
707
|
+
return {
|
|
708
|
+
value: {
|
|
709
|
+
result: review.result,
|
|
710
|
+
summary: review.summary,
|
|
711
|
+
files: review.files,
|
|
712
|
+
},
|
|
713
|
+
card: {
|
|
714
|
+
tool: "show_changes",
|
|
715
|
+
summary: review.summary,
|
|
716
|
+
files: review.files,
|
|
717
|
+
payload: { patch: review.patch },
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
downloadArtifact: {
|
|
723
|
+
available: artifactDownloadAvailable,
|
|
724
|
+
unavailableReason: !config.artifactsEnabled
|
|
725
|
+
? "Native artifact ingress is disabled."
|
|
726
|
+
: !isArtifactDownloadSupportedPlatform()
|
|
727
|
+
? "Native artifact ingress is unsupported on this platform."
|
|
728
|
+
: undefined,
|
|
729
|
+
run: async (input, context) => {
|
|
730
|
+
try {
|
|
731
|
+
const downloaded = await downloadIncomingArtifact({
|
|
732
|
+
registry: incomingArtifactRegistry,
|
|
733
|
+
workspaceId: context.workspaceId,
|
|
734
|
+
workspaceRoot: context.workspaceRoot,
|
|
735
|
+
maxFileBytes: config.artifactMaxFileBytes,
|
|
736
|
+
file: input.file,
|
|
737
|
+
path: input.path,
|
|
738
|
+
});
|
|
739
|
+
return {
|
|
740
|
+
value: { path: downloaded.path },
|
|
741
|
+
changedPaths: [downloaded.path],
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
if (error instanceof ArtifactError) {
|
|
746
|
+
throw new CapabilityError(`artifact.${error.code}`, error.message);
|
|
747
|
+
}
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
},
|
|
684
752
|
});
|
|
685
753
|
const server = new McpServer({
|
|
686
754
|
name: "forgerelay",
|
|
@@ -987,7 +1055,11 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
987
1055
|
arguments: z
|
|
988
1056
|
.record(z.string(), z.unknown())
|
|
989
1057
|
.optional()
|
|
990
|
-
.describe("Capability-specific arguments. Omit for describe and for capabilities with no arguments."),
|
|
1058
|
+
.describe("Capability-specific JSON arguments. Omit for describe and for capabilities with no arguments."),
|
|
1059
|
+
file: z
|
|
1060
|
+
.unknown()
|
|
1061
|
+
.optional()
|
|
1062
|
+
.describe("Host-native file value. Only capabilities whose describe result advertises native-file transport may consume it."),
|
|
991
1063
|
},
|
|
992
1064
|
outputSchema: {
|
|
993
1065
|
name: z.string(),
|
|
@@ -996,20 +1068,25 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
996
1068
|
result: z.unknown().optional(),
|
|
997
1069
|
error: capabilityErrorOutputSchema.optional(),
|
|
998
1070
|
},
|
|
999
|
-
_meta: {
|
|
1071
|
+
_meta: {
|
|
1072
|
+
...toolWidgetDescriptorMeta(config, "capability")._meta,
|
|
1073
|
+
"openai/fileParams": ["file"],
|
|
1074
|
+
},
|
|
1000
1075
|
annotations: {
|
|
1001
1076
|
readOnlyHint: false,
|
|
1002
1077
|
destructiveHint: false,
|
|
1003
1078
|
idempotentHint: false,
|
|
1004
|
-
openWorldHint:
|
|
1079
|
+
openWorldHint: true,
|
|
1005
1080
|
},
|
|
1006
|
-
}, async ({ workspaceId, name, action, arguments: capabilityArguments }) => {
|
|
1081
|
+
}, async ({ workspaceId, name, action, arguments: capabilityArguments, file }) => {
|
|
1007
1082
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1083
|
+
let changedPaths = [];
|
|
1008
1084
|
return runToolWithHooks(hooks, {
|
|
1009
1085
|
tool: toolNames.capability,
|
|
1010
1086
|
invocation: workspaceHookInvocation(workspace),
|
|
1011
1087
|
payload: { name, action },
|
|
1012
1088
|
isFailure: toolResultIsError,
|
|
1089
|
+
changedPaths: () => changedPaths,
|
|
1013
1090
|
operation: async () => {
|
|
1014
1091
|
const startedAt = performance.now();
|
|
1015
1092
|
try {
|
|
@@ -1036,10 +1113,24 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1036
1113
|
});
|
|
1037
1114
|
return result;
|
|
1038
1115
|
}
|
|
1039
|
-
const
|
|
1116
|
+
const execution = await capabilityRegistry.run(name, capabilityArguments ?? {}, capabilityContextFor(workspace), { nativeFile: file });
|
|
1117
|
+
changedPaths = execution.changedPaths ?? [];
|
|
1040
1118
|
const result = {
|
|
1041
|
-
content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(
|
|
1042
|
-
|
|
1119
|
+
content: [textBlock(`Capability ${name} completed.\n${JSON.stringify(execution.value, null, 2)}`)],
|
|
1120
|
+
...(execution.card
|
|
1121
|
+
? {
|
|
1122
|
+
_meta: {
|
|
1123
|
+
tool: execution.card.tool ?? toolNames.capability,
|
|
1124
|
+
card: {
|
|
1125
|
+
workspaceId,
|
|
1126
|
+
summary: execution.card.summary ?? {},
|
|
1127
|
+
files: execution.card.files,
|
|
1128
|
+
payload: execution.card.payload ?? {},
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
}
|
|
1132
|
+
: {}),
|
|
1133
|
+
structuredContent: { name, action, result: execution.value },
|
|
1043
1134
|
};
|
|
1044
1135
|
logToolCall(config, {
|
|
1045
1136
|
tool: toolNames.capability,
|
|
@@ -1076,53 +1167,25 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1076
1167
|
});
|
|
1077
1168
|
});
|
|
1078
1169
|
registerAppTool(server, toolNames.closeWorkspace, {
|
|
1079
|
-
title: "Close
|
|
1080
|
-
description: "
|
|
1170
|
+
title: "Close workspace",
|
|
1171
|
+
description: "Close one workspace after the user chooses cleanup. Checkout-backed workspaces release only the logical handle. Managed-worktree-backed workspaces finalize the existing safe worktree lifecycle, including hooks, commit/integration, and cleanup; provide commitMessage for that mode. Running or unconsumed processes prevent closure.",
|
|
1081
1172
|
inputSchema: {
|
|
1082
|
-
workspaceId: z.string().describe("
|
|
1083
|
-
},
|
|
1084
|
-
outputSchema: resultOutputSchema({ workspaceId: z.string() }),
|
|
1085
|
-
_meta: {},
|
|
1086
|
-
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
1087
|
-
}, async ({ workspaceId }) => {
|
|
1088
|
-
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1089
|
-
return runToolWithHooks(hooks, {
|
|
1090
|
-
tool: toolNames.closeWorkspace,
|
|
1091
|
-
invocation: workspaceHookInvocation(workspace),
|
|
1092
|
-
payload: { workspaceId },
|
|
1093
|
-
operation: async () => {
|
|
1094
|
-
if (processSessions.activeWorkspaceIds().has(workspaceId)) {
|
|
1095
|
-
throw new Error(`Workspace ${workspaceId} still owns a running process or an unconsumed process completion. Poll or consume it before closing this workspace.`);
|
|
1096
|
-
}
|
|
1097
|
-
workspaces.closeWorkspace(workspaceId);
|
|
1098
|
-
const result = `Closed logical workspace ${workspaceId}. Physical project files were not removed.`;
|
|
1099
|
-
return {
|
|
1100
|
-
content: [textBlock(result)],
|
|
1101
|
-
structuredContent: { result, workspaceId },
|
|
1102
|
-
};
|
|
1103
|
-
},
|
|
1104
|
-
});
|
|
1105
|
-
});
|
|
1106
|
-
registerAppTool(server, toolNames.closeWorktree, {
|
|
1107
|
-
title: "Close worktree",
|
|
1108
|
-
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.",
|
|
1109
|
-
inputSchema: {
|
|
1110
|
-
workspaceId: z
|
|
1111
|
-
.string()
|
|
1112
|
-
.describe("Managed worktree workspace identifier returned by open_workspace."),
|
|
1173
|
+
workspaceId: z.string().describe("Workspace identifier to close."),
|
|
1113
1174
|
commitMessage: z
|
|
1114
1175
|
.string()
|
|
1115
1176
|
.min(1)
|
|
1116
|
-
.
|
|
1177
|
+
.optional()
|
|
1178
|
+
.describe("Required only for a managed-worktree-backed workspace; concise Git commit message for remaining worktree changes."),
|
|
1117
1179
|
},
|
|
1118
1180
|
outputSchema: resultOutputSchema({
|
|
1119
1181
|
workspaceId: z.string(),
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1182
|
+
mode: z.enum(["checkout", "worktree"]),
|
|
1183
|
+
sourceRoot: z.string().optional(),
|
|
1184
|
+
branch: z.string().optional(),
|
|
1185
|
+
targetBranch: z.string().optional(),
|
|
1186
|
+
commitSha: z.string().optional(),
|
|
1187
|
+
mergedSha: z.string().optional(),
|
|
1188
|
+
committed: z.boolean().optional(),
|
|
1126
1189
|
cleanupWarning: z.string().optional(),
|
|
1127
1190
|
}),
|
|
1128
1191
|
_meta: {},
|
|
@@ -1130,49 +1193,70 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1130
1193
|
}, async ({ workspaceId, commitMessage }, extra) => {
|
|
1131
1194
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1132
1195
|
return runToolWithHooks(hooks, {
|
|
1133
|
-
tool: toolNames.
|
|
1196
|
+
tool: toolNames.closeWorkspace,
|
|
1134
1197
|
invocation: workspaceHookInvocation(workspace),
|
|
1135
|
-
payload: { commitMessage },
|
|
1136
|
-
afterCwd: (response) => response.structuredContent
|
|
1198
|
+
payload: { workspaceId, commitMessage, mode: workspace.mode },
|
|
1199
|
+
afterCwd: (response) => "sourceRoot" in response.structuredContent &&
|
|
1200
|
+
typeof response.structuredContent.sourceRoot === "string"
|
|
1201
|
+
? response.structuredContent.sourceRoot
|
|
1202
|
+
: undefined,
|
|
1137
1203
|
operation: async () => {
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1204
|
+
if (workspace.mode === "worktree") {
|
|
1205
|
+
if (!commitMessage) {
|
|
1206
|
+
throw new Error(`Managed-worktree-backed workspace ${workspaceId} requires commitMessage when closing.`);
|
|
1207
|
+
}
|
|
1208
|
+
const busyWorkspaceIds = workspaces
|
|
1209
|
+
.workspaceIdsForPhysicalWorkspace(workspace)
|
|
1210
|
+
.filter((id) => processSessions.activeWorkspaceIds().has(id));
|
|
1211
|
+
if (busyWorkspaceIds.length > 0) {
|
|
1212
|
+
throw new Error(`Cannot close this worktree-backed workspace while logical workspace processes are still running or awaiting completion delivery: ${busyWorkspaceIds.join(", ")}.`);
|
|
1213
|
+
}
|
|
1214
|
+
const startedAt = performance.now();
|
|
1215
|
+
const closed = await workspaces.closeWorktree(workspaceId, commitMessage);
|
|
1216
|
+
const result = [
|
|
1217
|
+
`Closed managed-worktree-backed workspace ${workspaceId}.`,
|
|
1218
|
+
`Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
|
|
1219
|
+
`Source checkout: ${closed.sourceRoot}`,
|
|
1220
|
+
`Commit: ${closed.commitSha}`,
|
|
1221
|
+
closed.cleanupWarning
|
|
1222
|
+
? `Cleanup warning: ${closed.cleanupWarning}`
|
|
1223
|
+
: "The managed worktree directory and branch were removed.",
|
|
1224
|
+
].join("\n");
|
|
1225
|
+
logToolCall(config, {
|
|
1226
|
+
tool: toolNames.closeWorkspace,
|
|
1227
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
1228
|
+
path: closed.sourceRoot,
|
|
1229
|
+
success: true,
|
|
1230
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
1231
|
+
});
|
|
1232
|
+
return attachHookReports({
|
|
1233
|
+
content: [textBlock(result)],
|
|
1234
|
+
structuredContent: {
|
|
1235
|
+
result,
|
|
1236
|
+
workspaceId,
|
|
1237
|
+
mode: "worktree",
|
|
1238
|
+
sourceRoot: closed.sourceRoot,
|
|
1239
|
+
branch: closed.branch,
|
|
1240
|
+
targetBranch: closed.targetBranch,
|
|
1241
|
+
commitSha: closed.commitSha,
|
|
1242
|
+
mergedSha: closed.mergedSha,
|
|
1243
|
+
committed: closed.committed,
|
|
1244
|
+
cleanupWarning: closed.cleanupWarning,
|
|
1245
|
+
},
|
|
1246
|
+
}, closed.hookReports);
|
|
1143
1247
|
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
`
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
tool: toolNames.closeWorktree,
|
|
1157
|
-
...workspaceLogContext(workspace, extra.sessionId),
|
|
1158
|
-
path: closed.sourceRoot,
|
|
1159
|
-
success: true,
|
|
1160
|
-
durationMs: Math.round(performance.now() - startedAt),
|
|
1161
|
-
});
|
|
1162
|
-
return attachHookReports({
|
|
1163
|
-
content: [{ type: "text", text: result }],
|
|
1164
|
-
structuredContent: {
|
|
1165
|
-
result,
|
|
1166
|
-
workspaceId,
|
|
1167
|
-
sourceRoot: closed.sourceRoot,
|
|
1168
|
-
branch: closed.branch,
|
|
1169
|
-
targetBranch: closed.targetBranch,
|
|
1170
|
-
commitSha: closed.commitSha,
|
|
1171
|
-
mergedSha: closed.mergedSha,
|
|
1172
|
-
committed: closed.committed,
|
|
1173
|
-
cleanupWarning: closed.cleanupWarning,
|
|
1174
|
-
},
|
|
1175
|
-
}, closed.hookReports);
|
|
1248
|
+
if (commitMessage !== undefined) {
|
|
1249
|
+
throw new Error("close_workspace commitMessage is only valid for managed-worktree-backed workspaces.");
|
|
1250
|
+
}
|
|
1251
|
+
if (processSessions.activeWorkspaceIds().has(workspaceId)) {
|
|
1252
|
+
throw new Error(`Workspace ${workspaceId} still owns a running process or an unconsumed process completion. Poll or consume it before closing this workspace.`);
|
|
1253
|
+
}
|
|
1254
|
+
workspaces.closeWorkspace(workspaceId);
|
|
1255
|
+
const result = `Closed checkout-backed workspace ${workspaceId}. Physical project files were not removed.`;
|
|
1256
|
+
return {
|
|
1257
|
+
content: [textBlock(result)],
|
|
1258
|
+
structuredContent: { result, workspaceId, mode: "checkout" },
|
|
1259
|
+
};
|
|
1176
1260
|
},
|
|
1177
1261
|
});
|
|
1178
1262
|
});
|
|
@@ -1651,11 +1735,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1651
1735
|
invocation: workspaceHookInvocation(workspace),
|
|
1652
1736
|
operation: async () => {
|
|
1653
1737
|
const startedAt = performance.now();
|
|
1654
|
-
const review = await reviewCheckpoints
|
|
1655
|
-
workspaceId,
|
|
1656
|
-
root: workspace.root,
|
|
1657
|
-
markReviewed: true,
|
|
1658
|
-
});
|
|
1738
|
+
const review = await reviewWorkspaceChanges(reviewCheckpoints, workspace);
|
|
1659
1739
|
const content = [textBlock(review.result)];
|
|
1660
1740
|
logToolCall(config, {
|
|
1661
1741
|
tool: "show_changes",
|
|
@@ -1893,59 +1973,174 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1893
1973
|
workspaceId: z
|
|
1894
1974
|
.string()
|
|
1895
1975
|
.describe("Workspace identifier returned by open_workspace."),
|
|
1976
|
+
action: z
|
|
1977
|
+
.enum(["run", "process"])
|
|
1978
|
+
.optional()
|
|
1979
|
+
.describe("Defaults to run. Use process with a returned processId to poll, interact, resize, or interrupt a running command."),
|
|
1896
1980
|
command: z
|
|
1897
1981
|
.string()
|
|
1898
|
-
.
|
|
1982
|
+
.optional()
|
|
1983
|
+
.describe(`${toolDescriptions.shellCommand} Required for action=run.`),
|
|
1984
|
+
processId: z
|
|
1985
|
+
.number()
|
|
1986
|
+
.int()
|
|
1987
|
+
.positive()
|
|
1988
|
+
.optional()
|
|
1989
|
+
.describe("Process identifier returned by a previous bash action=run call. Required for action=process."),
|
|
1990
|
+
input: z
|
|
1991
|
+
.string()
|
|
1992
|
+
.optional()
|
|
1993
|
+
.describe("Characters to write for action=process. Omit to poll/wait without input."),
|
|
1994
|
+
interrupt: z
|
|
1995
|
+
.boolean()
|
|
1996
|
+
.optional()
|
|
1997
|
+
.describe("For action=process, send SIGINT to the process. Cannot be combined with input."),
|
|
1998
|
+
tty: z
|
|
1999
|
+
.boolean()
|
|
2000
|
+
.optional()
|
|
2001
|
+
.describe("For action=run, allocate a pseudo-terminal for interactive commands. Defaults to false."),
|
|
2002
|
+
columns: z
|
|
2003
|
+
.number()
|
|
2004
|
+
.int()
|
|
2005
|
+
.min(1)
|
|
2006
|
+
.max(1_000)
|
|
2007
|
+
.optional()
|
|
2008
|
+
.describe("Initial PTY width for action=run, or resize width for action=process."),
|
|
2009
|
+
rows: z
|
|
2010
|
+
.number()
|
|
2011
|
+
.int()
|
|
2012
|
+
.min(1)
|
|
2013
|
+
.max(1_000)
|
|
2014
|
+
.optional()
|
|
2015
|
+
.describe("Initial PTY height for action=run, or resize height for action=process."),
|
|
1899
2016
|
workingDirectory: z
|
|
1900
2017
|
.string()
|
|
1901
2018
|
.optional()
|
|
1902
|
-
.describe("
|
|
2019
|
+
.describe("For action=run, working directory relative to the workspace root. Defaults to the workspace root."),
|
|
2020
|
+
yieldTimeMs: z
|
|
2021
|
+
.number()
|
|
2022
|
+
.int()
|
|
2023
|
+
.min(0)
|
|
2024
|
+
.max(300_000)
|
|
2025
|
+
.optional()
|
|
2026
|
+
.describe("Milliseconds to wait before returning. Run preserves the existing 300000ms default; process polling defaults to 5000ms and interaction to 250ms."),
|
|
2027
|
+
maxOutputTokens: z
|
|
2028
|
+
.number()
|
|
2029
|
+
.int()
|
|
2030
|
+
.positive()
|
|
2031
|
+
.max(100_000)
|
|
2032
|
+
.optional()
|
|
2033
|
+
.describe("Approximate output token budget. Defaults to 10000."),
|
|
1903
2034
|
},
|
|
1904
2035
|
outputSchema: processOutputSchema(),
|
|
1905
2036
|
...toolWidgetDescriptorMeta(config, "shell"),
|
|
1906
2037
|
annotations: SHELL_TOOL_ANNOTATIONS,
|
|
1907
|
-
}, async ({ workspaceId, command, workingDirectory }, extra) => {
|
|
2038
|
+
}, async ({ workspaceId, action = "run", command, processId, input, interrupt, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens, }, extra) => {
|
|
1908
2039
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
2040
|
+
if (action === "run") {
|
|
2041
|
+
if (!command)
|
|
2042
|
+
throw new Error("bash action=run requires command.");
|
|
2043
|
+
if (processId !== undefined || input !== undefined || interrupt !== undefined) {
|
|
2044
|
+
throw new Error("bash action=run does not accept processId, input, or interrupt.");
|
|
2045
|
+
}
|
|
2046
|
+
return runToolWithHooks(hooks, {
|
|
2047
|
+
tool: toolNames.shell,
|
|
2048
|
+
invocation: workspaceHookInvocation(workspace),
|
|
2049
|
+
payload: {
|
|
2050
|
+
action,
|
|
2051
|
+
command,
|
|
2052
|
+
workingDirectory: workingDirectory ?? ".",
|
|
2053
|
+
},
|
|
2054
|
+
isFailure: toolResultIsError,
|
|
2055
|
+
operation: async () => {
|
|
2056
|
+
const startedAt = performance.now();
|
|
2057
|
+
const cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
|
|
2058
|
+
const snapshot = await processSessions.start({
|
|
2059
|
+
workspaceId,
|
|
2060
|
+
command,
|
|
2061
|
+
cwd,
|
|
2062
|
+
workspaceRoot: workspace.root,
|
|
2063
|
+
tty,
|
|
2064
|
+
columns,
|
|
2065
|
+
rows,
|
|
2066
|
+
yieldTimeMs: yieldTimeMs ?? 300_000,
|
|
2067
|
+
maxOutputTokens,
|
|
2068
|
+
});
|
|
2069
|
+
logToolCall(config, {
|
|
2070
|
+
tool: toolNames.shell,
|
|
2071
|
+
...workspaceLogContext(workspace, extra.sessionId),
|
|
2072
|
+
workingDirectory: workingDirectory ?? ".",
|
|
2073
|
+
command,
|
|
2074
|
+
commandLength: command.length,
|
|
2075
|
+
exitCode: snapshot.exitCode,
|
|
2076
|
+
running: snapshot.running,
|
|
2077
|
+
processId: snapshot.processId,
|
|
2078
|
+
success: snapshot.running || (snapshot.exitCode === 0 && !snapshot.signal),
|
|
2079
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
2080
|
+
});
|
|
2081
|
+
const response = processToolResponse(toolNames.shell, workspaceId, snapshot, {
|
|
2082
|
+
action,
|
|
2083
|
+
command,
|
|
2084
|
+
workingDirectory: workingDirectory ?? ".",
|
|
2085
|
+
running: snapshot.running,
|
|
2086
|
+
exitCode: snapshot.exitCode,
|
|
2087
|
+
wallTimeMs: snapshot.wallTimeMs,
|
|
2088
|
+
});
|
|
2089
|
+
return !snapshot.running && (snapshot.signal || snapshot.exitCode !== 0)
|
|
2090
|
+
? { ...response, isError: true }
|
|
2091
|
+
: response;
|
|
2092
|
+
},
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
if (command !== undefined || workingDirectory !== undefined || tty !== undefined) {
|
|
2096
|
+
throw new Error("bash action=process does not accept command, workingDirectory, or tty.");
|
|
2097
|
+
}
|
|
2098
|
+
if (processId === undefined)
|
|
2099
|
+
throw new Error("bash action=process requires processId.");
|
|
2100
|
+
if (interrupt && input !== undefined) {
|
|
2101
|
+
throw new Error("bash action=process cannot combine interrupt with input.");
|
|
2102
|
+
}
|
|
1909
2103
|
return runToolWithHooks(hooks, {
|
|
1910
2104
|
tool: toolNames.shell,
|
|
1911
2105
|
invocation: workspaceHookInvocation(workspace),
|
|
1912
2106
|
payload: {
|
|
1913
|
-
|
|
1914
|
-
|
|
2107
|
+
action,
|
|
2108
|
+
processId,
|
|
2109
|
+
inputLength: input?.length ?? 0,
|
|
2110
|
+
interrupt: interrupt ?? false,
|
|
2111
|
+
columns,
|
|
2112
|
+
rows,
|
|
1915
2113
|
},
|
|
1916
2114
|
isFailure: toolResultIsError,
|
|
1917
2115
|
operation: async () => {
|
|
1918
2116
|
const startedAt = performance.now();
|
|
1919
|
-
const
|
|
1920
|
-
const snapshot = await processSessions.start({
|
|
2117
|
+
const snapshot = await processSessions.write({
|
|
1921
2118
|
workspaceId,
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
2119
|
+
processId,
|
|
2120
|
+
chars: interrupt ? "\u0003" : input,
|
|
2121
|
+
columns,
|
|
2122
|
+
rows,
|
|
2123
|
+
yieldTimeMs,
|
|
2124
|
+
maxOutputTokens,
|
|
1926
2125
|
});
|
|
1927
2126
|
logToolCall(config, {
|
|
1928
2127
|
tool: toolNames.shell,
|
|
1929
2128
|
...workspaceLogContext(workspace, extra.sessionId),
|
|
1930
|
-
workingDirectory: workingDirectory ?? ".",
|
|
1931
|
-
command,
|
|
1932
|
-
commandLength: command.length,
|
|
1933
2129
|
exitCode: snapshot.exitCode,
|
|
1934
2130
|
running: snapshot.running,
|
|
1935
2131
|
processId: snapshot.processId,
|
|
1936
|
-
success: snapshot.running ||
|
|
2132
|
+
success: snapshot.running || snapshot.exitCode === 0,
|
|
1937
2133
|
durationMs: Math.round(performance.now() - startedAt),
|
|
1938
2134
|
});
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
2135
|
+
return processToolResponse(toolNames.shell, workspaceId, snapshot, {
|
|
2136
|
+
action,
|
|
2137
|
+
processId,
|
|
2138
|
+
inputLength: input?.length ?? 0,
|
|
2139
|
+
interrupt: interrupt ?? false,
|
|
1942
2140
|
running: snapshot.running,
|
|
1943
2141
|
exitCode: snapshot.exitCode,
|
|
1944
2142
|
wallTimeMs: snapshot.wallTimeMs,
|
|
1945
2143
|
});
|
|
1946
|
-
return !snapshot.running && (snapshot.signal || snapshot.exitCode !== 0)
|
|
1947
|
-
? { ...response, isError: true }
|
|
1948
|
-
: response;
|
|
1949
2144
|
},
|
|
1950
2145
|
});
|
|
1951
2146
|
});
|
|
@@ -1957,6 +2152,7 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1957
2152
|
workspaces,
|
|
1958
2153
|
hooks,
|
|
1959
2154
|
incomingArtifactAdapters,
|
|
2155
|
+
incomingArtifactRegistry,
|
|
1960
2156
|
});
|
|
1961
2157
|
}
|
|
1962
2158
|
return server;
|
|
@@ -65,8 +65,8 @@ so a specific existing worktree can be reopened directly.
|
|
|
65
65
|
|
|
66
66
|
## Closing a managed worktree
|
|
67
67
|
|
|
68
|
-
After the task is complete and verified, call `
|
|
69
|
-
workspace ID and a
|
|
68
|
+
After the task is complete and verified, call `close_workspace` with the managed-worktree-backed
|
|
69
|
+
workspace ID and a `commitMessage`.
|
|
70
70
|
|
|
71
71
|
ForgeRelay:
|
|
72
72
|
|
|
@@ -110,14 +110,17 @@ skill-discovery path.
|
|
|
110
110
|
|
|
111
111
|
## MCP capability loading
|
|
112
112
|
|
|
113
|
-
ForgeRelay keeps callable MCP
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
ForgeRelay keeps a small callable MCP surface separate from low-frequency capability
|
|
114
|
+
details. `tools/list` remains the source of truth for the Host-visible tools, while
|
|
115
|
+
registered low-frequency actions are discovered through the single `capability`
|
|
116
|
+
gateway rather than each receiving another top-level tool schema.
|
|
116
117
|
|
|
117
|
-
`open_workspace` adds
|
|
118
|
+
`open_workspace` adds lightweight discovery surfaces:
|
|
118
119
|
|
|
119
120
|
- `capabilityFingerprint` is returned on every open/resume and includes the
|
|
120
121
|
ForgeRelay version, active tool mode, and stable semantic capability names;
|
|
122
|
+
- `capabilityCatalog` lists currently available registered actions such as
|
|
123
|
+
`hooks.check`, with compact guide metadata;
|
|
121
124
|
- `capabilityGuides` is returned with bootstrap context and contains compact
|
|
122
125
|
descriptors for ForgeRelay-owned, versioned guides that can be loaded with
|
|
123
126
|
the normal `read` tool.
|
|
@@ -195,26 +198,24 @@ edit
|
|
|
195
198
|
rename
|
|
196
199
|
delete
|
|
197
200
|
bash
|
|
198
|
-
|
|
199
|
-
close_worktree
|
|
201
|
+
capability
|
|
200
202
|
```
|
|
201
203
|
|
|
202
|
-
The exact lifecycle tools available depend on the active server configuration.
|
|
203
204
|
In minimal mode, normal shell inspection commands such as `rg`, `find`, and `ls`
|
|
204
|
-
can be used rather than dedicated MCP search tools. `bash`
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
0.2.x for compatibility with existing clients.
|
|
205
|
+
can be used rather than dedicated MCP search tools. `bash(action="run")` (or plain
|
|
206
|
+
`bash`, since `run` is the default) waits in the foreground for at most 300 seconds.
|
|
207
|
+
If the command is still running, ForgeRelay returns a canonical `processId` without
|
|
208
|
+
killing it. Reuse `bash(action="process", processId=...)` to poll/wait, send input,
|
|
209
|
+
resize a PTY, or interrupt the existing process; or continue other work and consume
|
|
210
|
+
the one-shot completion notice from a later result in the same workspace.
|
|
211
211
|
|
|
212
212
|
`FORGERELAY_TOOL_MODE=full` adds dedicated search/directory tools.
|
|
213
213
|
|
|
214
|
-
Experimental `FORGERELAY_TOOL_MODE=codex`
|
|
215
|
-
surface including direct `rename`/`delete` path mutations alongside `apply_patch`,
|
|
216
|
-
`exec_command`, and `write_stdin
|
|
217
|
-
for both files and directories; ForgeRelay does not
|
|
214
|
+
Experimental `FORGERELAY_TOOL_MODE=codex` keeps its Codex-shaped compatibility
|
|
215
|
+
surface, including direct `rename`/`delete` path mutations alongside `apply_patch`,
|
|
216
|
+
`exec_command`, and a compatibility `write_stdin` process adapter. `rename` is the
|
|
217
|
+
unified move/rename primitive for both files and directories; ForgeRelay does not
|
|
218
|
+
expose a separate `move` tool.
|
|
218
219
|
|
|
219
220
|
Workspace IDs are logical conversation handles rather than physical-directory
|
|
220
221
|
identities. The same conversation keeps a stable ID for a project, while another
|
|
@@ -223,8 +224,9 @@ worktree. `open_workspace` can explicitly resume a known `workspaceId`, and a
|
|
|
223
224
|
fresh logical ID is created only when the user asks for one. When a project has
|
|
224
225
|
other logical workspaces idle for more than two days, `open_workspace` reports
|
|
225
226
|
all of them so the user can choose to resume or clean them up. `close_workspace`
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
is the single public close operation: checkout-backed workspaces release the logical
|
|
228
|
+
handle, while managed-worktree-backed workspaces require `commitMessage` and run the
|
|
229
|
+
safe commit / fast-forward-only integration / cleanup lifecycle.
|
|
228
230
|
|
|
229
231
|
Shell commands are allowed to modify ordinary project files when that is a
|
|
230
232
|
natural part of the user's requested development task; ForgeRelay does not apply
|
package/docs/configuration.md
CHANGED
|
@@ -115,9 +115,9 @@ MCP clients discover metadata from:
|
|
|
115
115
|
|
|
116
116
|
| Value | Behavior |
|
|
117
117
|
| --- | --- |
|
|
118
|
-
| `minimal` | Default. Exposes `open_workspace`, `close_workspace`, `read`, `write`, `edit`, `rename`, `delete`, `bash`,
|
|
118
|
+
| `minimal` | Default. Exposes `open_workspace`, `close_workspace`, `read`, `write`, `edit`, `rename`, `delete`, `bash`, and `capability`. |
|
|
119
119
|
| `full` | Adds dedicated `grep`, `glob`, and `ls` tools. |
|
|
120
|
-
| `codex` | Experimental Codex-shaped
|
|
120
|
+
| `codex` | Experimental Codex-shaped compatibility surface using `open_workspace`, `close_workspace`, `read`, `rename`, `delete`, `apply_patch`, `exec_command`, `write_stdin`, and `capability`. |
|
|
121
121
|
|
|
122
122
|
`FORGERELAY_MINIMAL_TOOLS` remains a compatibility-style boolean alias when the
|
|
123
123
|
explicit tool mode is unset. The corresponding legacy `DEVSPACE_*` names are
|
|
@@ -163,16 +163,21 @@ current conversation. `newWorkspace: true` allocates a new logical handle withou
|
|
|
163
163
|
creating another checkout or Git worktree and should be used only on explicit user
|
|
164
164
|
request. Logical workspaces idle for more than two days are returned in `staleWorkspaces` so
|
|
165
165
|
the user can choose whether to resume or release them. `close_workspace` removes a
|
|
166
|
-
logical handle without deleting checkout files
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
166
|
+
checkout-backed logical handle without deleting checkout files. For a managed-worktree-backed
|
|
167
|
+
workspace, `close_workspace` requires `commitMessage` and runs the existing safe
|
|
168
|
+
worktree finalize lifecycle: close Hooks, commit when needed, fast-forward-only
|
|
169
|
+
integration, cleanup, and alias invalidation.
|
|
170
|
+
|
|
171
|
+
Regular `bash` has no execution-timeout input. `action="run"` (the default) waits
|
|
172
|
+
in the foreground for at most 300 seconds; if the process is still alive, the
|
|
173
|
+
result contains `running: true` and a canonical `processId`. Reuse the same `bash`
|
|
174
|
+
with `action="process"` to poll/wait, send `input`, resize a PTY, or set
|
|
175
|
+
`interrupt:true`; each wait can be up to 300 seconds. ForgeRelay does not kill a
|
|
176
|
+
process merely because a wait window expires. Completed background processes are
|
|
177
|
+
delivered once with a later tool result for the same logical workspace ID.
|
|
178
|
+
|
|
179
|
+
Codex mode retains `write_stdin` only as an experimental compatibility adapter;
|
|
180
|
+
regular Agent workflows should use the single `bash` process lifecycle.
|
|
176
181
|
|
|
177
182
|
## Widgets
|
|
178
183
|
|
package/docs/debugging.md
CHANGED
|
@@ -59,11 +59,11 @@ The acceptance checks:
|
|
|
59
59
|
3. unauthenticated `/mcp` rejection;
|
|
60
60
|
4. dynamic OAuth client registration, PKCE Owner-password approval, and access-token exchange;
|
|
61
61
|
5. MCP `initialize`, including package/server version consistency and the shell mutation safety contract;
|
|
62
|
-
6. `tools/list` for the full debug tool surface, including `close_workspace`, `write_stdin`, canonical `
|
|
62
|
+
6. `tools/list` for the full debug tool surface, including unified `close_workspace`, absence of regular `write_stdin` / `close_worktree`, canonical `bash` `action="run"` / `action="process"` plus `processId`, the non-blanket shell mutation policy, no kill-timeout input, workspace resume/stale-workspace schema, and MCP App tool metadata;
|
|
63
63
|
7. the full MCP App template chain: `resources/list`, `resources/templates/list`, current content-hashed `resources/read`, legacy/historical template compatibility reads, `text/html;profile=mcp-app`, the unique app domain plus CSP resource domains, and an HTTP fetch of the JavaScript asset referenced by the template;
|
|
64
|
-
8. a real checkout workspace with `write`, `read`, `rename`, `delete`, foreground `bash`
|
|
64
|
+
8. a real checkout workspace with `write`, `read`, `rename`, `delete`, foreground `bash`, `bash` long-process `run` → `processId` → `process`, and a deliberate failed `edit`;
|
|
65
65
|
9. OS temp-directory `write` → `read` → `edit` → `rename` → `delete` over the same real MCP transport session, plus rejection of an arbitrary path outside the workspace/temp roots;
|
|
66
|
-
10. a temporary Git repository with managed worktree creation, file modification, and `
|
|
66
|
+
10. a temporary Git repository with managed worktree creation, file modification, and `close_workspace` worktree finalization;
|
|
67
67
|
11. 本地 bare remote 上的 release-tag-push Hook:成功 Hook 必须先运行再允许 `v0.2.0` push,失败 Hook 必须在 remote mutation 前阻断 `v0.2.1`;
|
|
68
68
|
12. deterministic local subagent error path,不联系任何模型 provider;
|
|
69
69
|
13. debug hook recorder 覆盖全部九个 Hooks v1 lifecycle events。
|
package/docs/gotchas.md
CHANGED
|
@@ -165,18 +165,19 @@ Legacy persisted `devspace/*` branches remain valid and closable.
|
|
|
165
165
|
Uncommitted source-checkout changes are not automatically copied into a newly
|
|
166
166
|
created worktree.
|
|
167
167
|
|
|
168
|
-
## `
|
|
168
|
+
## `close_workspace` refuses to finalize a managed worktree
|
|
169
169
|
|
|
170
|
-
|
|
170
|
+
For a managed-worktree-backed workspace, close is deliberately refused when:
|
|
171
171
|
|
|
172
172
|
- the source checkout is dirty;
|
|
173
173
|
- the source checkout is no longer on the recorded target branch;
|
|
174
174
|
- the managed worktree is on the wrong branch;
|
|
175
175
|
- source and managed histories diverged.
|
|
176
176
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
A `commitMessage` is also required for managed-worktree-backed close. Integration
|
|
178
|
+
is fast-forward-only. If histories diverge, rebase and verify inside the managed
|
|
179
|
+
worktree, then retry the same `close_workspace` call with the original workspaceId.
|
|
180
|
+
ForgeRelay does not intentionally leave the source checkout in a merge-conflict state.
|
|
180
181
|
|
|
181
182
|
## Windows shell commands fail
|
|
182
183
|
|
package/docs/security.md
CHANGED
|
@@ -141,9 +141,9 @@ Do not describe ForgeRelay as a sandboxed coding environment.
|
|
|
141
141
|
|
|
142
142
|
Shell execution has a 300-second foreground wait ceiling, not a 300-second
|
|
143
143
|
process lifetime. When `bash` is still running after that window, ForgeRelay
|
|
144
|
-
returns a canonical `processId` and leaves the process alive.
|
|
145
|
-
poll, wait,
|
|
146
|
-
|
|
144
|
+
returns a canonical `processId` and leaves the process alive. Regular tool modes
|
|
145
|
+
reuse `bash(action="process")` to poll, wait, write input, resize a PTY, or
|
|
146
|
+
explicitly interrupt that process. An asynchronously completed process
|
|
147
147
|
is reported on a later tool result for the same logical workspace ID, including
|
|
148
148
|
error-result paths, and is never broadcast to another workspace ID. Explicitly
|
|
149
149
|
resuming the same workspace ID in another conversation intentionally transfers
|
package/package.json
CHANGED
package/scripts/debug/accept.mjs
CHANGED
|
@@ -129,9 +129,11 @@ try {
|
|
|
129
129
|
params: {},
|
|
130
130
|
}).message.result.tools;
|
|
131
131
|
const toolNames = tools.map((tool) => tool.name);
|
|
132
|
-
for (const expected of ["open_workspace", "close_workspace", "
|
|
132
|
+
for (const expected of ["open_workspace", "close_workspace", "read", "write", "edit", "rename", "delete", "grep", "glob", "ls", "bash", "capability"]) {
|
|
133
133
|
assert.ok(toolNames.includes(expected), `missing debug tool ${expected}`);
|
|
134
134
|
}
|
|
135
|
+
assert.equal(toolNames.includes("close_worktree"), false);
|
|
136
|
+
assert.equal(toolNames.includes("write_stdin"), false);
|
|
135
137
|
const bashTool = tools.find((tool) => tool.name === "bash");
|
|
136
138
|
assert.match(bashTool?.description ?? "", /local user's authority/);
|
|
137
139
|
assert.doesNotMatch(bashTool?.description ?? "", /may modify ordinary project files/);
|
|
@@ -139,18 +141,10 @@ try {
|
|
|
139
141
|
assert.doesNotMatch(bashTool?.description ?? "", /external device or hardware mutations/);
|
|
140
142
|
assert.doesNotMatch(bashTool?.description ?? "", /Do not use bash to create, move, rename, or delete project files/);
|
|
141
143
|
assert.equal(bashTool?.inputSchema?.properties?.timeout, undefined);
|
|
142
|
-
assert.match(bashTool?.description ?? "", /
|
|
143
|
-
assert.
|
|
144
|
-
|
|
145
|
-
assert.
|
|
146
|
-
assert.match(
|
|
147
|
-
writeStdinTool?.inputSchema?.properties?.processId?.description ?? "",
|
|
148
|
-
/Canonical process identifier/,
|
|
149
|
-
);
|
|
150
|
-
assert.match(
|
|
151
|
-
writeStdinTool?.inputSchema?.properties?.sessionId?.description ?? "",
|
|
152
|
-
/Deprecated alias for processId/,
|
|
153
|
-
);
|
|
144
|
+
assert.match(bashTool?.description ?? "", /action=process/);
|
|
145
|
+
assert.equal(bashTool?.inputSchema?.properties?.yieldTimeMs?.maximum, 300000);
|
|
146
|
+
assert.match(bashTool?.inputSchema?.properties?.processId?.description ?? "", /action=process/);
|
|
147
|
+
assert.match(bashTool?.inputSchema?.properties?.interrupt?.description ?? "", /SIGINT/);
|
|
154
148
|
const openWorkspaceTool = tools.find((tool) => tool.name === "open_workspace");
|
|
155
149
|
assert.ok(openWorkspaceTool?.inputSchema?.properties?.workspaceId);
|
|
156
150
|
assert.ok(openWorkspaceTool?.inputSchema?.properties?.newWorkspace);
|
|
@@ -234,7 +228,7 @@ try {
|
|
|
234
228
|
"worktree.managed",
|
|
235
229
|
"filesystem.rename-move",
|
|
236
230
|
"filesystem.delete",
|
|
237
|
-
"process.
|
|
231
|
+
"process.lifecycle",
|
|
238
232
|
"hooks.lifecycle",
|
|
239
233
|
"capability-guides.read",
|
|
240
234
|
"inspection.search-tools",
|
|
@@ -306,6 +300,25 @@ try {
|
|
|
306
300
|
assert.equal(shell.structuredContent.running, false);
|
|
307
301
|
pass("bash", "foreground command completed through ProcessManager");
|
|
308
302
|
|
|
303
|
+
const background = callTool(oauth.accessToken, sessionId, 61, "bash", {
|
|
304
|
+
workspaceId,
|
|
305
|
+
action: "run",
|
|
306
|
+
command: `${JSON.stringify(process.execPath)} -e "setTimeout(() => console.log('debug-process-ok'), 100)"`,
|
|
307
|
+
yieldTimeMs: 0,
|
|
308
|
+
});
|
|
309
|
+
assert.equal(background.structuredContent.running, true);
|
|
310
|
+
assert.equal(typeof background.structuredContent.processId, "number");
|
|
311
|
+
const polled = callTool(oauth.accessToken, sessionId, 62, "bash", {
|
|
312
|
+
workspaceId,
|
|
313
|
+
action: "process",
|
|
314
|
+
processId: background.structuredContent.processId,
|
|
315
|
+
yieldTimeMs: 5_000,
|
|
316
|
+
});
|
|
317
|
+
assert.equal(polled.structuredContent.running, false);
|
|
318
|
+
assert.equal(polled.structuredContent.exitCode, 0);
|
|
319
|
+
assert.match(polled.structuredContent.result, /debug-process-ok/);
|
|
320
|
+
pass("bash process", "action=run -> processId -> action=process completed through one MCP tool");
|
|
321
|
+
|
|
309
322
|
const failedEdit = callTool(oauth.accessToken, sessionId, 7, "edit", {
|
|
310
323
|
workspaceId,
|
|
311
324
|
path: "acceptance.txt",
|
|
@@ -390,7 +403,7 @@ try {
|
|
|
390
403
|
path: "feature.txt",
|
|
391
404
|
content: "debug worktree acceptance\n",
|
|
392
405
|
});
|
|
393
|
-
const closed = callTool(oauth.accessToken, sessionId, 10, "
|
|
406
|
+
const closed = callTool(oauth.accessToken, sessionId, 10, "close_workspace", {
|
|
394
407
|
workspaceId: worktreeWorkspaceId,
|
|
395
408
|
commitMessage: "test(debug): verify 7677 worktree lifecycle",
|
|
396
409
|
});
|
|
@@ -401,7 +414,7 @@ try {
|
|
|
401
414
|
"debug worktree acceptance\n",
|
|
402
415
|
);
|
|
403
416
|
pass(
|
|
404
|
-
"managed worktree close",
|
|
417
|
+
"managed worktree workspace close",
|
|
405
418
|
`${closed.structuredContent.branch} -> ${closed.structuredContent.targetBranch}`,
|
|
406
419
|
);
|
|
407
420
|
|
|
@@ -734,6 +747,7 @@ function exerciseReleaseTagHooks(accessToken, sessionId) {
|
|
|
734
747
|
assert.ok(existsSync(join(releaseProject, "release-ci-ran.txt")));
|
|
735
748
|
assert.deepEqual(JSON.parse(readFileSync(join(releaseProject, "release-ci-ran.txt"), "utf8")), {
|
|
736
749
|
tool: "bash",
|
|
750
|
+
action: "run",
|
|
737
751
|
command: "git push origin v0.2.0",
|
|
738
752
|
workingDirectory: ".",
|
|
739
753
|
originalCommand: "git status --short && git push origin v0.2.0 && echo release-pushed",
|