@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 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 `close_worktree` succeeds, ForgeRelay:
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": "^git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+$"
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 默认关闭。只有 `tools/list` 实际暴露 `download_artifact` 时才调用它;当前安全发布实现主要面向 Linux。
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. 调用 `download_artifact`,传入 Host 原样提供的 native `file` 值、现有 `workspaceId` workspace-relative `path`;
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` 会创建缺失的父目录,但拒绝覆盖已有目标。不要把 native file 值改造成:
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` + `show_changes` 聚合 review;
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
- `tools/list` 暴露 `show_changes` 时,它自己的 tool description 是调用契约:本轮成功修改文件后,在最后一个相关 file mutation 之后、final response 之前调用一次,让用户看到聚合 diff;不要每改一个文件就调用一次。
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
- `show_changes` 使用 Git-backed review checkpoint。它按 workspace 跟踪 open/baseline 状态,展示自上次 review checkpoint 以来的 coherent diff,并在成功 review 后推进 baseline。当前版本要求可用的 Git workspace;checkpoint 缺失或 root 不匹配时会明确失败或使用受控 fallback,而不是凭空重建历史。
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 自己的文件搬运协议。真实可调用工具仍以 `tools/list` 为准;本指南只提供低频流程和边界,不代表隐藏工具。
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` 是逻辑工作身份;managed worktree 是物理 Git worktree。二者不要混用。
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;关闭逻辑 handle 与删除物理 worktree 是不同操作。
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` 与 `close_worktree`
25
+ ## `close_workspace`
26
26
 
27
- `close_workspace` 只释放一个逻辑 `workspaceId`,不会删除 checkout 文件,也不会完成 managed branch 集成。若某个 managed worktree 仍有其他逻辑 handle,释放其中一个 handle 不会移除物理 worktree。
27
+ `close_workspace` 是唯一公开关闭入口,行为由 Workspace backing mode 决定:
28
28
 
29
- `close_worktree` 用于完成一个 managed worktree:
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 使用调用时提供的 commit message 提交;
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、需要 `write_stdin`,或遇到 shell/process 平台边界问题时读取本指南。
3
+ 当命令长时间运行、需要交互式 TTY、需要继续操作已有 `processId`,或遇到 shell/process 平台边界问题时读取本指南。
4
4
 
5
5
  ## Core process model
6
6
 
7
- `bash`(Codex tool mode 下为 `exec_command`)在 open workspace 内启动命令。命令拥有本地用户权限;workspace path containment 不等于 OS sandbox。
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
- ## write_stdin
18
+ ## `bash(action="process")`
19
19
 
20
- 使用同一个 `workspaceId` 和 `processId`:
20
+ 普通命令使用 `bash(action="run")`,其中 `action` 可省略;如果返回 `running: true` 和 `processId`,后续仍通过同一个 `bash` tool 操作该 process:
21
21
 
