clawt 3.8.10 → 3.9.1

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/README.md CHANGED
@@ -338,7 +338,7 @@ Configuration file is located at `~/.clawt/config.json`, auto-generated after in
338
338
  | `autoPullPush` | `false` | Auto pull/push after merge |
339
339
  | `confirmDestructiveOps` | `true` | Confirm before destructive operations |
340
340
  | `maxConcurrency` | `0` | Max concurrency for run command, `0` means unlimited |
341
- | `terminalApp` | `"auto"` | Terminal for batch resume: `auto` / `iterm2` / `terminal` |
341
+ | `terminalApp` | `"auto"` | Terminal for batch resume: `auto` / `iterm2` / `terminal` / `cmux` |
342
342
  | `resumeInPlace` | `false` | Resume single selection in current terminal; `false` opens in new tab |
343
343
  | `aliases` | `{}` | Command alias mapping (e.g., `{"l": "list", "r": "run"}`) |
344
344
  | `autoUpdate` | `true` | Auto-check for new versions (checks npm registry every 24 hours) |
@@ -374,6 +374,10 @@ Hooks run asynchronously in the background (fire-and-forget), not blocking the m
374
374
 
375
375
  > **Priority:** `--yes` > `CI` > `CLAWT_NON_INTERACTIVE` > default interactive mode
376
376
 
377
+ **Internally injected environment variables:**
378
+
379
+ All non-interactive Claude Code sessions launched via `claude -p` (task-executor and conflict-resolver) automatically inject the environment variable `CLAUDE_CODE_ENTRYPOINT="cli"`, which enables these sessions to be resumed via `--continue`. This does not apply to interactive Claude Code sessions (e.g., `clawt resume`).
380
+
377
381
  ## Logs
378
382
 
379
383
  Logs are saved in `~/.clawt/logs/`, rotated by date, retained for 30 days.
package/README.zh-CN.md CHANGED
@@ -374,6 +374,10 @@ hook 以 fire-and-forget 模式后台异步并行执行,不阻塞主流程。
374
374
 
375
375
  > **优先级:** `--yes` > `CI` > `CLAWT_NON_INTERACTIVE` > 默认交互模式
376
376
 
377
+ **内部注入的环境变量:**
378
+
379
+ 所有通过 `claude -p` 启动的非交互式 Claude Code 子进程(task-executor 和 conflict-resolver)会自动注入环境变量 `CLAUDE_CODE_ENTRYPOINT="cli"`,使这些会话支持通过 `--continue` 恢复。不适用于交互式启动 Claude Code 的场景(如 `clawt resume`)。
380
+
377
381
  ## 日志
378
382
 
379
383
  日志保存在 `~/.clawt/logs/`,按日期滚动,保留 30 天。
