@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 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 `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,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
 
@@ -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
  ];
@@ -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
@@ -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 write_stdin."),
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 logical workspace",
1169
- 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.",
1170
1172
  inputSchema: {
1171
- workspaceId: z.string().describe("Logical workspace ID to release."),
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
- .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."),
1206
1179
  },
1207
1180
  outputSchema: resultOutputSchema({
1208
1181
  workspaceId: z.string(),
1209
- sourceRoot: z.string(),
1210
- branch: z.string(),
1211
- targetBranch: z.string(),
1212
- commitSha: z.string(),
1213
- mergedSha: z.string(),
1214
- 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(),
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.closeWorktree,
1196
+ tool: toolNames.closeWorkspace,
1223
1197
  invocation: workspaceHookInvocation(workspace),
1224
- payload: { commitMessage },
1225
- 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,
1226
1203
  operation: async () => {
1227
- const busyWorkspaceIds = workspaces
1228
- .workspaceIdsForPhysicalWorkspace(workspace)
1229
- .filter((id) => processSessions.activeWorkspaceIds().has(id));
1230
- if (busyWorkspaceIds.length > 0) {
1231
- 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);
1232
1247
  }
1233
- const startedAt = performance.now();
1234
- const closed = await workspaces.closeWorktree(workspaceId, commitMessage);
1235
- const result = [
1236
- `Closed managed worktree ${workspaceId}.`,
1237
- `Merged ${closed.branch} into ${closed.targetBranch} by fast-forward.`,
1238
- `Source checkout: ${closed.sourceRoot}`,
1239
- `Commit: ${closed.commitSha}`,
1240
- closed.cleanupWarning
1241
- ? `Cleanup warning: ${closed.cleanupWarning}`
1242
- : "The worktree directory and managed branch were removed.",
1243
- ].join("\n");
1244
- logToolCall(config, {
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
- .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."),
1984
2016
  workingDirectory: z
1985
2017
  .string()
1986
2018
  .optional()
1987
- .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."),
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
- command,
1999
- workingDirectory: workingDirectory ?? ".",
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 cwd = workspaces.resolveWorkingDirectory(workspace, workingDirectory);
2005
- const snapshot = await processSessions.start({
2117
+ const snapshot = await processSessions.write({
2006
2118
  workspaceId,
2007
- command,
2008
- cwd,
2009
- workspaceRoot: workspace.root,
2010
- yieldTimeMs: 300_000,
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 || (snapshot.exitCode === 0 && !snapshot.signal),
2132
+ success: snapshot.running || snapshot.exitCode === 0,
2022
2133
  durationMs: Math.round(performance.now() - startedAt),
2023
2134
  });
2024
- const response = processToolResponse(toolNames.shell, workspaceId, snapshot, {
2025
- command,
2026
- workingDirectory: workingDirectory ?? ".",
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 `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.3",
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",