22
- - 省略 `chars` 或传空字符串:poll
23
- - 传普通字符:向正在运行的进程写入输入;
24
- - `\u0003`:显式发送 Ctrl-C;
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: true` + `write_stdin`;对 tests/builds/formatters 等非交互命令保持默认非 PTY,以获得更稳定的 CI-style 输出。
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
 
@@ -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 = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
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.",
@@ -31,7 +31,7 @@ const CAPABILITY_GUIDE_DEFINITIONS = [
31
31
  },
32
32
  {
33
33
  name: "shell-processes",
34
- description: "Long-running processes, write_stdin, PTY, and platform edges.",
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.write-stdin",
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
- const parsed = definition.inputSchema.safeParse(argumentsValue ?? {});
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
- ok: true,
114
- ...await dependencies.inspectHooks(context.workspaceRoot),
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 command inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. ForgeRelay waits up to 300 seconds, then returns a processId for a still-running command; use ${toolNames.writeStdin} to poll, interact, wait, or send Ctrl-C. Completed background commands may be reported later for the same workspaceId. Call ${toolNames.openWorkspace} first and pass workspaceId. Expose this capability only behind strong authentication.`,
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 returned by ${toolNames.openWorkspace} for this conversation; resume another logical workspaceId only when the user wants that workspace, and request a new logical workspace only when explicitly asked.${staleWorkspacePolicy} Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. ${toolNames.closeWorkspace} releases a logical workspace; ${toolNames.closeWorktree} finalizes a managed worktree. Read the managed-worktrees capability guide for advanced worktree lifecycle and failure semantics.`;
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, use ${toolNames.writeStdin} only when you need to poll, wait, interact, or interrupt it; otherwise you may continue other work and consume its completion notice from a later tool result.`);
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 write_stdin."),
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: false,
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 capabilityResult = await capabilityRegistry.run(name, capabilityArguments ?? {}, capabilityContextFor(workspace));
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(capabilityResult, null, 2)}`)],
1042
- structuredContent: { name, action, result: capabilityResult },
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 logical workspace",
1080
- description: "Release one logical workspaceId after the user chooses cleanup. This does not delete checkout files. Use close_worktree to finalize and remove a managed worktree. Running or unconsumed processes prevent closure.",
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("Logical workspace ID to release."),
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
- .describe("Concise Git commit message describing the completed worktree changes."),
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
- sourceRoot: z.string(),
1121
- branch: z.string(),
1122
- targetBranch: z.string(),
1123
- commitSha: z.string(),
1124
- mergedSha: z.string(),
1125
- committed: z.boolean(),
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.closeWorktree,
1196
+ tool: toolNames.closeWorkspace,
1134
1197
  invocation: workspaceHookInvocation(workspace),
1135
- payload: { commitMessage },
1136
- afterCwd: (response) => response.structuredContent.sourceRoot,
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
- const busyWorkspaceIds = workspaces
1139
- .workspaceIdsForPhysicalWorkspace(workspace)
1140
- .filter((id) => processSessions.activeWorkspaceIds().has(id));
1141
- if (busyWorkspaceIds.length > 0) {
1142
- throw new Error(`Cannot close this worktree while logical workspace processes are still running or awaiting completion delivery: ${busyWorkspaceIds.join(", ")}.`);
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
- const startedAt = performance.now();
1145
- const closed = await workspaces.closeWorktree(workspaceId, commitMessage);
1146
- const result = [
1147
- `Closed managed worktree ${workspaceId}.`,
1148
- `Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
1149
- `Source checkout: ${closed.sourceRoot}`,
1150
- `Commit: ${closed.commitSha}`,
1151
- closed.cleanupWarning
1152
- ? `Cleanup warning: ${closed.cleanupWarning}`
1153
- : "The worktree directory and managed branch were removed.",
1154
- ].join("\n");
1155
- logToolCall(config, {
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.reviewChanges({
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
- .describe(toolDescriptions.shellCommand),
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("Optional working directory relative to the workspace root. Defaults to the workspace root."),
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
- command,
1914
- workingDirectory: workingDirectory ?? ".",
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 cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
1920
- const snapshot = await processSessions.start({
2117
+ const snapshot = await processSessions.write({
1921
2118
  workspaceId,
1922
- command,
1923
- cwd,
1924
- workspaceRoot: workspace.root,
1925
- yieldTimeMs: 300_000,
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 || (snapshot.exitCode === 0 && !snapshot.signal),
2132
+ success: snapshot.running || snapshot.exitCode === 0,
1937
2133
  durationMs: Math.round(performance.now() - startedAt),
1938
2134
  });
1939
- const response = processToolResponse(toolNames.shell, workspaceId, snapshot, {
1940
- command,
1941
- workingDirectory: workingDirectory ?? ".",
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 `close_worktree` with the managed
69
- workspace ID and a commit message.
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 tools and explanatory capability documentation
114
- separate. `tools/list` remains the source of truth for what the current server
115
- actually exposes; 0.3 does not hide callable tools behind documentation.
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 two lightweight discovery surfaces:
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
- write_stdin
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` waits in the foreground
205
- for at most 300 seconds. If the command is still running, ForgeRelay returns a
206
- canonical `processId` without killing it. The Agent can use `write_stdin` to poll,
207
- wait again, interact, or explicitly send Ctrl-C, or continue other work; once the
208
- command finishes, its completion is attached to a later tool result using the
209
- same workspace ID. The former process `sessionId` remains a deprecated alias in
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` provides a smaller Codex-shaped
215
- surface including direct `rename`/`delete` path mutations alongside `apply_patch`,
216
- `exec_command`, and `write_stdin`. `rename` is the unified move/rename primitive
217
- for both files and directories; ForgeRelay does not expose a separate `move` tool.
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
- releases only the logical handle; the last handle for a physical worktree cannot
227
- be released that way and must be finalized with `close_worktree`.
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
@@ -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`, `write_stdin`, and `close_worktree`. |
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 tool surface using `open_workspace`, `close_workspace`, `read`, `rename`, `delete`, `apply_patch`, `exec_command`, `write_stdin`, and `close_worktree`. |
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; it refuses to remove the last
167
- handle anchoring a physical worktree.
168
-
169
- `bash` has no execution-timeout input. It waits in the foreground for at most 300
170
- seconds; if the process is still alive, the result contains `running: true` and a
171
- canonical `processId`. `write_stdin` can poll or interact with that process for up
172
- to another 300 seconds per call. The former `sessionId` field remains a deprecated
173
- alias during the 0.2.x compatibility window. ForgeRelay does not kill a process
174
- merely because a wait window expires. Completed background processes are delivered once with a later
175
- tool result for the same logical workspace ID.
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 `processId` plus the deprecated `sessionId` compatibility alias, the non-blanket `bash` mutation policy, no kill-timeout input, the 300-second foreground-wait contract, workspace resume/stale-workspace schema, and MCP App tool metadata;
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` through `ProcessManager`, and a deliberate failed `edit`;
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 `close_worktree`;
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
- ## `close_worktree` refuses to finish
168
+ ## `close_workspace` refuses to finalize a managed worktree
169
169
 
170
- Close is deliberately refused when:
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
- Integration is fast-forward-only. If histories diverge, rebase and verify inside
178
- the managed worktree, then retry. ForgeRelay does not intentionally leave the
179
- source checkout in a merge-conflict state.
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. `write_stdin` can
145
- poll, wait, interact, or explicitly interrupt it. The former process `sessionId`
146
- remains a deprecated compatibility alias during 0.2.x. An asynchronously completed process
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -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", "close_worktree", "read", "write", "edit", "rename", "delete", "grep", "glob", "ls", "bash", "write_stdin", "capability"]) {
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 ?? "", /waits up to 300 seconds/);
143
- assert.match(bashTool?.description ?? "", /write_stdin/);
144
- const writeStdinTool = tools.find((tool) => tool.name === "write_stdin");
145
- assert.equal(writeStdinTool?.inputSchema?.properties?.yieldTimeMs?.maximum, 300000);
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.write-stdin",
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, "close_worktree", {
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",