package/dist/index.js CHANGED
@@ -545,9 +545,9 @@ var COVER_VALIDATE_MESSAGES = {
545
545
  /** 无快照,提示先执行 validate */
546
546
  COVER_VALIDATE_NO_SNAPSHOT: (branch) => `\u672A\u627E\u5230\u5206\u652F ${branch} \u7684 validate \u5FEB\u7167
547
547
  \u8BF7\u5148\u6267\u884C clawt validate -b ${branch} \u521B\u5EFA\u5FEB\u7167`,
548
- /** patch 应用失败 */
549
- COVER_VALIDATE_APPLY_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Apatch \u5E94\u7528\u51FA\u9519
550
- \u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u5DE5\u4F5C\u533A\u72B6\u6001\u540E\u91CD\u8BD5`,
548
+ /** 覆盖失败(tree checkout/clean 失败) */
549
+ COVER_VALIDATE_COVER_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Atree checkout \u6216\u6E05\u7406\u64CD\u4F5C\u51FA\u9519
550
+ \u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u72B6\u6001\u540E\u91CD\u8BD5`,
551
551
  /** 工作区和暂存区无修改,可能为误操作 */
552
552
  COVER_VALIDATE_WORKING_DIR_CLEAN: "\u5F53\u524D\u9A8C\u8BC1\u5206\u652F\u7684\u5DE5\u4F5C\u533A\u548C\u6682\u5B58\u533A\u6CA1\u6709\u4EFB\u4F55\u4FEE\u6539\uFF0C\u53EF\u80FD\u4E3A\u8BEF\u64CD\u4F5C",
553
553
  /** 覆盖成功 */
@@ -703,7 +703,7 @@ var EXIT_CODES = {
703
703
  };
704
704
 
705
705
  // src/constants/terminal.ts
706
- var VALID_TERMINAL_APPS = ["auto", "iterm2", "terminal"];
706
+ var VALID_TERMINAL_APPS = ["auto", "iterm2", "terminal", "cmux"];
707
707
  var ITERM2_APP_PATH = "/Applications/iTerm.app";
708
708
 
709
709
  // src/constants/config.ts
@@ -732,7 +732,7 @@ var CONFIG_DEFINITIONS = {
732
732
  },
733
733
  terminalApp: {
734
734
  defaultValue: "auto",
735
- description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\uFF08macOS\uFF09",
735
+ description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\u3001cmux\uFF08macOS\uFF09",
736
736
  allowedValues: VALID_TERMINAL_APPS
737
737
  },
738
738
  resumeInPlace: {
@@ -1252,6 +1252,9 @@ function gitResetHard(cwd) {
1252
1252
  function gitCleanForce(cwd) {
1253
1253
  execCommand("git clean -fd", { cwd });
1254
1254
  }
1255
+ function gitCheckoutIndexForce(cwd) {
1256
+ execCommand("git checkout-index -f -a", { cwd });
1257
+ }
1255
1258
  function gitStashPush(message, cwd) {
1256
1259
  execCommand(`git stash push -m "${message}"`, { cwd });
1257
1260
  }
@@ -2015,14 +2018,20 @@ import { existsSync as existsSync6 } from "fs";
2015
2018
  function isITerm2Installed() {
2016
2019
  return existsSync6(ITERM2_APP_PATH);
2017
2020
  }
2021
+ function isCmuxEnvironment() {
2022
+ return !!process.env.CMUX_WORKSPACE_ID;
2023
+ }
2018
2024
  function detectTerminalApp() {
2019
2025
  const configured = getConfigValue("terminalApp");
2020
- if (configured === "iterm2" || configured === "terminal") {
2026
+ if (configured === "iterm2" || configured === "terminal" || configured === "cmux") {
2021
2027
  return configured;
2022
2028
  }
2023
2029
  if (!VALID_TERMINAL_APPS.includes(configured)) {
2024
2030
  logger.warn(`terminalApp \u914D\u7F6E\u503C "${configured}" \u65E0\u6548\uFF0C\u6709\u6548\u503C: ${VALID_TERMINAL_APPS.join(", ")}\uFF0C\u5C06\u4F7F\u7528\u81EA\u52A8\u68C0\u6D4B`);
2025
2031
  }
2032
+ if (isCmuxEnvironment()) {
2033
+ return "cmux";
2034
+ }
2026
2035
  if (isITerm2Installed()) {
2027
2036
  return "iterm2";
2028
2037
  }
@@ -2060,14 +2069,52 @@ tell application "iTerm"
2060
2069
  end tell
2061
2070
  `.trim();
2062
2071
  }
2063
- function openCommandInNewTerminalTab(command, tabTitle) {
2064
- if (process.platform !== "darwin") {
2065
- throw new ClawtError("\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0\uFF08\u901A\u8FC7 AppleScript \u6253\u5F00\u7EC8\u7AEF Tab\uFF09");
2072
+ function openCommandInCmuxSurface(command, title) {
2073
+ if (!isCmuxEnvironment()) {
2074
+ throw new ClawtError(
2075
+ "\u5F53\u524D\u4E0D\u5728 cmux \u73AF\u5883\u4E2D\uFF0C\u65E0\u6CD5\u521B\u5EFA surface\n\u8BF7\u786E\u4FDD\u5728 cmux \u7EC8\u7AEF\u4E2D\u6267\u884C clawt resume \u547D\u4EE4\uFF0C\u6216\u4FEE\u6539 terminalApp \u914D\u7F6E"
2076
+ );
2066
2077
  }
2067
- const terminalApp = detectTerminalApp();
2068
- const script = terminalApp === "iterm2" ? buildITermAppleScript(command, tabTitle) : buildTerminalAppleScript(command, tabTitle);
2069
- logger.debug(`\u6253\u5F00\u7EC8\u7AEF Tab [${terminalApp}]: ${tabTitle}`);
2078
+ logger.debug(`\u5728 cmux \u4E2D\u521B\u5EFA\u65B0 surface: ${title}`);
2070
2079
  logger.debug(`\u6267\u884C\u547D\u4EE4: ${command}`);
2080
+ try {
2081
+ const newSurfaceResult = execFileSync3("cmux", [
2082
+ "new-split",
2083
+ "right"
2084
+ // 在右侧创建新 surface
2085
+ ], {
2086
+ encoding: "utf-8",
2087
+ stdio: ["pipe", "pipe", "pipe"],
2088
+ timeout: 5e3
2089
+ // 5秒超时
2090
+ });
2091
+ logger.debug(`new-split \u8F93\u51FA: ${newSurfaceResult}`);
2092
+ const match = newSurfaceResult.match(/(?:OK\s+)?(surface:\d+)/i);
2093
+ if (!match) {
2094
+ throw new Error(`\u65E0\u6CD5\u89E3\u6790 cmux new-split \u8F93\u51FA: ${newSurfaceResult}`);
2095
+ }
2096
+ const surfaceRef = match[1];
2097
+ logger.debug(`\u5DF2\u521B\u5EFA surface: ${surfaceRef}`);
2098
+ execFileSync3("cmux", [
2099
+ "send",
2100
+ "--surface",
2101
+ surfaceRef,
2102
+ `${command}\\n`
2103
+ // 追加换行符以自动执行命令
2104
+ ], {
2105
+ encoding: "utf-8",
2106
+ stdio: ["pipe", "pipe", "pipe"],
2107
+ timeout: 5e3
2108
+ });
2109
+ logger.debug(`\u5DF2\u5411 ${surfaceRef} \u53D1\u9001\u547D\u4EE4`);
2110
+ } catch (error) {
2111
+ const message = error instanceof Error ? error.message : String(error);
2112
+ throw new ClawtError(`\u5728 cmux \u4E2D\u521B\u5EFA surface \u5931\u8D25: ${message}`);
2113
+ }
2114
+ }
2115
+ function executeAppleScript(script, terminalApp) {
2116
+ logger.debug(`\u6253\u5F00\u7EC8\u7AEF Tab [${terminalApp}]`);
2117
+ logger.debug(`\u6267\u884C AppleScript`);
2071
2118
  try {
2072
2119
  execFileSync3("osascript", ["-e", script], {
2073
2120
  encoding: "utf-8",
@@ -2079,6 +2126,27 @@ function openCommandInNewTerminalTab(command, tabTitle) {
2079
2126
  throw new ClawtError(`\u6253\u5F00\u7EC8\u7AEF Tab \u5931\u8D25: ${message}${accessibilityHint}`);
2080
2127
  }
2081
2128
  }
2129
+ function openCommandInNewTerminalTab(command, tabTitle) {
2130
+ if (process.platform !== "darwin") {
2131
+ throw new ClawtError("\u6279\u91CF resume \u76EE\u524D\u4EC5\u652F\u6301 macOS \u5E73\u53F0");
2132
+ }
2133
+ const terminalApp = detectTerminalApp();
2134
+ switch (terminalApp) {
2135
+ case "cmux":
2136
+ openCommandInCmuxSurface(command, tabTitle);
2137
+ break;
2138
+ case "iterm2":
2139
+ const itermScript = buildITermAppleScript(command, tabTitle);
2140
+ executeAppleScript(itermScript, "iterm2");
2141
+ break;
2142
+ case "terminal":
2143
+ const terminalScript = buildTerminalAppleScript(command, tabTitle);
2144
+ executeAppleScript(terminalScript, "terminal");
2145
+ break;
2146
+ default:
2147
+ throw new ClawtError(`\u4E0D\u652F\u6301\u7684\u7EC8\u7AEF\u7C7B\u578B: ${terminalApp}`);
2148
+ }
2149
+ }
2082
2150
 
2083
2151
  // src/utils/claude.ts
2084
2152
  function encodeClaudeProjectPath(absolutePath) {
@@ -5085,7 +5153,7 @@ function findTargetWorktreePath(branchName) {
5085
5153
  }
5086
5154
  return match.path;
5087
5155
  }
5088
- function computeIncrementalPatch(snapshotTreeHash, mainWorktreePath) {
5156
+ function computeWorktreeTreeHash(mainWorktreePath) {
5089
5157
  const savedIndexTreeHash = gitWriteTree(mainWorktreePath);
5090
5158
  let currentTreeHash;
5091
5159
  try {
@@ -5094,11 +5162,7 @@ function computeIncrementalPatch(snapshotTreeHash, mainWorktreePath) {
5094
5162
  } finally {
5095
5163
  gitReadTree(savedIndexTreeHash, mainWorktreePath);
5096
5164
  }
5097
- if (snapshotTreeHash === currentTreeHash) {
5098
- return null;
5099
- }
5100
- const patch = gitDiffTree(snapshotTreeHash, currentTreeHash, mainWorktreePath);
5101
- return { patch, currentTreeHash };
5165
+ return currentTreeHash;
5102
5166
  }
5103
5167
  async function handleCoverValidate() {
5104
5168
  await runPreChecks({ requireMainWorktree: true, requireHead: true, requireProjectConfig: true });
@@ -5120,18 +5184,20 @@ async function handleCoverValidate() {
5120
5184
  const confirmed = await confirmAction("\u662F\u5426\u7EE7\u7EED\u6267\u884C\u8986\u76D6\uFF1F");
5121
5185
  if (!confirmed) return;
5122
5186
  }
5123
- const result = computeIncrementalPatch(snapshotTreeHash, mainWorktreePath);
5124
- if (!result) {
5187
+ const currentTreeHash = computeWorktreeTreeHash(mainWorktreePath);
5188
+ if (snapshotTreeHash === currentTreeHash) {
5125
5189
  printInfo(MESSAGES.COVER_VALIDATE_NO_CHANGES);
5126
5190
  return;
5127
5191
  }
5128
5192
  try {
5129
- gitApplyFromStdin(result.patch, targetWorktreePath);
5193
+ gitReadTree(currentTreeHash, targetWorktreePath);
5194
+ gitCheckoutIndexForce(targetWorktreePath);
5195
+ gitCleanForce(targetWorktreePath);
5130
5196
  } catch (error) {
5131
- logger.error(`cover-validate patch apply \u5931\u8D25: ${error}`);
5132
- throw new ClawtError(MESSAGES.COVER_VALIDATE_APPLY_FAILED(targetBranchName));
5197
+ logger.error(`cover-validate \u8986\u76D6\u5931\u8D25: ${error}`);
5198
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_COVER_FAILED(targetBranchName));
5133
5199
  }
5134
- writeSnapshot(projectName, targetBranchName, result.currentTreeHash);
5200
+ writeSnapshot(projectName, targetBranchName, currentTreeHash);
5135
5201
  printSuccess(MESSAGES.COVER_VALIDATE_SUCCESS(targetBranchName));
5136
5202
  }
5137
5203
 
@@ -522,9 +522,9 @@ var COVER_VALIDATE_MESSAGES = {
522
522
  /** 无快照,提示先执行 validate */
523
523
  COVER_VALIDATE_NO_SNAPSHOT: (branch) => `\u672A\u627E\u5230\u5206\u652F ${branch} \u7684 validate \u5FEB\u7167
524
524
  \u8BF7\u5148\u6267\u884C clawt validate -b ${branch} \u521B\u5EFA\u5FEB\u7167`,
525
- /** patch 应用失败 */
526
- COVER_VALIDATE_APPLY_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Apatch \u5E94\u7528\u51FA\u9519
527
- \u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u5DE5\u4F5C\u533A\u72B6\u6001\u540E\u91CD\u8BD5`,
525
+ /** 覆盖失败(tree checkout/clean 失败) */
526
+ COVER_VALIDATE_COVER_FAILED: (branch) => `\u8986\u76D6\u53D8\u66F4\u5230 worktree ${branch} \u5931\u8D25\uFF1Atree checkout \u6216\u6E05\u7406\u64CD\u4F5C\u51FA\u9519
527
+ \u8BF7\u68C0\u67E5\u76EE\u6807 worktree \u72B6\u6001\u540E\u91CD\u8BD5`,
528
528
  /** 工作区和暂存区无修改,可能为误操作 */
529
529
  COVER_VALIDATE_WORKING_DIR_CLEAN: "\u5F53\u524D\u9A8C\u8BC1\u5206\u652F\u7684\u5DE5\u4F5C\u533A\u548C\u6682\u5B58\u533A\u6CA1\u6709\u4EFB\u4F55\u4FEE\u6539\uFF0C\u53EF\u80FD\u4E3A\u8BEF\u64CD\u4F5C",
530
530
  /** 覆盖成功 */
@@ -641,7 +641,7 @@ var MESSAGES = {
641
641
  };
642
642
 
643
643
  // src/constants/terminal.ts
644
- var VALID_TERMINAL_APPS = ["auto", "iterm2", "terminal"];
644
+ var VALID_TERMINAL_APPS = ["auto", "iterm2", "terminal", "cmux"];
645
645
 
646
646
  // src/constants/config.ts
647
647
  var CONFIG_DEFINITIONS = {
@@ -667,7 +667,7 @@ var CONFIG_DEFINITIONS = {
667
667
  },
668
668
  terminalApp: {
669
669
  defaultValue: "auto",
670
- description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\uFF08macOS\uFF09",
670
+ description: "\u6279\u91CF resume \u4F7F\u7528\u7684\u7EC8\u7AEF\u5E94\u7528\uFF1Aauto\uFF08\u81EA\u52A8\u68C0\u6D4B\uFF09\u3001iterm2\u3001terminal\u3001cmux\uFF08macOS\uFF09",
671
671
  allowedValues: VALID_TERMINAL_APPS
672
672
  },
673
673
  resumeInPlace: {
@@ -39,7 +39,7 @@
39
39
  | `autoPullPush` | `boolean` | `false` | merge 成功后是否自动执行 git pull 和 git push |
40
40
  | `confirmDestructiveOps` | `boolean` | `true` | 执行破坏性操作(reset、validate --clean)前是否提示确认 |
41
41
  | `maxConcurrency` | `number` | `0` | run 命令默认最大并发数,`0` 表示不限制 |
42
- | `terminalApp` | `string` | `"auto"` | 批量 resume 使用的终端应用:`auto`(自动检测)、`iterm2`、`terminal`(macOS) |
42
+ | `terminalApp` | `string` | `"auto"` | 批量 resume 使用的终端应用:`auto`(自动检测)、`iterm2`、`terminal`、`cmux`(macOS) |
43
43
  | `resumeInPlace` | `boolean` | `false` | resume 单选时是否在当前终端就地打开,`false` 则通过 `terminalApp` 在新 Tab 中打开 |
44
44
  | `aliases` | `Record<string, string>` | `{}` | 命令别名映射,键为别名,值为目标内置命令名 |
45
45
  | `autoUpdate` | `boolean` | `true` | 是否启用自动更新检查(每 24 小时通过 npm registry 检查一次新版本) |
@@ -44,20 +44,25 @@ clawt cover
44
44
 
45
45
  > 工作区干净时通常意味着用户没有在验证分支上做任何修改就执行了 cover,这大概率是误操作。增加确认提示可以避免不必要的覆盖操作。
46
46
 
47
- ##### 步骤 4:计算增量 patch
47
+ ##### 步骤 4:计算当前 tree hash
48
48
 
49
- 通过 `computeIncrementalPatch()` 计算验证分支上相对于快照的增量变更:
49
+ 通过 `computeWorktreeTreeHash()` 计算验证分支当前的完整 tree hash:
50
50
 
51
- 1. 保存当前暂存区的 tree hash(`savedIndexTreeHash`),用于后续恢复
52
- 2. `git add .` + `git write-tree` 获取当前工作区的完整 tree hash(`currentTreeHash`)
53
- 3. 通过 `git read-tree` 恢复原始暂存区状态(无论成功失败都执行,在 `finally` 块中)
54
- 4. 比较 `snapshotTreeHash` 与 `currentTreeHash`:
55
- - **相同** → 无增量变更,输出提示后返回
56
- - **不同** → 通过 `git diff-tree` 生成 patch
51
+ 1. 保存当前暂存区的 tree hash,用于后续恢复
52
+ 2. `git add .` + `git write-tree` 获取当前工作区的完整 tree hash
53
+ 3. 通过 `git read-tree` 恢复原始暂存区状态
57
54
 
58
- ##### 步骤 5:应用 patch 到目标 worktree
55
+ 比较 snapshotTreeHash currentTreeHash,如果相同则无变更,输出提示后返回。
59
56
 
60
- 将增量 patch 通过 `git apply --binary` 应用到目标 worktree 的工作区。如果 patch apply 失败,报错退出并提示用户检查目标 worktree 工作区状态。
57
+ ##### 步骤 5:直接覆盖目标 worktree
58
+
59
+ 采用 **直接 checkout tree** 方式,实现真正的覆盖语义:
60
+
61
+ 1. **写入暂存区**:通过 `git read-tree <currentTreeHash>` 将验证分支的完整 tree 写入目标 worktree 的暂存区
62
+ 2. **强制检出工作区**:通过 `git checkout-index -f -a` 将暂存区内容强制写入工作区
63
+ 3. **清理残留文件**:通过 `git clean -fd` 删除目标 worktree 中未跟踪文件
64
+
65
+ > **关键优势**:无基准依赖,无条件覆盖目标 worktree,符合 cover 的语义。
61
66
 
62
67
  ##### 步骤 6:更新快照
63
68
 
@@ -78,7 +83,7 @@ clawt cover
78
83
  | `COVER_VALIDATE_NO_SNAPSHOT` | 无快照 | 提示先执行 `clawt validate -b <branch>` 创建快照 |
79
84
  | `COVER_VALIDATE_NO_CHANGES` | 无增量变更 | 提示无需覆盖 |
80
85
  | `COVER_VALIDATE_WORKING_DIR_CLEAN` | 工作区干净 | 提示可能为误操作,需确认是否继续 |
81
- | `COVER_VALIDATE_APPLY_FAILED` | patch 应用失败 | 提示检查目标 worktree 工作区状态后重试 |
86
+ | `COVER_VALIDATE_COVER_FAILED` | tree checkout/clean 失败 | 提示检查目标 worktree 状态后重试 |
82
87
 
83
88
  **实现要点:**
84
89
 
@@ -87,7 +92,11 @@ clawt cover
87
92
  - 辅助函数:
88
93
  - `extractTargetBranchName()`:从验证分支名提取目标分支名
89
94
  - `findTargetWorktreePath()`:查找目标 worktree 路径
90
- - `computeIncrementalPatch()`:计算增量 patch
95
+ - `computeWorktreeTreeHash()`:计算当前工作区 tree hash(保存并恢复暂存区状态)
96
+ - Git 工具函数:
97
+ - `gitReadTree()`:将 tree 写入暂存区
98
+ - `gitCheckoutIndexForce()`:强制检出暂存区到工作区
99
+ - `gitCleanForce()`:清理未跟踪文件
91
100
  - 消息常量:`COVER_VALIDATE_MESSAGES`(`src/constants/messages/cover-validate.ts`)
92
101
  - `writeSnapshot` 调用时只传 `treeHash`,利用其可选参数特性保留磁盘上的 `headCommitHash` 和 `stagedTreeHash` 原值
93
102
 
package/docs/merge.md CHANGED
@@ -104,7 +104,7 @@ clawt merge [-m <commitMessage>]
104
104
  | `auto` | — | 直接调用 AI 解决,不询问 |
105
105
  | `manual` | — | 输出冲突提示信息,用户手动解决 |
106
106
 
107
- AI 解决冲突时,调用 Claude Code CLI 在主 worktree 中分析并解决冲突文件,超时时间由配置项 `conflictResolveTimeoutMs` 控制(默认 15 分钟)。AI 解决成功后自动执行 `git add . && git merge --continue` 完成合并。
107
+ AI 解决冲突时,调用 Claude Code CLI 在主 worktree 中分析并解决冲突文件,超时时间由配置项 `conflictResolveTimeoutMs` 控制(默认 15 分钟)。冲突解决子进程通过 `getEnvWithoutNestedSessionFlag()` 启动,会自动注入环境变量 `CLAUDE_CODE_ENTRYPOINT="cli"` 使会话支持 `--continue` 恢复。AI 解决成功后自动执行 `git add . && git merge --continue` 完成合并。
108
108
  10. **推送(受 `autoPullPush` 配置控制)**
109
109
  - `autoPullPush` 为 `false` → 输出提示 `已跳过自动 pull/push,请手动执行 git pull && git push`
110
110
  - `autoPullPush` 为 `true` → 执行 `git pull` + `git push`:
package/docs/resume.md CHANGED
@@ -88,13 +88,20 @@ clawt resume -f tasks.md -c 2
88
88
 
89
89
  | 配置值 | 行为 |
90
90
  | ---------- | ------------------------------------------------------------ |
91
- | `auto` | 自动检测:优先检测 iTerm2 是否已安装(`/Applications/iTerm.app`),已安装则使用 iTerm2,否则降级到 Terminal.app |
92
- | `iterm2` | 强制使用 iTerm2 |
93
- | `terminal` | 强制使用 Terminal.app |
91
+ | `auto` | 自动检测:优先检测 cmux 环境(通过 `CMUX_WORKSPACE_ID` 环境变量),在 cmux 环境时在当前 workspace 创建新 surface;否则检测 iTerm2 是否已安装,已安装则使用 iTerm2,否则降级到 Terminal.app |
92
+ | `cmux` | 在当前 cmux workspace 中创建新 surface 执行命令 |
93
+ | `iterm2` | 强制使用 iTerm2 创建新 Tab |
94
+ | `terminal` | 强制使用 Terminal.app 创建新 Tab |
94
95
 
95
- **平台限制:** 批量恢复目前仅支持 macOS 平台(通过 AppleScript 打开终端 Tab)。非 macOS 平台会抛出错误。
96
+ **平台限制:** 批量恢复目前仅支持 macOS 平台。非 macOS 平台会抛出错误。
96
97
 
97
- **权限要求:** Terminal.app 通过 System Events 模拟键盘操作(`Cmd+T`)新建 Tab,需要在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用。iTerm2 使用原生 AppleScript 接口,无需辅助功能权限。
98
+ **cmux 集成说明:**
99
+ - 环境检测:通过 `CMUX_WORKSPACE_ID` 环境变量判断是否在 cmux 环境中
100
+ - 创建 surface:使用 `cmux new-split right` 在右侧创建新 surface
101
+ - 执行命令:通过 `cmux send --surface <surface-id> <command>\n` 发送命令(`\n` 触发自动执行)
102
+ - 输出解析:支持简短格式 `surface:24` 和完整格式 `OK surface:24 pane:14 workspace:5`
103
+
104
+ **权限要求:** Terminal.app 通过 System Events 模拟键盘操作(`Cmd+T`)新建 Tab,需要在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用。iTerm2 使用原生 AppleScript 接口,无需辅助功能权限。cmux 通过 CLI 命令操作,无需特殊权限。
98
105
 
99
106
  启动命令通过配置项 `claudeCodeCommand`(默认值 `claude`)指定,与 `clawt run` 不传 `--tasks` 时的交互式界面行为一致。
100
107
 
package/docs/run.md CHANGED
@@ -89,6 +89,7 @@ clawt run -b <branchName>
89
89
  claude -p "<tasks[i]>" --output-format stream-json --verbose --permission-mode bypassPermissions --append-system-prompt "<系统提示>"
90
90
  ```
91
91
  其中 `--append-system-prompt` 使用统一的 `APPEND_SYSTEM_PROMPT` 常量(定义在 `src/constants/config.ts`)。
92
+ 子进程通过 `spawnProcess()`(`src/utils/shell.ts`)启动,会自动注入环境变量 `CLAUDE_CODE_ENTRYPOINT="cli"`(通过 `getEnvWithoutNestedSessionFlag()` 函数),使会话支持通过 `--continue` 恢复。
92
93
  使用 `stream-json` 格式可实时获取 Claude Code 的流式事件(工具调用、文本输出、最终结果),用于在进度面板中显示每个任务的实时活动描述和结果预览。流式事件解析由 `src/utils/stream-parser.ts` 负责。
93
94
  6. 进入**事件监听通知**阶段(见 [5.3](#53-任务完成通知机制))
94
95
  7. **中断处理(Ctrl+C / SIGINT)**
package/docs/spec.md CHANGED
@@ -316,6 +316,12 @@ async function interactiveConfigEditor<T extends object>(
316
316
 
317
317
  > **非交互模式判断优先级:** CLI `--yes` 选项 > `CI` 环境变量 > `CLAWT_NON_INTERACTIVE` 环境变量 > 默认交互模式。实现见 `src/utils/interactive.ts`。
318
318
 
319
+ **Clawt 内部注入的环境变量:**
320
+
321
+ | 环境变量 | 值 | 说明 |
322
+ | -------- | --- | ---- |
323
+ | `CLAUDE_CODE_ENTRYPOINT` | `cli` | 所有通过 `claude -p` 启动的非交互式 Claude Code 子进程(task-executor 和 conflict-resolver)会自动注入此环境变量,使这些会话支持通过 `--continue` 恢复。常量定义在 `src/constants/config.ts` 的 `CLAUDE_CODE_ENTRYPOINT_VALUE`,注入逻辑在 `src/utils/shell.ts` 的 `getEnvWithoutNestedSessionFlag()` 函数中实现。不适用于交互式启动 Claude Code 的场景(如 `clawt resume`)。 |
324
+
319
325
  所有命令执行前,都必须先执行**主 worktree 校验**(见 [2.1](#21-主-worktree-的定义与定位规则))。
320
326
 
321
327
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawt",
3
- "version": "3.8.10",
3
+ "version": "3.9.1",
4
4
  "description": "本地并行执行多个Claude Code Agent任务,融合 Git Worktree 与 Claude Code CLI 的命令行工具",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,8 +15,8 @@ import {
15
15
  gitAddAll,
16
16
  gitWriteTree,
17
17
  gitReadTree,
18
- gitDiffTree,
19
- gitApplyFromStdin,
18
+ gitCheckoutIndexForce,
19
+ gitCleanForce,
20
20
  printSuccess,
21
21
  printInfo,
22
22
  isWorkingDirClean,
@@ -62,15 +62,12 @@ export function findTargetWorktreePath(branchName: string): string {
62
62
  }
63
63
 
64
64
  /**
65
- * 计算验证分支上相对于快照的增量 patch
65
+ * 计算验证分支当前的 tree hash(保存并恢复暂存区状态)
66
66
  * 操作序列:git write-tree(保存暂存区)→ git add . → git write-tree(获取工作区 tree)→ git read-tree(恢复暂存区)
67
- * 当 snapshotTreeHash 与当前 tree hash 相同时返回 null 表示无变更
68
- * @param {string} snapshotTreeHash - 快照中记录的 tree hash
69
67
  * @param {string} mainWorktreePath - 主 worktree 路径
70
- * @returns {{ patch: Buffer; currentTreeHash: string } | null} 增量 patch 和当前 tree hash,无变更时返回 null
68
+ * @returns {string} 当前工作区对应的 tree hash
71
69
  */
72
- export function computeIncrementalPatch(snapshotTreeHash: string, mainWorktreePath: string): { patch: Buffer; currentTreeHash: string } | null {
73
- // 先保存当前暂存区的 tree hash,用于后续恢复
70
+ export function computeWorktreeTreeHash(mainWorktreePath: string): string {
74
71
  const savedIndexTreeHash = gitWriteTree(mainWorktreePath);
75
72
  let currentTreeHash: string;
76
73
 
@@ -78,17 +75,10 @@ export function computeIncrementalPatch(snapshotTreeHash: string, mainWorktreePa
78
75
  gitAddAll(mainWorktreePath);
79
76
  currentTreeHash = gitWriteTree(mainWorktreePath);
80
77
  } finally {
81
- // 无论成功或失败,都通过 git read-tree 恢复原始暂存区状态
82
78
  gitReadTree(savedIndexTreeHash, mainWorktreePath);
83
79
  }
84
80
 
85
- // 快照 tree 与当前 tree 相同,无增量变更
86
- if (snapshotTreeHash === currentTreeHash) {
87
- return null;
88
- }
89
-
90
- const patch = gitDiffTree(snapshotTreeHash, currentTreeHash, mainWorktreePath);
91
- return { patch, currentTreeHash };
81
+ return currentTreeHash;
92
82
  }
93
83
 
94
84
  /**
@@ -127,23 +117,26 @@ async function handleCoverValidate(): Promise<void> {
127
117
  if (!confirmed) return;
128
118
  }
129
119
 
130
- // 步骤 4:计算增量 patch
131
- const result = computeIncrementalPatch(snapshotTreeHash, mainWorktreePath);
132
- if (!result) {
120
+ // 步骤 4:计算当前 tree hash
121
+ const currentTreeHash = computeWorktreeTreeHash(mainWorktreePath);
122
+
123
+ if (snapshotTreeHash === currentTreeHash) {
133
124
  printInfo(MESSAGES.COVER_VALIDATE_NO_CHANGES);
134
125
  return;
135
126
  }
136
127
 
137
- // 步骤 5:应用 patch 到目标 worktree
128
+ // 步骤 5:直接将 tree 应用到目标 worktree
138
129
  try {
139
- gitApplyFromStdin(result.patch, targetWorktreePath);
130
+ gitReadTree(currentTreeHash, targetWorktreePath);
131
+ gitCheckoutIndexForce(targetWorktreePath);
132
+ gitCleanForce(targetWorktreePath);
140
133
  } catch (error) {
141
- logger.error(`cover-validate patch apply 失败: ${error}`);
142
- throw new ClawtError(MESSAGES.COVER_VALIDATE_APPLY_FAILED(targetBranchName));
134
+ logger.error(`cover-validate 覆盖失败: ${error}`);
135
+ throw new ClawtError(MESSAGES.COVER_VALIDATE_COVER_FAILED(targetBranchName));
143
136
  }
144
137
 
145
138
  // 步骤 6:更新快照 treeHash(使后续再次 cover 的基准正确),HEAD 和 stagedTreeHash 不变
146
- writeSnapshot(projectName, targetBranchName, result.currentTreeHash);
139
+ writeSnapshot(projectName, targetBranchName, currentTreeHash);
147
140
 
148
141
  printSuccess(MESSAGES.COVER_VALIDATE_SUCCESS(targetBranchName));
149
142
  }
@@ -5,7 +5,10 @@ import { VALID_TERMINAL_APPS } from './terminal.js';
5
5
  export const APPEND_SYSTEM_PROMPT =
6
6
  'Currently, you are in the git worktree directory.';
7
7
 
8
- /** 通过 clawt 启动的 Claude Code 非交互式会话(claude -p)的 entrypoint 标识 */
8
+ /**
9
+ * 通过 clawt 启动的 Claude Code 非交互式会话(claude -p)的 entrypoint 标识
10
+ * 设置为 'cli' 使 claude -p 启动的会话可以通过 --continue 恢复
11
+ */
9
12
  export const CLAUDE_CODE_ENTRYPOINT_VALUE = 'cli';
10
13
 
11
14
  /**
@@ -35,7 +38,7 @@ export const CONFIG_DEFINITIONS: ConfigDefinitions = {
35
38
  },
36
39
  terminalApp: {
37
40
  defaultValue: 'auto',
38
- description: '批量 resume 使用的终端应用:auto(自动检测)、iterm2、terminal(macOS)',
41
+ description: '批量 resume 使用的终端应用:auto(自动检测)、iterm2、terminal、cmux(macOS)',
39
42
  allowedValues: VALID_TERMINAL_APPS,
40
43
  },
41
44
  resumeInPlace: {
@@ -10,9 +10,9 @@ export const COVER_VALIDATE_MESSAGES = {
10
10
  /** 无快照,提示先执行 validate */
11
11
  COVER_VALIDATE_NO_SNAPSHOT: (branch: string) =>
12
12
  `未找到分支 ${branch} 的 validate 快照\n 请先执行 clawt validate -b ${branch} 创建快照`,
13
- /** patch 应用失败 */
14
- COVER_VALIDATE_APPLY_FAILED: (branch: string) =>
15
- `覆盖变更到 worktree ${branch} 失败:patch 应用出错\n 请检查目标 worktree 工作区状态后重试`,
13
+ /** 覆盖失败(tree checkout/clean 失败) */
14
+ COVER_VALIDATE_COVER_FAILED: (branch: string) =>
15
+ `覆盖变更到 worktree ${branch} 失败:tree checkout 或清理操作出错\n 请检查目标 worktree 状态后重试`,
16
16
  /** 工作区和暂存区无修改,可能为误操作 */
17
17
  COVER_VALIDATE_WORKING_DIR_CLEAN: '当前验证分支的工作区和暂存区没有任何修改,可能为误操作',
18
18
  /** 覆盖成功 */
@@ -13,7 +13,7 @@ export const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
13
13
  export const PASTE_THRESHOLD_MS = 10;
14
14
 
15
15
  /** 配置项 terminalApp 允许的有效值 */
16
- export const VALID_TERMINAL_APPS: readonly string[] = ['auto', 'iterm2', 'terminal'];
16
+ export const VALID_TERMINAL_APPS: readonly string[] = ['auto', 'iterm2', 'terminal', 'cmux'];
17
17
 
18
18
  /** iTerm2 应用路径,用于 auto 模式检测是否已安装 */
19
19
  export const ITERM2_APP_PATH = '/Applications/iTerm.app';
@@ -10,7 +10,7 @@ export interface ClawtConfig {
10
10
  confirmDestructiveOps: boolean;
11
11
  /** run 命令默认最大并发数,0 表示不限制 */
12
12
  maxConcurrency: number;
13
- /** 批量 resume 使用的终端应用:'auto'(自动检测)、'iterm2'、'terminal'(macOS) */
13
+ /** 批量 resume 使用的终端应用:'auto'(自动检测)、'iterm2'、'terminal'、'cmux'(macOS) */
14
14
  terminalApp: string;
15
15
  /** resume 单选时是否在当前终端就地打开,false 则通过 terminalApp 在新 Tab 中打开 */
16
16
  resumeInPlace: boolean;
@@ -159,6 +159,14 @@ export function gitCleanForce(cwd?: string): void {
159
159
  execCommand('git clean -fd', { cwd });
160
160
  }
161
161
 
162
+ /**
163
+ * git checkout-index -f -a,将暂存区内容强制写入工作区(覆盖工作区文件)
164
+ * @param {string} [cwd] - 工作目录
165
+ */
166
+ export function gitCheckoutIndexForce(cwd?: string): void {
167
+ execCommand('git checkout-index -f -a', { cwd });
168
+ }
169
+
162
170
  /**
163
171
  * git stash push -m <message>
164
172
  * @param {string} message - stash 消息
@@ -22,6 +22,7 @@ export {
22
22
  gitPush,
23
23
  gitResetHard,
24
24
  gitCleanForce,
25
+ gitCheckoutIndexForce,
25
26
  gitStashPush,
26
27
  gitStashApply,
27
28
  gitStashPop,
@@ -6,7 +6,9 @@ import { throwIfGitIndexLockError } from './git-lock.js';
6
6
 
7
7
  /**
8
8
  * 获取移除了 CLAUDECODE 嵌套会话标记的环境变量副本,并注入 CLAUDE_CODE_ENTRYPOINT 标识
9
- * 仅用于 claude -p 等非交互式子进程,避免被 Claude Code 误判为嵌套会话而拒绝启动
9
+ * 仅用于 claude -p 等非交互式子进程:
10
+ * - 移除 CLAUDECODE:避免被 Claude Code 误判为嵌套会话而拒绝启动
11
+ * - 注入 CLAUDE_CODE_ENTRYPOINT="cli":使 claude -p 会话支持 --continue 恢复
10
12
  * 不适用于交互式启动 Claude Code(如 clawt resume),交互式场景应保留原始环境变量
11
13
  * @returns {NodeJS.ProcessEnv} 移除 CLAUDECODE 并注入 CLAUDE_CODE_ENTRYPOINT 后的环境变量
12
14
  */
@@ -6,7 +6,7 @@ import { VALID_TERMINAL_APPS, ITERM2_APP_PATH } from '../constants/index.js';
6
6
  import { getConfigValue } from './config.js';
7
7
 
8
8
  /** 终端应用类型 */
9
- type TerminalApp = 'iterm2' | 'terminal';
9
+ type TerminalApp = 'iterm2' | 'terminal' | 'cmux';
10
10
 
11
11
  /**
12
12
  * 检测系统是否安装了 iTerm2
@@ -17,17 +17,28 @@ function isITerm2Installed(): boolean {
17
17
  return existsSync(ITERM2_APP_PATH);
18
18
  }
19
19
 
20
+ /**
21
+ * 检测当前是否在 cmux 环境中运行
22
+ * 通过检查环境变量 CMUX_WORKSPACE_ID 是否存在来判断
23
+ * @returns {boolean} 是否在 cmux 环境中
24
+ */
25
+ export function isCmuxEnvironment(): boolean {
26
+ return !!process.env.CMUX_WORKSPACE_ID;
27
+ }
28
+
20
29
  /**
21
30
  * 检测当前使用的终端应用
22
- * 优先读取配置项 terminalApp;值为 'auto' 时优先检测 iTerm2 是否已安装,
23
- * 已安装则使用 iTerm2,否则降级到 Terminal.app
24
- * @returns {TerminalApp} 终端类型:'iterm2' 或 'terminal'
31
+ * 优先读取配置项 terminalApp;值为 'auto' 时按以下顺序检测:
32
+ * 1. cmux 环境(通过 CMUX_WORKSPACE_ID 环境变量)
33
+ * 2. iTerm2 是否已安装
34
+ * 3. 降级到 Terminal.app
35
+ * @returns {TerminalApp} 终端类型:'iterm2'、'terminal' 或 'cmux'
25
36
  */
26
37
  export function detectTerminalApp(): TerminalApp {
27
38
  const configured = getConfigValue('terminalApp');
28
39
 
29
40
  // 配置了明确的终端类型,直接使用
30
- if (configured === 'iterm2' || configured === 'terminal') {
41
+ if (configured === 'iterm2' || configured === 'terminal' || configured === 'cmux') {
31
42
  return configured;
32
43
  }
33
44
 
@@ -36,10 +47,15 @@ export function detectTerminalApp(): TerminalApp {
36
47
  logger.warn(`terminalApp 配置值 "${configured}" 无效,有效值: ${VALID_TERMINAL_APPS.join(', ')},将使用自动检测`);
37
48
  }
38
49
 
39
- // auto 模式:优先检测 iTerm2 是否已安装
50
+ // auto 模式:优先检测 cmux 环境
51
+ if (isCmuxEnvironment()) {
52
+ return 'cmux';
53
+ }
54
+
40
55
  if (isITerm2Installed()) {
41
56
  return 'iterm2';
42
57
  }
58
+
43
59
  return 'terminal';
44
60
  }
45
61
 
@@ -99,25 +115,78 @@ end tell
99
115
  }
100
116
 
101
117
  /**
102
- * 在新终端 Tab 中执行命令
103
- * 自动检测终端类型(iTerm2 / Terminal.app),通过 AppleScript 打开新 Tab
118
+ * 在当前 cmux workspace 中分割创建新 surface 并执行命令
104
119
  * @param {string} command - 要执行的 shell 命令
105
- * @param {string} tabTitle - Tab 标题
106
- * @throws {ClawtError} macOS 平台或 AppleScript 执行失败时抛出
120
+ * @param {string} title - surface 标题(用于日志)
121
+ * @throws {ClawtError} 不在 cmux 环境中或 CLI 执行失败时抛出
107
122
  */
108
- export function openCommandInNewTerminalTab(command: string, tabTitle: string): void {
109
- if (process.platform !== 'darwin') {
110
- throw new ClawtError('批量 resume 目前仅支持 macOS 平台(通过 AppleScript 打开终端 Tab)');
123
+ function openCommandInCmuxSurface(command: string, title: string): void {
124
+ // 环境检查:只需要检查 WORKSPACE_ID
125
+ if (!isCmuxEnvironment()) {
126
+ throw new ClawtError(
127
+ '当前不在 cmux 环境中,无法创建 surface\n' +
128
+ '请确保在 cmux 终端中执行 clawt resume 命令,或修改 terminalApp 配置'
129
+ );
111
130
  }
112
131
 
113
- const terminalApp = detectTerminalApp();
114
- const script = terminalApp === 'iterm2'
115
- ? buildITermAppleScript(command, tabTitle)
116
- : buildTerminalAppleScript(command, tabTitle);
117
-
118
- logger.debug(`打开终端 Tab [${terminalApp}]: ${tabTitle}`);
132
+ logger.debug(`在 cmux 中创建新 surface: ${title}`);
119
133
  logger.debug(`执行命令: ${command}`);
120
134
 
135
+ try {
136
+ // 步骤 1:分割创建新 surface(利用默认值机制)
137
+ const newSurfaceResult = execFileSync('cmux', [
138
+ 'new-split',
139
+ 'right', // 在右侧创建新 surface
140
+ ], {
141
+ encoding: 'utf-8',
142
+ stdio: ['pipe', 'pipe', 'pipe'],
143
+ timeout: 5000, // 5秒超时
144
+ });
145
+
146
+ logger.debug(`new-split 输出: ${newSurfaceResult}`);
147
+
148
+ // 步骤 2:解析输出获取新 surface ID
149
+ // 输出格式可能是:
150
+ // - "surface:24"(简短引用)
151
+ // - "OK surface:24 pane:14 workspace:5"(带 OK 前缀)
152
+ // 需要灵活匹配
153
+ const match = newSurfaceResult.match(/(?:OK\s+)?(surface:\d+)/i);
154
+ if (!match) {
155
+ throw new Error(`无法解析 cmux new-split 输出: ${newSurfaceResult}`);
156
+ }
157
+ const surfaceRef = match[1];
158
+
159
+ logger.debug(`已创建 surface: ${surfaceRef}`);
160
+
161
+ // 步骤 3:向新 surface 发送命令(追加 \n 自动执行)
162
+ execFileSync('cmux', [
163
+ 'send',
164
+ '--surface', surfaceRef,
165
+ `${command}\\n`, // 追加换行符以自动执行命令
166
+ ], {
167
+ encoding: 'utf-8',
168
+ stdio: ['pipe', 'pipe', 'pipe'],
169
+ timeout: 5000,
170
+ });
171
+
172
+ logger.debug(`已向 ${surfaceRef} 发送命令`);
173
+
174
+ } catch (error) {
175
+ const message = error instanceof Error ? error.message : String(error);
176
+ throw new ClawtError(`在 cmux 中创建 surface 失败: ${message}`);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 执行 AppleScript 脚本
182
+ * @param {string} script - AppleScript 内容
183
+ * @param {TerminalApp} terminalApp - 终端类型('iterm2' 或 'terminal')
184
+ * @throws {ClawtError} AppleScript 执行失败时抛出
185
+ */
186
+ function executeAppleScript(script: string, terminalApp: 'iterm2' | 'terminal'): void {
187
+ logger.debug(`打开终端 Tab [${terminalApp}]`);
188
+ logger.debug(`执行 AppleScript`);
189
+
121
190
  try {
122
191
  execFileSync('osascript', ['-e', script], {
123
192
  encoding: 'utf-8',
@@ -125,10 +194,42 @@ export function openCommandInNewTerminalTab(command: string, tabTitle: string):
125
194
  });
126
195
  } catch (error) {
127
196
  const message = error instanceof Error ? error.message : String(error);
128
- // Terminal.app 通过 System Events 模拟键盘操作需要辅助功能权限
129
197
  const accessibilityHint = terminalApp === 'terminal'
130
198
  ? '\n提示:Terminal.app 需要辅助功能权限,请在「系统设置 → 隐私与安全性 → 辅助功能」中授权终端应用'
131
199
  : '';
132
200
  throw new ClawtError(`打开终端 Tab 失败: ${message}${accessibilityHint}`);
133
201
  }
134
202
  }
203
+
204
+ /**
205
+ * 在新终端 Tab 或 cmux Surface 中执行命令
206
+ * 自动检测终端类型(cmux / iTerm2 / Terminal.app)
207
+ * - cmux:在当前 pane 创建新 surface
208
+ * - iTerm2 / Terminal.app:通过 AppleScript 打开新 Tab
209
+ * @param {string} command - 要执行的 shell 命令
210
+ * @param {string} tabTitle - Tab 或 surface 标题
211
+ * @throws {ClawtError} 非 macOS 平台或终端打开失败时抛出
212
+ */
213
+ export function openCommandInNewTerminalTab(command: string, tabTitle: string): void {
214
+ if (process.platform !== 'darwin') {
215
+ throw new ClawtError('批量 resume 目前仅支持 macOS 平台');
216
+ }
217
+
218
+ const terminalApp = detectTerminalApp();
219
+
220
+ switch (terminalApp) {
221
+ case 'cmux':
222
+ openCommandInCmuxSurface(command, tabTitle);
223
+ break;
224
+ case 'iterm2':
225
+ const itermScript = buildITermAppleScript(command, tabTitle);
226
+ executeAppleScript(itermScript, 'iterm2');
227
+ break;
228
+ case 'terminal':
229
+ const terminalScript = buildTerminalAppleScript(command, tabTitle);
230
+ executeAppleScript(terminalScript, 'terminal');
231
+ break;
232
+ default:
233
+ throw new ClawtError(`不支持的终端类型: ${terminalApp}`);
234
+ }
235
+ }
@@ -21,7 +21,7 @@ vi.mock('../../../src/constants/index.js', () => ({
21
21
  COVER_VALIDATE_NO_CHANGES: '验证分支上没有相对于快照的增量修改,无需覆盖',
22
22
  COVER_VALIDATE_TARGET_NOT_FOUND: (branch: string) => `未找到分支 ${branch} 对应的 worktree`,
23
23
  COVER_VALIDATE_NO_SNAPSHOT: (branch: string) => `未找到分支 ${branch} 的 validate 快照`,
24
- COVER_VALIDATE_APPLY_FAILED: (branch: string) => `覆盖变更到 worktree ${branch} 失败`,
24
+ COVER_VALIDATE_COVER_FAILED: (branch: string) => `覆盖变更到 worktree ${branch} 失败`,
25
25
  COVER_VALIDATE_SUCCESS: (branch: string) => `✓ 已将验证分支上的修改覆盖到 worktree => ${branch}`,
26
26
  COVER_VALIDATE_WORKING_DIR_CLEAN: '当前验证分支的工作区和暂存区没有任何修改,可能为误操作',
27
27
  },
@@ -42,8 +42,8 @@ vi.mock('../../../src/utils/index.js', () => ({
42
42
  gitAddAll: vi.fn(),
43
43
  gitWriteTree: vi.fn().mockReturnValue('current-tree-hash'),
44
44
  gitReadTree: vi.fn(),
45
- gitDiffTree: vi.fn().mockReturnValue(Buffer.from('fake-patch')),
46
- gitApplyFromStdin: vi.fn(),
45
+ gitCheckoutIndexForce: vi.fn(),
46
+ gitCleanForce: vi.fn(),
47
47
  printSuccess: vi.fn(),
48
48
  printInfo: vi.fn(),
49
49
  isWorkingDirClean: vi.fn().mockReturnValue(false),
@@ -53,7 +53,7 @@ vi.mock('../../../src/utils/index.js', () => ({
53
53
  isNonInteractive: vi.fn().mockReturnValue(false),
54
54
  }));
55
55
 
56
- import { registerCoverValidateCommand, extractTargetBranchName, findTargetWorktreePath, computeIncrementalPatch } from '../../../src/commands/cover-validate.js';
56
+ import { registerCoverValidateCommand, extractTargetBranchName, findTargetWorktreePath, computeWorktreeTreeHash } from '../../../src/commands/cover-validate.js';
57
57
  import {
58
58
  getCurrentBranch,
59
59
  getProjectWorktrees,
@@ -64,8 +64,8 @@ import {
64
64
  gitAddAll,
65
65
  gitWriteTree,
66
66
  gitReadTree,
67
- gitDiffTree,
68
- gitApplyFromStdin,
67
+ gitCheckoutIndexForce,
68
+ gitCleanForce,
69
69
  printSuccess,
70
70
  printInfo,
71
71
  isWorkingDirClean,
@@ -81,8 +81,8 @@ const mockedWriteSnapshot = vi.mocked(writeSnapshot);
81
81
  const mockedGitAddAll = vi.mocked(gitAddAll);
82
82
  const mockedGitWriteTree = vi.mocked(gitWriteTree);
83
83
  const mockedGitReadTree = vi.mocked(gitReadTree);
84
- const mockedGitDiffTree = vi.mocked(gitDiffTree);
85
- const mockedGitApplyFromStdin = vi.mocked(gitApplyFromStdin);
84
+ const mockedGitCheckoutIndexForce = vi.mocked(gitCheckoutIndexForce);
85
+ const mockedGitCleanForce = vi.mocked(gitCleanForce);
86
86
  const mockedPrintSuccess = vi.mocked(printSuccess);
87
87
  const mockedPrintInfo = vi.mocked(printInfo);
88
88
  const mockedIsWorkingDirClean = vi.mocked(isWorkingDirClean);
@@ -99,7 +99,6 @@ beforeEach(() => {
99
99
  mockedIsWorkingDirClean.mockReturnValue(false);
100
100
  mockedConfirmAction.mockResolvedValue(true);
101
101
  mockedGitWriteTree.mockReturnValue('current-tree-hash');
102
- mockedGitDiffTree.mockReturnValue(Buffer.from('fake-patch'));
103
102
  });
104
103
 
105
104
  describe('registerCoverValidateCommand', () => {
@@ -130,27 +129,16 @@ describe('findTargetWorktreePath', () => {
130
129
  });
131
130
  });
132
131
 
133
- describe('computeIncrementalPatch', () => {
134
- it('有增量变更时返回 patch 和 currentTreeHash', () => {
132
+ describe('computeWorktreeTreeHash', () => {
133
+ it('返回当前工作区的 tree hash', () => {
135
134
  mockedGitWriteTree
136
135
  .mockReturnValueOnce('saved-index-tree') // 保存暂存区
137
136
  .mockReturnValueOnce('new-tree-hash'); // git add . 后的 tree
138
137
 
139
- const result = computeIncrementalPatch('snapshot-tree-hash', '/repo');
140
- expect(result).not.toBeNull();
141
- expect(result!.currentTreeHash).toBe('new-tree-hash');
138
+ const result = computeWorktreeTreeHash('/repo');
139
+ expect(result).toBe('new-tree-hash');
142
140
  expect(mockedGitAddAll).toHaveBeenCalledWith('/repo');
143
141
  expect(mockedGitReadTree).toHaveBeenCalledWith('saved-index-tree', '/repo');
144
- expect(mockedGitDiffTree).toHaveBeenCalledWith('snapshot-tree-hash', 'new-tree-hash', '/repo');
145
- });
146
-
147
- it('无增量变更时返回 null', () => {
148
- mockedGitWriteTree
149
- .mockReturnValueOnce('saved-index-tree')
150
- .mockReturnValueOnce('snapshot-tree-hash'); // 与快照相同
151
-
152
- const result = computeIncrementalPatch('snapshot-tree-hash', '/repo');
153
- expect(result).toBeNull();
154
142
  });
155
143
  });
156
144
 
@@ -163,7 +151,7 @@ describe('handleCoverValidate - 工作区干净检查', () => {
163
151
  await program.parseAsync(['cover'], { from: 'user' });
164
152
  }
165
153
 
166
- it('工作区干净且用户取消时不执行 patch', async () => {
154
+ it('工作区干净且用户取消时不执行覆盖', async () => {
167
155
  mockedIsWorkingDirClean.mockReturnValue(true);
168
156
  mockedConfirmAction.mockResolvedValue(false);
169
157
 
@@ -173,14 +161,14 @@ describe('handleCoverValidate - 工作区干净检查', () => {
173
161
  expect(mockedConfirmAction).toHaveBeenCalledWith('是否继续执行覆盖?');
174
162
  // 用户取消后不应继续执行后续逻辑
175
163
  expect(mockedGitAddAll).not.toHaveBeenCalled();
176
- expect(mockedGitApplyFromStdin).not.toHaveBeenCalled();
164
+ expect(mockedGitCheckoutIndexForce).not.toHaveBeenCalled();
165
+ expect(mockedGitCleanForce).not.toHaveBeenCalled();
177
166
  expect(mockedPrintSuccess).not.toHaveBeenCalled();
178
167
  });
179
168
 
180
169
  it('工作区干净且用户确认继续时正常执行', async () => {
181
170
  mockedIsWorkingDirClean.mockReturnValue(true);
182
171
  mockedConfirmAction.mockResolvedValue(true);
183
- // computeIncrementalPatch 返回有 patch 的结果
184
172
  mockedGitWriteTree
185
173
  .mockReturnValueOnce('saved-index-tree')
186
174
  .mockReturnValueOnce('new-tree-hash');
@@ -188,7 +176,9 @@ describe('handleCoverValidate - 工作区干净检查', () => {
188
176
  await runCover();
189
177
 
190
178
  expect(mockedConfirmAction).toHaveBeenCalledWith('是否继续执行覆盖?');
191
- expect(mockedGitApplyFromStdin).toHaveBeenCalled();
179
+ expect(mockedGitReadTree).toHaveBeenCalledWith('new-tree-hash', '/path/feature');
180
+ expect(mockedGitCheckoutIndexForce).toHaveBeenCalledWith('/path/feature');
181
+ expect(mockedGitCleanForce).toHaveBeenCalledWith('/path/feature');
192
182
  expect(mockedPrintSuccess).toHaveBeenCalled();
193
183
  });
194
184
 
@@ -201,7 +191,9 @@ describe('handleCoverValidate - 工作区干净检查', () => {
201
191
  await runCover();
202
192
 
203
193
  expect(mockedConfirmAction).not.toHaveBeenCalled();
204
- expect(mockedGitApplyFromStdin).toHaveBeenCalled();
194
+ expect(mockedGitReadTree).toHaveBeenCalledWith('new-tree-hash', '/path/feature');
195
+ expect(mockedGitCheckoutIndexForce).toHaveBeenCalledWith('/path/feature');
196
+ expect(mockedGitCleanForce).toHaveBeenCalledWith('/path/feature');
205
197
  expect(mockedPrintSuccess).toHaveBeenCalled();
206
198
  });
207
199
  });
@@ -36,6 +36,7 @@ import {
36
36
  gitPush,
37
37
  gitResetHard,
38
38
  gitCleanForce,
39
+ gitCheckoutIndexForce,
39
40
  gitStashPush,
40
41
  gitStashApply,
41
42
  gitStashPop,
@@ -251,6 +252,13 @@ describe('gitCleanForce', () => {
251
252
  });
252
253
  });
253
254
 
255
+ describe('gitCheckoutIndexForce', () => {
256
+ it('执行 git checkout-index -f -a', () => {
257
+ gitCheckoutIndexForce('/repo');
258
+ expect(mockedExecCommand).toHaveBeenCalledWith('git checkout-index -f -a', { cwd: '/repo' });
259
+ });
260
+ });
261
+
254
262
  describe('gitStashPush', () => {
255
263
  it('执行 git stash push -m', () => {
256
264
  gitStashPush('auto-stash', '/repo');
@@ -0,0 +1,288 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ // mock node:child_process
4
+ vi.mock('node:child_process', () => ({
5
+ execFileSync: vi.fn(),
6
+ }));
7
+
8
+ // mock node:fs
9
+ vi.mock('node:fs', () => ({
10
+ existsSync: vi.fn(),
11
+ }));
12
+
13
+ // mock logger
14
+ vi.mock('../../../src/logger/index.js', () => ({
15
+ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
16
+ }));
17
+
18
+ // mock config
19
+ vi.mock('../../../src/utils/config.js', () => ({
20
+ getConfigValue: vi.fn(),
21
+ }));
22
+
23
+ import { execFileSync } from 'node:child_process';
24
+ import { existsSync } from 'node:fs';
25
+ import {
26
+ isCmuxEnvironment,
27
+ detectTerminalApp,
28
+ openCommandInNewTerminalTab,
29
+ } from '../../../src/utils/terminal.js';
30
+ import { getConfigValue } from '../../../src/utils/config.js';
31
+
32
+ const mockedExecFileSync = vi.mocked(execFileSync);
33
+ const mockedExistsSync = vi.mocked(existsSync);
34
+ const mockedGetConfigValue = vi.mocked(getConfigValue);
35
+
36
+ describe('cmux 环境检测', () => {
37
+ const originalEnv = process.env;
38
+
39
+ beforeEach(() => {
40
+ // 每个测试前重置环境变量
41
+ vi.resetModules();
42
+ process.env = { ...originalEnv };
43
+ });
44
+
45
+ afterEach(() => {
46
+ // 测试后恢复原始环境变量
47
+ process.env = originalEnv;
48
+ });
49
+
50
+ describe('isCmuxEnvironment', () => {
51
+ it('CMUX_WORKSPACE_ID 存在时返回 true', () => {
52
+ process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28';
53
+ expect(isCmuxEnvironment()).toBe(true);
54
+ });
55
+
56
+ it('CMUX_WORKSPACE_ID 不存在时返回 false', () => {
57
+ delete process.env.CMUX_WORKSPACE_ID;
58
+ expect(isCmuxEnvironment()).toBe(false);
59
+ });
60
+ });
61
+ });
62
+
63
+ describe('终端检测优先级', () => {
64
+ const originalEnv = process.env;
65
+
66
+ beforeEach(() => {
67
+ vi.clearAllMocks();
68
+ process.env = { ...originalEnv };
69
+ });
70
+
71
+ afterEach(() => {
72
+ process.env = originalEnv;
73
+ });
74
+
75
+ it('明确配置 cmux 时返回 cmux', () => {
76
+ mockedGetConfigValue.mockReturnValue('cmux');
77
+ expect(detectTerminalApp()).toBe('cmux');
78
+ });
79
+
80
+ it('明确配置 iterm2 时返回 iterm2', () => {
81
+ mockedGetConfigValue.mockReturnValue('iterm2');
82
+ expect(detectTerminalApp()).toBe('iterm2');
83
+ });
84
+
85
+ it('明确配置 terminal 时返回 terminal', () => {
86
+ mockedGetConfigValue.mockReturnValue('terminal');
87
+ expect(detectTerminalApp()).toBe('terminal');
88
+ });
89
+
90
+ it('auto 模式下 cmux 环境优先级最高', () => {
91
+ mockedGetConfigValue.mockReturnValue('auto');
92
+ process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28'; // 在 cmux 环境中
93
+
94
+ expect(detectTerminalApp()).toBe('cmux');
95
+ });
96
+
97
+ it('auto 模式下非 cmux 环境降级到 iTerm2', () => {
98
+ mockedGetConfigValue.mockReturnValue('auto');
99
+ mockedExistsSync.mockReturnValue(true); // iTerm2 已安装
100
+ delete process.env.CMUX_WORKSPACE_ID; // 不在 cmux 环境中
101
+
102
+ expect(detectTerminalApp()).toBe('iterm2');
103
+ });
104
+
105
+ it('auto 模式下无 iTerm2 时降级到 terminal', () => {
106
+ mockedGetConfigValue.mockReturnValue('auto');
107
+ mockedExistsSync.mockReturnValue(false); // iTerm2 未安装
108
+ delete process.env.CMUX_WORKSPACE_ID; // 不在 cmux 环境中
109
+
110
+ expect(detectTerminalApp()).toBe('terminal');
111
+ });
112
+ });
113
+
114
+ describe('cmux surface 创建', () => {
115
+ const originalEnv = process.env;
116
+ const originalPlatform = process.platform;
117
+
118
+ beforeEach(() => {
119
+ vi.clearAllMocks();
120
+ process.env = { ...originalEnv };
121
+ });
122
+
123
+ afterEach(() => {
124
+ process.env = originalEnv;
125
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
126
+ });
127
+
128
+ it('成功创建 surface 并发送命令(简短格式输出)', () => {
129
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
130
+ process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28';
131
+
132
+ mockedGetConfigValue.mockReturnValue('cmux');
133
+ mockedExecFileSync
134
+ .mockReturnValueOnce('surface:24') // new-split 返回简短格式
135
+ .mockReturnValueOnce(''); // send 返回
136
+
137
+ expect(() => openCommandInNewTerminalTab('claude', 'test-title')).not.toThrow();
138
+
139
+ // 验证 new-split 调用
140
+ expect(mockedExecFileSync).toHaveBeenNthCalledWith(
141
+ 1,
142
+ 'cmux',
143
+ ['new-split', 'right'],
144
+ expect.objectContaining({ timeout: 5000 })
145
+ );
146
+
147
+ // 验证 send 调用(包含 \n 以自动执行)
148
+ expect(mockedExecFileSync).toHaveBeenNthCalledWith(
149
+ 2,
150
+ 'cmux',
151
+ ['send', '--surface', 'surface:24', 'claude\\n'],
152
+ expect.objectContaining({ timeout: 5000 })
153
+ );
154
+ });
155
+
156
+ it('成功创建 surface 并发送命令(带 OK 前缀输出)', () => {
157
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
158
+ process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28';
159
+
160
+ mockedGetConfigValue.mockReturnValue('cmux');
161
+ mockedExecFileSync
162
+ .mockReturnValueOnce('OK surface:24 pane:14 workspace:5') // new-split 返回带前缀格式
163
+ .mockReturnValueOnce(''); // send 返回
164
+
165
+ expect(() => openCommandInNewTerminalTab('claude', 'test-title')).not.toThrow();
166
+
167
+ // 验证解析正确(包含 \n 以自动执行)
168
+ expect(mockedExecFileSync).toHaveBeenNthCalledWith(
169
+ 2,
170
+ 'cmux',
171
+ ['send', '--surface', 'surface:24', 'claude\\n'],
172
+ expect.objectContaining({ timeout: 5000 })
173
+ );
174
+ });
175
+
176
+ it('不在 cmux 环境中时抛出友好错误', () => {
177
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
178
+ delete process.env.CMUX_WORKSPACE_ID;
179
+
180
+ mockedGetConfigValue.mockReturnValue('cmux');
181
+
182
+ expect(() => openCommandInNewTerminalTab('claude', 'test-title')).toThrow(
183
+ /当前不在 cmux 环境中/
184
+ );
185
+ });
186
+
187
+ it('new-split 输出格式无法解析时抛出错误', () => {
188
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
189
+ process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28';
190
+
191
+ mockedGetConfigValue.mockReturnValue('cmux');
192
+ mockedExecFileSync.mockReturnValueOnce('invalid output format');
193
+
194
+ expect(() => openCommandInNewTerminalTab('claude', 'test-title')).toThrow(
195
+ /无法解析 cmux new-split 输出/
196
+ );
197
+ });
198
+
199
+ it('cmux CLI 执行失败时捕获并抛出错误', () => {
200
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
201
+ process.env.CMUX_WORKSPACE_ID = '6E83B1B3-5617-43F0-82FB-75F55E9F3F28';
202
+
203
+ mockedGetConfigValue.mockReturnValue('cmux');
204
+ mockedExecFileSync.mockImplementation(() => {
205
+ const error = new Error('spawn cmux ENOENT');
206
+ throw error;
207
+ });
208
+
209
+ expect(() => openCommandInNewTerminalTab('claude', 'test-title')).toThrow(
210
+ /在 cmux 中创建 surface 失败/
211
+ );
212
+ });
213
+
214
+ it('非 macOS 平台抛出错误', () => {
215
+ Object.defineProperty(process, 'platform', { value: 'linux' });
216
+
217
+ expect(() => openCommandInNewTerminalTab('claude', 'test-title')).toThrow(
218
+ /仅支持 macOS 平台/
219
+ );
220
+ });
221
+ });
222
+
223
+ describe('向后兼容性', () => {
224
+ const originalEnv = process.env;
225
+ const originalPlatform = process.platform;
226
+
227
+ beforeEach(() => {
228
+ vi.clearAllMocks();
229
+ process.env = { ...originalEnv };
230
+ });
231
+
232
+ afterEach(() => {
233
+ process.env = originalEnv;
234
+ Object.defineProperty(process, 'platform', { value: originalPlatform });
235
+ });
236
+
237
+ it('iTerm2 用户不受影响', () => {
238
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
239
+ delete process.env.CMUX_WORKSPACE_ID;
240
+
241
+ mockedGetConfigValue.mockReturnValue('iterm2');
242
+ mockedExecFileSync.mockReturnValue('');
243
+
244
+ expect(() => openCommandInNewTerminalTab('claude', 'test-title')).not.toThrow();
245
+
246
+ // 验证使用 osascript 执行 AppleScript
247
+ expect(mockedExecFileSync).toHaveBeenCalledWith(
248
+ 'osascript',
249
+ expect.arrayContaining([expect.stringContaining('-e')]),
250
+ expect.any(Object)
251
+ );
252
+ });
253
+
254
+ it('Terminal.app 用户不受影响', () => {
255
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
256
+ delete process.env.CMUX_WORKSPACE_ID;
257
+
258
+ mockedGetConfigValue.mockReturnValue('terminal');
259
+ mockedExecFileSync.mockReturnValue('');
260
+
261
+ expect(() => openCommandInNewTerminalTab('claude', 'test-title')).not.toThrow();
262
+
263
+ // 验证使用 osascript 执行 AppleScript
264
+ expect(mockedExecFileSync).toHaveBeenCalledWith(
265
+ 'osascript',
266
+ expect.arrayContaining([expect.stringContaining('-e')]),
267
+ expect.any(Object)
268
+ );
269
+ });
270
+
271
+ it('auto 模式下原有行为不变(无 cmux 环境时)', () => {
272
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
273
+ delete process.env.CMUX_WORKSPACE_ID;
274
+
275
+ mockedGetConfigValue.mockReturnValue('auto');
276
+ mockedExistsSync.mockReturnValue(true); // iTerm2 已安装
277
+ mockedExecFileSync.mockReturnValue('');
278
+
279
+ expect(() => openCommandInNewTerminalTab('claude', 'test-title')).not.toThrow();
280
+
281
+ // 验证使用 iTerm2
282
+ expect(mockedExecFileSync).toHaveBeenCalledWith(
283
+ 'osascript',
284
+ expect.arrayContaining([expect.stringContaining('-e')]),
285
+ expect.any(Object)
286
+ );
287
+ });
288
+ });