@akira-tl/forgerelay 0.3.3 → 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 +8 -0
- package/README.md +2 -2
- package/capabilities/managed-worktrees/GUIDE.md +12 -7
- package/capabilities/shell-processes/GUIDE.md +10 -8
- package/dist/capabilities.js +2 -2
- package/dist/mcp/server-instructions.js +3 -4
- package/dist/server.js +212 -102
- 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,14 @@ 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
|
+
|
|
7
15
|
## [0.3.3] - 2026-08-10
|
|
8
16
|
|
|
9
17
|
### 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,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/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
|
];
|
|
@@ -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
|
@@ -461,7 +461,7 @@ function attachCompletedProcessNotices(processSessions, workspaceId, result) {
|
|
|
461
461
|
}
|
|
462
462
|
function processOutputSchema() {
|
|
463
463
|
return resultOutputSchema({
|
|
464
|
-
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."),
|
|
465
465
|
sessionId: z.number().int().positive().optional().describe("Deprecated alias of processId for compatibility."),
|
|
466
466
|
running: z.boolean(),
|
|
467
467
|
exitCode: z.number().int().optional(),
|
|
@@ -613,6 +613,8 @@ function registerProcessTools(server, config, workspaces, processSessions, hooks
|
|
|
613
613
|
});
|
|
614
614
|
});
|
|
615
615
|
}
|
|
616
|
+
if (config.toolMode !== "codex")
|
|
617
|
+
return;
|
|
616
618
|
registerAppTool(server, "write_stdin", {
|
|
617
619
|
title: "Write to process",
|
|
618
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.",
|
|
@@ -1165,53 +1167,25 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1165
1167
|
});
|
|
1166
1168
|
});
|
|
1167
1169
|
registerAppTool(server, toolNames.closeWorkspace, {
|
|
1168
|
-
title: "Close
|
|
1169
|
-
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.",
|
|
1170
1172
|
inputSchema: {
|
|
1171
|
-
workspaceId: z.string().describe("
|
|
1172
|
-
},
|
|
1173
|
-
outputSchema: resultOutputSchema({ workspaceId: z.string() }),
|
|
1174
|
-
_meta: {},
|
|
1175
|
-
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
1176
|
-
}, async ({ workspaceId }) => {
|
|
1177
|
-
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1178
|
-
return runToolWithHooks(hooks, {
|
|
1179
|
-
tool: toolNames.closeWorkspace,
|
|
1180
|
-
invocation: workspaceHookInvocation(workspace),
|
|
1181
|
-
payload: { workspaceId },
|
|
1182
|
-
operation: async () => {
|
|
1183
|
-
if (processSessions.activeWorkspaceIds().has(workspaceId)) {
|
|
1184
|
-
throw new Error(`Workspace ${workspaceId} still owns a running process or an unconsumed process completion. Poll or consume it before closing this workspace.`);
|
|
1185
|
-
}
|
|
1186
|
-
workspaces.closeWorkspace(workspaceId);
|
|
1187
|
-
const result = `Closed logical workspace ${workspaceId}. Physical project files were not removed.`;
|
|
1188
|
-
return {
|
|
1189
|
-
content: [textBlock(result)],
|
|
1190
|
-
structuredContent: { result, workspaceId },
|
|
1191
|
-
};
|
|
1192
|
-
},
|
|
1193
|
-
});
|
|
1194
|
-
});
|
|
1195
|
-
registerAppTool(server, toolNames.closeWorktree, {
|
|
1196
|
-
title: "Close worktree",
|
|
1197
|
-
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.",
|
|
1198
|
-
inputSchema: {
|
|
1199
|
-
workspaceId: z
|
|
1200
|
-
.string()
|
|
1201
|
-
.describe("Managed worktree workspace identifier returned by open_workspace."),
|
|
1173
|
+
workspaceId: z.string().describe("Workspace identifier to close."),
|
|
1202
1174
|
commitMessage: z
|
|
1203
1175
|
.string()
|
|
1204
1176
|
.min(1)
|
|
1205
|
-
.
|
|
1177
|
+
.optional()
|
|
1178
|
+
.describe("Required only for a managed-worktree-backed workspace; concise Git commit message for remaining worktree changes."),
|
|
1206
1179
|
},
|
|
1207
1180
|
outputSchema: resultOutputSchema({
|
|
1208
1181
|
workspaceId: z.string(),
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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(),
|
|
1215
1189
|
cleanupWarning: z.string().optional(),
|
|
1216
1190
|
}),
|
|
1217
1191
|
_meta: {},
|
|
@@ -1219,49 +1193,70 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1219
1193
|
}, async ({ workspaceId, commitMessage }, extra) => {
|
|
1220
1194
|
const workspace = workspaces.getWorkspace(workspaceId);
|
|
1221
1195
|
return runToolWithHooks(hooks, {
|
|
1222
|
-
tool: toolNames.
|
|
1196
|
+
tool: toolNames.closeWorkspace,
|
|
1223
1197
|
invocation: workspaceHookInvocation(workspace),
|
|
1224
|
-
payload: { commitMessage },
|
|
1225
|
-
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,
|
|
1226
1203
|
operation: async () => {
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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);
|
|
1232
1247
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
`
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
tool: toolNames.closeWorktree,
|
|
1246
|
-
...workspaceLogContext(workspace, extra.sessionId),
|
|
1247
|
-
path: closed.sourceRoot,
|
|
1248
|
-
success: true,
|
|
1249
|
-
durationMs: Math.round(performance.now() - startedAt),
|
|
1250
|
-
});
|
|
1251
|
-
return attachHookReports({
|
|
1252
|
-
content: [{ type: "text", text: result }],
|
|
1253
|
-
structuredContent: {
|
|
1254
|
-
result,
|
|
1255
|
-
workspaceId,
|
|
1256
|
-
sourceRoot: closed.sourceRoot,
|
|
1257
|
-
branch: closed.branch,
|
|
1258
|
-
targetBranch: closed.targetBranch,
|
|
1259
|
-
commitSha: closed.commitSha,
|
|
1260
|
-
mergedSha: closed.mergedSha,
|
|
1261
|
-
committed: closed.committed,
|
|
1262
|
-
cleanupWarning: closed.cleanupWarning,
|
|
1263
|
-
},
|
|
1264
|
-
}, 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
|
+
};
|
|
1265
1260
|
},
|
|
1266
1261
|
});
|
|
1267
1262
|
});
|
|
@@ -1978,59 +1973,174 @@ export function createMcpServer(config, workspaces, reviewCheckpoints, processSe
|
|
|
1978
1973
|
workspaceId: z
|
|
1979
1974
|
.string()
|
|
1980
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."),
|
|
1981
1980
|
command: z
|
|
1982
1981
|
.string()
|
|
1983
|
-
.
|
|
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."),
|
|
1984
2016
|
workingDirectory: z
|
|
1985
2017
|
.string()
|
|
1986
2018
|
.optional()
|
|
1987
|
-
.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."),
|
|
1988
2034
|
},
|
|
1989
2035
|
outputSchema: processOutputSchema(),
|
|
1990
2036
|
...toolWidgetDescriptorMeta(config, "shell"),
|
|
1991
2037
|
annotations: SHELL_TOOL_ANNOTATIONS,
|
|
1992
|
-
}, async ({ workspaceId, command, workingDirectory }, extra) => {
|
|
2038
|
+
}, async ({ workspaceId, action = "run", command, processId, input, interrupt, tty, columns, rows, workingDirectory, yieldTimeMs, maxOutputTokens, }, extra) => {
|
|
1993
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
|
+
}
|
|
1994
2103
|
return runToolWithHooks(hooks, {
|
|
1995
2104
|
tool: toolNames.shell,
|
|
1996
2105
|
invocation: workspaceHookInvocation(workspace),
|
|
1997
2106
|
payload: {
|
|
1998
|
-
|
|
1999
|
-
|
|
2107
|
+
action,
|
|
2108
|
+
processId,
|
|
2109
|
+
inputLength: input?.length ?? 0,
|
|
2110
|
+
interrupt: interrupt ?? false,
|
|
2111
|
+
columns,
|
|
2112
|
+
rows,
|
|
2000
2113
|
},
|
|
2001
2114
|
isFailure: toolResultIsError,
|
|
2002
2115
|
operation: async () => {
|
|
2003
2116
|
const startedAt = performance.now();
|
|
2004
|
-
const
|
|
2005
|
-
const snapshot = await processSessions.start({
|
|
2117
|
+
const snapshot = await processSessions.write({
|
|
2006
2118
|
workspaceId,
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2119
|
+
processId,
|
|
2120
|
+
chars: interrupt ? "\u0003" : input,
|
|
2121
|
+
columns,
|
|
2122
|
+
rows,
|
|
2123
|
+
yieldTimeMs,
|
|
2124
|
+
maxOutputTokens,
|
|
2011
2125
|
});
|
|
2012
2126
|
logToolCall(config, {
|
|
2013
2127
|
tool: toolNames.shell,
|
|
2014
2128
|
...workspaceLogContext(workspace, extra.sessionId),
|
|
2015
|
-
workingDirectory: workingDirectory ?? ".",
|
|
2016
|
-
command,
|
|
2017
|
-
commandLength: command.length,
|
|
2018
2129
|
exitCode: snapshot.exitCode,
|
|
2019
2130
|
running: snapshot.running,
|
|
2020
2131
|
processId: snapshot.processId,
|
|
2021
|
-
success: snapshot.running ||
|
|
2132
|
+
success: snapshot.running || snapshot.exitCode === 0,
|
|
2022
2133
|
durationMs: Math.round(performance.now() - startedAt),
|
|
2023
2134
|
});
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2135
|
+
return processToolResponse(toolNames.shell, workspaceId, snapshot, {
|
|
2136
|
+
action,
|
|
2137
|
+
processId,
|
|
2138
|
+
inputLength: input?.length ?? 0,
|
|
2139
|
+
interrupt: interrupt ?? false,
|
|
2027
2140
|
running: snapshot.running,
|
|
2028
2141
|
exitCode: snapshot.exitCode,
|
|
2029
2142
|
wallTimeMs: snapshot.wallTimeMs,
|
|
2030
2143
|
});
|
|
2031
|
-
return !snapshot.running && (snapshot.signal || snapshot.exitCode !== 0)
|
|
2032
|
-
? { ...response, isError: true }
|
|
2033
|
-
: response;
|
|
2034
2144
|
},
|
|
2035
2145
|
});
|
|
2036
2146
|
});
|
|
@@ -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",
|