Files
claude-code/src/utils/worktree.ts
claude-code-best 58ee6419b1 feat: dynamic-workflow 来了 (#1271)
* feat(workflow): add workflow engine, /workflows panel, /ultracode skill

将 feat/sdk-backend 分支中 workflow 相关的 20 个 commit 压缩为单 commit:

- 工作流引擎核心:phase / agent / parallel / pipeline 编排原语(packages/workflow-engine/)
- /workflows 面板:三区焦点布局(顶部 run tabs + 左侧 phase 侧栏 + 右侧 agent 列表)
- /ultracode skill:多 agent workflow 编排入口
- 进度存储 / journal / notification 系统
- WorkflowService 生命周期管理 + SentryErrorBoundary
- 脚本沙箱:禁用 dynamic import()、JSON args 防御性归一化
- journal 与 named-workflow 路径统一在 projectRoot
- 错误处理:parallel/pipeline hooks 错误日志、failure routing、semaphore abort
- workflow 工具升级为 core 工具 + PascalCase 命名

Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>

* feat(workflow): 复刻 ultracode 手册并修复 worktree/inline/opt-in 三处缺口

围绕 ultracode skill 审查 agent 系统一致性后:
- ultracode.ts: 用系统提示版完整 Workflow 编排手册替换中文精简版
- HIGH#1 isolation:'worktree': claudeCodeBackend.run() 用 createAgentWorktree +
  runWithCwdOverride 包裹 runAgent + finally 清理实现真正的 cwd 隔离;slug 用
  sha256(runId:agentId) 派生以匹配 cleanupStaleAgentWorktrees 清理正则
  (修 runId 为 w+base36 非 UUID 导致的泄漏盲区);worktree.ts 注释同步修正
- HIGH#2 inline 持久化: 新增 persistInlineScript,WorkflowTool + service 两条
  inline 路径对称持久化到 .claude/workflow-runs/<runId>/script.js,返回可复用
  scriptPath(闭环 inline→编辑→scriptPath 重提迭代循环)
- HIGH#3 opt-in 分工: ultracode/WorkflowTool/effort 注明 session reminder 由
  harness 注入,repo 内无 ultracode 信号,保持 feature('WORKFLOW_SCRIPTS') +
  isEnabled 两层 gate,不自造注入
- 测试: 新增 persistInline.test.ts;扩展 claudeCodeBackend(isolation 4 用例)/
  WorkflowTool(inline)/service(scriptPath)/ultracode(harness)

含配套 workflow engine/panel 完善与 run-state-persistence design doc。

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(workflow): run 终态落盘 state.json 支持跨重启恢复

终态 RunProgress(含 returnValue/error)此前只在内存 ProgressStore,进程
重启即丢失。本次让其落盘到 .claude/workflow-runs/<runId>/state.json,使
(a) 重启后可按 runId 取 return、(b) /workflows 面板跨重启展示历史 run。
跨进程 resume 明确不在范围。

- persistence.ts: getRunsDir/writeRunState/readRunState/listPersistedRuns
  + attachRunStatePersistence;原子覆盖写(tmp+rename),读容错(缺文件/
  损坏/schemaVersion 不符 → null),写 best-effort(IO 失败只 log warn)
- progress/store.ts: 加 hydrate(run) 直接注入磁盘 run(已存在 runId 跳过,
  内存优先)
- service.ts: getWorkflowService() 接线 attachRunStatePersistence(bus,
  store) 订阅 run_done(completed/failed/killed 三态共用,shutdown-kill
  也走同路径,无需额外钩子);WorkflowService 加 getRunAsync(id) 内存
  miss→读盘 fallback(不注入内存)+ loadPersistedRuns() 扫盘 hydrate
  (persistedLoaded flag 守护幂等)
- panel/WorkflowsPanel.tsx: mount 时调一次 loadPersistedRuns(重 mount
  不重复)
- ports.ts: runsDir 改用 getRunsDir() 消除拼接重复
- 测试: persistence.test.ts(11)/runStatePersistence.test.ts(5)/
  progressStore(2)/service(5)/WorkflowsPanel(1) 共 24 个新测试;
  precheck 5629 pass / 0 fail

设计偏离: 计划原写 monkey-patch getRunsDir 指向 tmpdir,Bun ESM namespace
不可变不可行;改用可选 runsDirProvider 参数(默认 getRunsDir)DI 注入,
加到 attachRunStatePersistence 与 makeService(cwdOverride 之后第 4 参),
与现有 cwdOverride 模式一致。makeService 的 cwdOverride 保持不变,不破坏
inline 持久化特性。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): 默认并发降为 3 并支持 per-run maxConcurrency 注入

- DEFAULT_MAX_CONCURRENCY=3 替代旧的 min(16, cores-2);MAX_CONCURRENCY_CAP=16 保留为用户输入的绝对上限
- 新增 clampMaxConcurrency() 处理 undefined/<1/>CAP 边界
- WorkflowInput schema 新增 maxConcurrency: number.int().min(1).max(16).optional()
- 引擎层 context/runWorkflow 全链路透传:semaphore 容量来自 per-run 入参
- WorkflowTool prompt 增加指引:fan-out 场景先用 AskUserQuestion 与用户确认并发再启动
- 同步 ultracode skill + audit workflow spec 的并发文字(删 cpu-cores 公式)
- 同步 docs/features/workflow-scripts.md 旧公式

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): 面板 UI 字符串英文化

WorkflowsPanel 中 4 处面向用户的中文(onDone 错误消息、键位提示行)
改为英文;其他面板组件(AgentList/TabsBar)原本已是英文。代码注释
保留中文,与 workflow 模块惯例一致。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): 中断系统(x 杀单 agent / K 杀整个 workflow,Dialog 二次确认)

- claudeCodeBackend 桥接 ctx.signal → runAgent.override.abortController(修 'x' 无效根因:abort 到不了内部 fetch)
- AbortError 识别为 throw WorkflowAbortedError(不再吞成 dead,workflow 能感知被 kill)
- ports.taskRegistrar 加 registerAgentAbort/unregisterAgentAbort/killAgent;service.killAgent(runId, agentId) 精确中断
- 面板键位:'x' 杀当前 agent(agents 列聚焦时) / 'K' 杀整个 workflow;Dialog 二次确认 + confirm 模式吞导航键防误触
- 新增测试 8 项(backend signal bridge / hooks inject / ports killAgent / service killAgent)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(workflow): ultracode skill 加 model tier 选择指引(haiku/sonnet/opus/best 场景匹配)

补足 agent() 已有 model 参数缺的判断依据:列出 4 个 tier 的成本/延迟量级和典型场景,
明确"无法 articulate 为什么换 tier 就 omit"的 rule of thumb。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): maxConcurrency≠3 必须先 AskUserQuestion(默认 3 推荐值)

把 fan-out 时才问改成任何 maxConcurrency≠3 都必须问。
唯一例外:用户在当前会话已明确说过并发数("use 6" / "maxConcurrency 9")。
prompt (WorkflowTool.ts) + skill (ultracode.ts) + audit spec 三处同步。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): agent 失败自动重试一次(dead 或非 abort throw)

- hooks.agent 包装 invokeBackend:第一次 dead 或非 abort throw → 重试一次
- WorkflowAbortedError(kill)不重试——是用户意图
- registry.resolve 配置错(AdapterNotFoundError 等)在 try 外直接上抛,不走重试——
  配置问题重试无意义且掩盖 bug
- 重试仍失败:dead 保持 dead;throw 降级 dead(不击穿 workflow,
  与 parallel/pipeline null-on-error 契约一致)
- budget 不重复扣:dead 不 addOutputTokens,重试 ok 才扣一次
- 新增 7 项 hooks 层重试测试 + 1 项 service 层降级测试

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): 面板 label 截断保留 #数字 后缀(同 dim 多 finding 可区分)

audit workflow 用 verify:\${dim}#\${findingIdx} 命名 verify agent。
旧逻辑 slice(0, 18) 从右切把 #idx 全吃了——同 dimension 多 finding
肉眼无法区分。新逻辑:含 #数字 后缀时保留后缀,前缀截断 + … 省略号。

例:verify:correctness#0 → verify:correctn…#0
   verify:architecture#15 → verify:archite…#15

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(workflow): kill 整个 workflow 后立即回主 chat

run_done→store→notifications.ts 的通知路径已有,但 confirmYes 后面板继续
挂着挡住主 chat,用户看不到"已停止"反馈。kill 后调 onDone() 立即退出面板,
让主 chat 的 `Workflow "<name>" was stopped` 通知直接可见。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): agent dead 带 reason/detail + prompt 加压 StructuredOutput

12 agent audit workflow 8 个 dead,journal 只记 {kind:"dead"} 无信息,
事后无法区分 "agent 没产 StructuredOutput" vs "runAgent 抛错"。
证据指向主因:sonnet 长 tool chain 后忘记调 StructuredOutput,
extractStructuredOutput 返回 null 即降级 dead。

- types.ts: AgentRunResult.dead 加可选 reason/detail 字段
  (no-structured-output / runagent-threw / worktree-failed / unknown)
  兼容旧 journal(均 optional)。
- claudeCodeBackend.ts: 三处 dead 填 reason + detail;
  no-structured-output 把 finalized 文本前 200 字符做 detail,
  让日志/面板能立刻看到 agent 最后说了什么。
- claudeCodeBackend.ts: schema 模式 prompt 首尾各放一次
  StructuredOutput 强制要求,针对 sonnet 长 tool chain 后忘记收尾。
- hooks.ts: retry 日志带 reason;retry 仍 throw 时降级 dead 也填
  reason=runagent-threw + detail。
- types.test.ts: 加 reason JSON 往返 + 旧 journal 兼容测试。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): schema 模式弃用 StructuredOutput 工具契约,改鲁棒 JSON 文本解析

上一轮 70a2f76 把"agent 长 tool chain 后忘调 StructuredOutput"当作死因,
加 prompt 头尾双强制。但实测跑 5 个 review agent 4 个 dead,detail 全是
"StructuredOutput tool is not available as a deferred tool"——根因是
该工具从未注入 workflow sub-agent 的工具集(assembleToolPool 默认池不含,
只有 stop_hook 路径 execAgentHook.ts 显式 createStructuredOutputTool())。
prompt 反复要求调一个不可达的工具,agent 困扰、长篇辩解、最终没产 JSON。

- claudeCodeBackend.ts:
  - extractStructuredOutput 重写:括号栈扫描替代 indexOf/lastIndexOf,
    处理嵌套对象、字符串内的括号、转义符;新增 fenced code block
    优先路径(```json / ```),多 JSON 块取第一个 parse 成功的;
    只返回 plain object(拒 array/number/string/null)。不做语法修复
    (尾逗号/单引号/注释)——避免在字符串内误改(如 "http://" 被 // 注释正则吃)。
  - schema 模式 prompt 简化:删首尾双 STRUCTURED OUTPUT 强制(600+ token),
    改成指示 agent 在最后文本块 emit raw JSON;明确告知"StructuredOutput
    is not available in this environment",消除调用幻觉。
- hooks.ts: detail.slice 用 typeof === 'string' 守卫;catch 块用
  e instanceof Error ? e.message : String(e)(旧 journal / 第三方 adapter
  可能写非 string detail,直接 .slice 会抛 TypeError 击穿日志)。
- claudeCodeBackend.test.ts: +9 测试覆盖 fenced / 嵌套 / 字符串内括号 /
  转义引号 / 多块取首 / 类型守卫 / 损坏 JSON。

precheck: 5663 pass / 0 fail。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): 新增 /effort 交互面板设计 spec

设计要点:
- /effort 无参 → 横向 slider 面板(low/medium/high/xhigh/max/ultracode)
- ←/→ 移动光标,Enter 确认,Esc 取消
- ultracode 仅视觉占位,确认后提示走 /ultracode <context>
- env override 时双标记 + 顶部警告
- 模型不支持时面板禁用
- 两阶段交付:先基础面板 commit,再做 ultracode 波纹动画

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): 新增 EffortPanel 基础面板实施计划(第一阶段)

按 TDD 分 6 个 task:纯函数状态 → keybinding 注册 → 组件 → 命令挂载 → 分支测试 → precheck。
波纹动画在第二阶段单独 commit。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): plan 补 q/ctrl+c 取消绑定,对齐 spec §5 状态机

verifier 抓到的 gap:spec §5 写明 Esc / Ctrl+C / q 都是取消事件,
但 plan Task 2.3 只绑了 escape。补上 q 和 ctrl+c → effortPanel:cancel。
同时把 Step 2.2 直接写成 6 个 action 版本(home/end),删除迂回表达。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* docs(effort): plan 修订执行前 review 发现的 5 处 gap

- Task 3.3 EffortPanel.tsx 草稿:Faster/Smarter padEnd 语法错乱重写;
  useKeybindings import 路径从 @anthropic/ink 修正为 ../../keybindings/useKeybinding.js;
  移除冗余 renderSeparatorLine;保留 renderPaddedLine
- Task 5.2 computeConfirmOutcome 改为注入 ApplyFn 模式:
  避免 effortPanelState → effort.tsx → EffortPanel 循环依赖;
  测试可注入 mockApply,无需 mock settings
- Step 5.3 测试代码对齐注入版签名

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): 新增 EffortPanel 纯函数状态模块(PanelPosition + 移动/初始光标)

仅含纯函数与类型,无 React/Ink 依赖,便于单测。
- PANEL_POSITIONS:low → medium → high → xhigh → max → ultracode
- moveLeft/moveRight:边界钳制(low 不再左移、ultracode 不再右移)
- getInitialCursor:env override > displayed level

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(keybindings): 注册 EffortPanel context 与 6 个 action

绑定 ←/→/h/l/home/end/enter/escape/q/ctrl+c 到 effortPanel:* action。
与 ModelPicker context 范式一致,避免左右键被全局 keybinding 拦截。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): 实现 EffortPanel 组件主体(渲染 + 键盘交互 + 确认/取消分支)

- 横向 slider 布局:Faster ↔ Smarter 两极,6 档刻度
- useKeybindings 注册 EffortPanel context(←/→/h/l/home/end/enter/escape/q/ctrl+c)
- Enter 在 5 档之一 → 调 executeEffort 写 settings + AppState
- Enter 在 ultracode → 输出引导文案,不写状态
- Esc/q → "Effort unchanged."
- env override 时顶部黄色警告
- computeConfirmOutcome 注入 ApplyFn,便于测试(Task 5 补测试)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): /effort 无参时挂载 EffortPanel 交互面板

- 无参 → <EffortPanelWrapper> 透传 AppState.effortValue
- current/status → 仍显示文本(不变)
- 有参 → 直跳 executeEffort(不变)
- help/-h/--help → 不变

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* test(effort): 补 computeConfirmOutcome 分支测试(注入 mockApply)

- ultracode → kind=ultracode-hint,不调 applyFn
- low → kind=apply,message/effortUpdate 来自 applyFn
- applyFn 返回无 effortUpdate 时 outcome.effortUpdate 为 undefined
- CANCEL_MESSAGE / ULTRACODE_HINT 常量

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 测试里 cursor cast 为 EffortValue,避免 PanelPosition 含 ultracode 触发 TS 错误

computeConfirmOutcome 的 ApplyFn 契约要求 EffortValue,但测试 mockApply 接收 PanelPosition。
实际运行时 computeConfirmOutcome 在 ultracode 档位走 hint 分支不会调 applyFn,
cast 安全。precheck 全量通过:5688 tests / 0 fail。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 面板对齐与配色修复

- 对齐:用 Box width={SEGMENT} + justifyContent="center" 让 ▲ 与档位名严格居中对齐,
  替代之前 string padEnd(11) 与 SEGMENT=12 不一致导致的 1 列偏移
- 配色:所有面板文字改用 theme.claude(Claude Orange rgb(215,119,87)),
  替代终端默认紫;分隔线/副标签/底栏用 theme.subtle;env 警告用 theme.warning
- 光标档位的档位名也加粗,强化视觉焦点

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 面板文字改紫色,ULTRACODE_HINT 英文化

- 颜色:theme.claude(橙)→ theme.purple_FOR_SUBAGENTS_ONLY(Purple 600, rgb(147,51,234)),
  覆盖标题、Faster/Smarter、▲、档位名
- ULTRACODE_HINT:中文 → 英文
  "ultracode is not an effort level. Use /ultracode <context> to start a multi-agent workflow."

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 统一用色版——选中 suggestion(蓝),未选中 subtle(灰)

弃用 purple_FOR_SUBAGENTS_ONLY(subagent 专用)。改与项目其他面板一致:
- 选中档位 + ▲:color="suggestion"(Medium blue rgb(87,105,247))+ bold
- 未选中档位 + 空 ▲ 占位:color="subtle"(Light gray rgb(175,175,175))
- 标题 / Faster / Smarter:color="suggestion"
- 分隔线 / 副标签 / 底栏:color="subtle"

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(workflow): 终态前补发 phase_done,面板自动退出 running→terminal 转换

runWorkflow:脚本结束时 hook.phase 不会触发最后一个 phase 的 phase_done,
UI 左栏会永远显示 running。三路径(completed/killed/failed)统一在 run_done
之前补发 emitTerminalPhaseDone。

WorkflowsPanel:抽 isRunTerminatedTransition 纯函数判定 running → terminal,
面板 useEffect 检测到转换后自动退出聚焦。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): 波纹动画纯函数 pickChar/computeRippleLine/mergeLayers + 18 测试

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): useRippleFrame hook 包装 useAnimationFrame,按需订阅时钟

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): EffortPanel 集成波纹背景——cursor 停在 ultracode 时切换波纹模式

仅在 cursor === 'ultracode' 时启用 useRippleFrame,渲染 5 行波纹背景
+ overlay 文字(Faster/Smarter、分隔线、▲、档位名、副标签)。
其余档位保持原 PlainContent 渲染路径不动。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* refactor(effort): 波纹动画从字符密度改为颜色渐变

按原版风格把波纹背景从 INTENSITY_CHARS 密度字符('·∙░▒▓')改为
suggestion 系颜色渐变(transparent → 暗深紫蓝 → suggestion → 高光):

rippleAnimation.ts:
- 删除 pickChar / INTENSITY_CHARS / WAVE_PEAK_CHARS / mergeLayers
- 新增 intensityToColor(intensity) → 'transparent' | '#xxxxxx'
- 新增 computeRippleCells 返回 Cell[](每位置 char+color)
- 新增 applyOverlaysToCells(cells, overlays) 替代 mergeLayers
- 新增 cellsToSegments(cells) 合并相邻同色段(减少 Text 节点)

EffortPanel.tsx:
- RippleContent 用 cells→segments→tokens 渲染
- 空格段用 BaseText backgroundColor 染色块(纯色块视觉)
- 文字段用 Text color 染色(亮色突出)
- tokens 按空格/文字二次拆分,避免混合段渲染歧义

测试: 29 个 rippleAnimation 测试覆盖 intensityToColor 边界、
computeRippleCells 长度/震源/衰减、applyOverlaysToCells 覆盖/截断/
防御式拷贝、cellsToSegments 合并逻辑。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 波纹参数调优——铺满左侧 + 速度调慢 + 全面板有底色

用户反馈三个问题:
1. "低峰部分没有颜色变化" → intensity ≤ 0.1 返回 transparent 导致波谷
   位置看不见。改为永不返回 transparent,最低档 #0a0d1a 作为面板
   底色(暗紫黑海洋),波峰在底色上流动。
2. "波浪速度太快" → time 系数 0.012 → 0.004(约 1/3 速)。波峰移动
   速度从 34 cell/s 降到 11 cell/s,每帧颜色变化从 45% 降到 36%。
3. "波浪只到中间部分,没覆盖左侧" → falloff 覆盖半径 40 → 90。
   震源 x=65,左侧 dist=65 < 90,波纹可达最左端(约 30-50% 覆盖)。

色阶调整:
- 删除 transparent 档,新增 #0a0d1a 作最暗档(底色)
- 最高档从 #8aa0ff(高光)改为 #5769F7(suggestion),避免与
  文字 overlay 同色互相吞噬
- 7 档颜色:#0a0d1a → #15182b → #1f2543 → #2a3360 →
  #3a4582 → #4a5bb0 → #5769F7

测试:删除 transparent 期望,改为期望具体颜色(#0a0d1a 等)。
新增"覆盖半径扩大"测试验证 dist=65 仍有非最暗颜色。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* fix(effort): 波纹 v3 — 去黑边 + 删中心高频涟漪 + y 轴覆盖快捷键行

用户反馈三个问题:
1. "黑色边感觉不太对" — 最暗档 #0a0d1a (rgb 10,13,26) 太接近纯黑,
   远端波谷看起来像硬黑边。改为 #1a1f3a (rgb 26,31,58),紫蓝感
   更强而非纯黑。
2. "中心的快速波纹有点奇怪" — 删除震源附近 dist<6 的高频涟漪叠加
   (time*0.02,5 倍主波纹频率)。原本想让震源附近"水波感"更强,
   实际效果像"快速闪烁"反而突兀。主波纹已经足够,无需叠加。
3. "y 方向覆盖快捷键" — RippleContent 新增 y=2 行渲染快捷键 overlay
   ("←/→ adjust · Enter confirm · Esc cancel")。PlainContent 路径
   保持原 Box marginTop=1 + Text 渲染。

色阶调整(紫蓝感更强):
- #1a1f3a (原 #0a0d1a) — 最暗档
- #1f2543 / #252c55 / #2e3870 / #3a4582 / #4a5bb0 / #5769F7
  (中间档略调亮度,保持平滑过渡)

测试:震源点测试更新为"time=0 时波谷最暗,time 推进后扫过波峰变亮",
反映删除高频涟漪后的纯主波纹行为。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* chore(workflow): 工作流相关代码中文文案全部英文化

源码(src/workflow/ + packages/workflow-engine/src/)的中文注释、
用户可见错误消息、字符串字面量;测试文件的标题与注释;同步 6 条
硬编码断言到英文化后的错误消息。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* feat(effort): 波纹 v4 — 平滑波 + 全色环旋转 + 淡入淡出 + 宽度自适应

- 波函数改 (sin+1)/2:消除 max(0,sin) 平直暗带(约 6 行宽)
- 主色相连续旋转(0.03°/ms,12s/圈全色环):蓝→紫→品红→红→橙→黄→绿→青
- 文字 overlay 同步色相旋转(rotateHue 应用到 Faster/▲/档位名/分隔线/副标签)
- 淡入淡出动画:fadeColor/fadeCells + fade 状态机 ~300ms 进出过渡
- 副标签固定 ultracode 段下方,不跟随光标移动
- 顶部/底部各加一行纯波纹行,视觉一致
- 宽度自适应终端列数:窄则 72,宽则铺满(computeSegment/computeRippleSourceX)
- 快捷键改 plain Text,不参与波纹背景渲染
- 新增 18 测试(fadeColor/fadeCells/rotateHue/getHueShiftAtTime)

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>

* refactor: remove CYBER_RISK_MITIGATION_REMINDER from FileReadTool

Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>

* fix: prevent ReDoS in extractMeta regex by anchoring to splice boundary

Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>

* chore: 更新脚本

---------

Co-authored-by: glm-5.1 <zai-org@claude-code-best.win>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
2026-06-14 18:13:49 +08:00

1519 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { feature } from 'bun:bundle'
import chalk from 'chalk'
import { spawnSync } from 'child_process'
import {
copyFile,
mkdir,
readdir,
readFile,
stat,
symlink,
utimes,
} from 'fs/promises'
import ignore from 'ignore'
import { basename, dirname, join } from 'path'
import { saveCurrentProjectConfig } from './config.js'
import { getCwd } from './cwd.js'
import { logForDebugging } from './debug.js'
import { errorMessage, getErrnoCode } from './errors.js'
import { execFileNoThrow, execFileNoThrowWithCwd } from './execFileNoThrow.js'
import { parseGitConfigValue } from './git/gitConfigParser.js'
import {
getCommonDir,
readWorktreeHeadSha,
resolveGitDir,
resolveRef,
} from './git/gitFilesystem.js'
import {
findCanonicalGitRoot,
findGitRoot,
getBranch,
getDefaultBranch,
gitExe,
} from './git.js'
import {
executeWorktreeCreateHook,
executeWorktreeRemoveHook,
hasWorktreeCreateHook,
} from './hooks.js'
import { containsPathTraversal } from './path.js'
import { getPlatform } from './platform.js'
import {
getInitialSettings,
getRelativeSettingsFilePathForSource,
} from './settings/settings.js'
import { sleep } from './sleep.js'
import { isInITerm2 } from './swarm/backends/detection.js'
const VALID_WORKTREE_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/
const MAX_WORKTREE_SLUG_LENGTH = 64
/**
* Validates a worktree slug to prevent path traversal and directory escape.
*
* The slug is joined into `.claude/worktrees/<slug>` via path.join, which
* normalizes `..` segments — so `../../../target` would escape the worktrees
* directory. Similarly, an absolute path (leading `/` or `C:\`) would discard
* the prefix entirely.
*
* Forward slashes are allowed for nesting (e.g. `asm/feature-foo`); each
* segment is validated independently against the allowlist, so `.` / `..`
* segments and drive-spec characters are still rejected.
*
* Throws synchronously — callers rely on this running before any side effects
* (git commands, hook execution, chdir).
*/
export function validateWorktreeSlug(slug: string): void {
if (slug.length > MAX_WORKTREE_SLUG_LENGTH) {
throw new Error(
`Invalid worktree name: must be ${MAX_WORKTREE_SLUG_LENGTH} characters or fewer (got ${slug.length})`,
)
}
// Leading or trailing `/` would make path.join produce an absolute path
// or a dangling segment. Splitting and validating each segment rejects
// both (empty segments fail the regex) while allowing `user/feature`.
for (const segment of slug.split('/')) {
if (segment === '.' || segment === '..') {
throw new Error(
`Invalid worktree name "${slug}": must not contain "." or ".." path segments`,
)
}
if (!VALID_WORKTREE_SLUG_SEGMENT.test(segment)) {
throw new Error(
`Invalid worktree name "${slug}": each "/"-separated segment must be non-empty and contain only letters, digits, dots, underscores, and dashes`,
)
}
}
}
// Helper function to create directories recursively
async function mkdirRecursive(dirPath: string): Promise<void> {
await mkdir(dirPath, { recursive: true })
}
/**
* Symlinks directories from the main repository to avoid duplication.
* This prevents disk bloat from duplicating node_modules and other large directories.
*
* @param repoRootPath - Path to the main repository root
* @param worktreePath - Path to the worktree directory
* @param dirsToSymlink - Array of directory names to symlink (e.g., ['node_modules'])
*/
async function symlinkDirectories(
repoRootPath: string,
worktreePath: string,
dirsToSymlink: string[],
): Promise<void> {
for (const dir of dirsToSymlink) {
// Validate directory doesn't escape repository boundaries
if (containsPathTraversal(dir)) {
logForDebugging(
`Skipping symlink for "${dir}": path traversal detected`,
{ level: 'warn' },
)
continue
}
const sourcePath = join(repoRootPath, dir)
const destPath = join(worktreePath, dir)
try {
await symlink(sourcePath, destPath, 'dir')
logForDebugging(
`Symlinked ${dir} from main repository to worktree to avoid disk bloat`,
)
} catch (error) {
const code = getErrnoCode(error)
// ENOENT: source doesn't exist yet (expected - skip silently)
// EEXIST: destination already exists (expected - skip silently)
if (code !== 'ENOENT' && code !== 'EEXIST') {
// Unexpected error (e.g., permission denied, unsupported platform)
logForDebugging(
`Failed to symlink ${dir} (${code ?? 'unknown'}): ${errorMessage(error)}`,
{ level: 'warn' },
)
}
}
}
}
export type WorktreeSession = {
originalCwd: string
worktreePath: string
worktreeName: string
worktreeBranch?: string
originalBranch?: string
originalHeadCommit?: string
sessionId: string
tmuxSessionName?: string
hookBased?: boolean
/** How long worktree creation took (unset when resuming an existing worktree). */
creationDurationMs?: number
/** True if git sparse-checkout was applied via settings.worktree.sparsePaths. */
usedSparsePaths?: boolean
}
let currentWorktreeSession: WorktreeSession | null = null
export function getCurrentWorktreeSession(): WorktreeSession | null {
return currentWorktreeSession
}
/**
* Restore the worktree session on --resume. The caller must have already
* verified the directory exists (via process.chdir) and set the bootstrap
* state (cwd, originalCwd).
*/
export function restoreWorktreeSession(session: WorktreeSession | null): void {
currentWorktreeSession = session
}
export function generateTmuxSessionName(
repoPath: string,
branch: string,
): string {
const repoName = basename(repoPath)
const combined = `${repoName}_${branch}`
return combined.replace(/[/.]/g, '_')
}
type WorktreeCreateResult =
| {
worktreePath: string
worktreeBranch: string
headCommit: string
existed: true
}
| {
worktreePath: string
worktreeBranch: string
headCommit: string
baseBranch: string
existed: false
}
// Env vars to prevent git/SSH from prompting for credentials (which hangs the CLI).
// GIT_TERMINAL_PROMPT=0 prevents git from opening /dev/tty for credential prompts.
// GIT_ASKPASS='' disables askpass GUI programs.
// stdin: 'ignore' closes stdin so interactive prompts can't block.
const GIT_NO_PROMPT_ENV = {
GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: '',
}
function worktreesDir(repoRoot: string): string {
return join(repoRoot, '.claude', 'worktrees')
}
// Flatten nested slugs (`user/feature` → `user+feature`) for both the branch
// name and the directory path. Nesting in either location is unsafe:
// - git refs: `worktree-user` (file) vs `worktree-user/feature` (needs dir)
// is a D/F conflict that git rejects.
// - directory: `.claude/worktrees/user/feature/` lives inside the `user`
// worktree; `git worktree remove` on the parent deletes children with
// uncommitted work.
// `+` is valid in git branch names and filesystem paths but NOT in the
// slug-segment allowlist ([a-zA-Z0-9._-]), so the mapping is injective.
function flattenSlug(slug: string): string {
return slug.replaceAll('/', '+')
}
export function worktreeBranchName(slug: string): string {
return `worktree-${flattenSlug(slug)}`
}
function worktreePathFor(repoRoot: string, slug: string): string {
return join(worktreesDir(repoRoot), flattenSlug(slug))
}
/**
* Creates a new git worktree for the given slug, or resumes it if it already exists.
* Named worktrees reuse the same path across invocations, so the existence check
* prevents unconditionally running `git fetch` (which can hang waiting for credentials)
* on every resume.
*/
async function getOrCreateWorktree(
repoRoot: string,
slug: string,
options?: { prNumber?: number },
): Promise<WorktreeCreateResult> {
const worktreePath = worktreePathFor(repoRoot, slug)
const worktreeBranch = worktreeBranchName(slug)
// Fast resume path: if the worktree already exists skip fetch and creation.
// Read the .git pointer file directly (no subprocess, no upward walk) — a
// subprocess `rev-parse HEAD` burns ~15ms on spawn overhead even for a 2ms
// task, and the await yield lets background spawnSyncs pile on (seen at 55ms).
const existingHead = await readWorktreeHeadSha(worktreePath)
if (existingHead) {
return {
worktreePath,
worktreeBranch,
headCommit: existingHead,
existed: true,
}
}
// New worktree: fetch base branch then add
await mkdir(worktreesDir(repoRoot), { recursive: true })
const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV }
let baseBranch: string
let baseSha: string | null = null
if (options?.prNumber) {
const { code: prFetchCode, stderr: prFetchStderr } =
await execFileNoThrowWithCwd(
gitExe(),
['fetch', 'origin', `pull/${options.prNumber}/head`],
{ cwd: repoRoot, stdin: 'ignore', env: fetchEnv },
)
if (prFetchCode !== 0) {
throw new Error(
`Failed to fetch PR #${options.prNumber}: ${prFetchStderr.trim() || 'PR may not exist or the repository may not have a remote named "origin"'}`,
)
}
baseBranch = 'FETCH_HEAD'
} else {
// If origin/<branch> already exists locally, skip fetch. In large repos
// (210k files, 16M objects) fetch burns ~6-8s on a local commit-graph
// scan before even hitting the network. A slightly stale base is fine —
// the user can pull in the worktree if they want latest.
// resolveRef reads the loose/packed ref directly; when it succeeds we
// already have the SHA, so the later rev-parse is skipped entirely.
const [defaultBranch, gitDir] = await Promise.all([
getDefaultBranch(),
resolveGitDir(repoRoot),
])
const originRef = `origin/${defaultBranch}`
const originSha = gitDir
? await resolveRef(gitDir, `refs/remotes/origin/${defaultBranch}`)
: null
if (originSha) {
baseBranch = originRef
baseSha = originSha
} else {
const { code: fetchCode } = await execFileNoThrowWithCwd(
gitExe(),
['fetch', 'origin', defaultBranch],
{ cwd: repoRoot, stdin: 'ignore', env: fetchEnv },
)
baseBranch = fetchCode === 0 ? originRef : 'HEAD'
}
}
// For the fetch/PR-fetch paths we still need the SHA — the fs-only resolveRef
// above only covers the "origin/<branch> already exists locally" case.
if (!baseSha) {
const { stdout, code: shaCode } = await execFileNoThrowWithCwd(
gitExe(),
['rev-parse', baseBranch],
{ cwd: repoRoot },
)
if (shaCode !== 0) {
throw new Error(
`Failed to resolve base branch "${baseBranch}": git rev-parse failed`,
)
}
baseSha = stdout.trim()
}
const sparsePaths = getInitialSettings().worktree?.sparsePaths
const addArgs = ['worktree', 'add']
if (sparsePaths?.length) {
addArgs.push('--no-checkout')
}
// -B (not -b): reset any orphan branch left behind by a removed worktree dir.
// Saves a `git branch -D` subprocess (~15ms spawn overhead) on every create.
addArgs.push('-B', worktreeBranch, worktreePath, baseBranch)
const { code: createCode, stderr: createStderr } =
await execFileNoThrowWithCwd(gitExe(), addArgs, { cwd: repoRoot })
if (createCode !== 0) {
throw new Error(`Failed to create worktree: ${createStderr}`)
}
if (sparsePaths?.length) {
// If sparse-checkout or checkout fail after --no-checkout, the worktree
// is registered and HEAD is set but the working tree is empty. Next run's
// fast-resume (rev-parse HEAD) would succeed and present a broken worktree
// as "resumed". Tear it down before propagating the error.
const tearDown = async (msg: string): Promise<never> => {
await execFileNoThrowWithCwd(
gitExe(),
['worktree', 'remove', '--force', worktreePath],
{ cwd: repoRoot },
)
throw new Error(msg)
}
const { code: sparseCode, stderr: sparseErr } =
await execFileNoThrowWithCwd(
gitExe(),
['sparse-checkout', 'set', '--cone', '--', ...sparsePaths],
{ cwd: worktreePath },
)
if (sparseCode !== 0) {
await tearDown(`Failed to configure sparse-checkout: ${sparseErr}`)
}
const { code: coCode, stderr: coErr } = await execFileNoThrowWithCwd(
gitExe(),
['checkout', 'HEAD'],
{ cwd: worktreePath },
)
if (coCode !== 0) {
await tearDown(`Failed to checkout sparse worktree: ${coErr}`)
}
}
return {
worktreePath,
worktreeBranch,
headCommit: baseSha,
baseBranch,
existed: false,
}
}
/**
* Copy gitignored files specified in .worktreeinclude from base repo to worktree.
*
* Only copies files that are BOTH:
* 1. Matched by patterns in .worktreeinclude (uses .gitignore syntax)
* 2. Gitignored (not tracked by git)
*
* Uses `git ls-files --others --ignored --exclude-standard --directory` to list
* gitignored entries with fully-ignored dirs collapsed to single entries (so large
* build outputs like node_modules/ don't force a full tree walk), then filters
* against .worktreeinclude patterns in-process using the `ignore` library. If a
* .worktreeinclude pattern explicitly targets a path inside a collapsed directory,
* that directory is expanded with a second scoped `ls-files` call.
*/
export async function copyWorktreeIncludeFiles(
repoRoot: string,
worktreePath: string,
): Promise<string[]> {
let includeContent: string
try {
includeContent = await readFile(join(repoRoot, '.worktreeinclude'), 'utf-8')
} catch {
return []
}
const patterns = includeContent
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line.length > 0 && !line.startsWith('#'))
if (patterns.length === 0) {
return []
}
// Single pass with --directory: collapses fully-gitignored dirs (node_modules/,
// .turbo/, etc.) into single entries instead of listing every file inside.
// In a large repo this cuts ~500k entries/~7s down to ~hundreds of entries/~100ms.
const gitignored = await execFileNoThrowWithCwd(
gitExe(),
['ls-files', '--others', '--ignored', '--exclude-standard', '--directory'],
{ cwd: repoRoot },
)
if (gitignored.code !== 0 || !gitignored.stdout.trim()) {
return []
}
const entries = gitignored.stdout.trim().split('\n').filter(Boolean)
const matcher = ignore().add(includeContent)
// --directory emits collapsed dirs with a trailing slash; everything else is
// an individual file.
const collapsedDirs = entries.filter(e => e.endsWith('/'))
const files = entries.filter(e => !e.endsWith('/') && matcher.ignores(e))
// Edge case: a .worktreeinclude pattern targets a path inside a collapsed dir
// (e.g. pattern `config/secrets/api.key` when all of `config/secrets/` is
// gitignored with no tracked siblings). Expand only dirs where a pattern has
// that dir as its explicit path prefix (stripping redundant leading `/`), the
// dir falls under an anchored glob's literal prefix (e.g. `config/**/*.key`
// expands `config/secrets/`), or the dir itself matches a pattern. We don't
// expand for `**/` or anchorless patterns -- those match files in tracked dirs
// (already listed individually) and expanding every collapsed dir for them
// would defeat the perf win.
const dirsToExpand = collapsedDirs.filter(dir => {
if (
patterns.some(p => {
const normalized = p.startsWith('/') ? p.slice(1) : p
// Literal prefix match: pattern starts with the collapsed dir path
if (normalized.startsWith(dir)) return true
// Anchored glob: dir falls under the pattern's literal (non-glob) prefix
// e.g. `config/**/*.key` has literal prefix `config/` → expand `config/secrets/`
const globIdx = normalized.search(/[*?[]/)
if (globIdx > 0) {
const literalPrefix = normalized.slice(0, globIdx)
if (dir.startsWith(literalPrefix)) return true
}
return false
})
)
return true
if (matcher.ignores(dir.slice(0, -1))) return true
return false
})
if (dirsToExpand.length > 0) {
const expanded = await execFileNoThrowWithCwd(
gitExe(),
[
'ls-files',
'--others',
'--ignored',
'--exclude-standard',
'--',
...dirsToExpand,
],
{ cwd: repoRoot },
)
if (expanded.code === 0 && expanded.stdout.trim()) {
for (const f of expanded.stdout.trim().split('\n').filter(Boolean)) {
if (matcher.ignores(f)) {
files.push(f)
}
}
}
}
const copied: string[] = []
for (const relativePath of files) {
const srcPath = join(repoRoot, relativePath)
const destPath = join(worktreePath, relativePath)
try {
await mkdir(dirname(destPath), { recursive: true })
await copyFile(srcPath, destPath)
copied.push(relativePath)
} catch (e: unknown) {
logForDebugging(
`Failed to copy ${relativePath} to worktree: ${(e as Error).message}`,
{ level: 'warn' },
)
}
}
if (copied.length > 0) {
logForDebugging(
`Copied ${copied.length} files from .worktreeinclude: ${copied.join(', ')}`,
)
}
return copied
}
/**
* Post-creation setup for a newly created worktree.
* Propagates settings.local.json, configures git hooks, and symlinks directories.
*/
async function performPostCreationSetup(
repoRoot: string,
worktreePath: string,
): Promise<void> {
// Copy settings.local.json to the worktree's .claude directory
// This propagates local settings (which may contain secrets) to the worktree
const localSettingsRelativePath =
getRelativeSettingsFilePathForSource('localSettings')
const sourceSettingsLocal = join(repoRoot, localSettingsRelativePath)
try {
const destSettingsLocal = join(worktreePath, localSettingsRelativePath)
await mkdirRecursive(dirname(destSettingsLocal))
await copyFile(sourceSettingsLocal, destSettingsLocal)
logForDebugging(
`Copied settings.local.json to worktree: ${destSettingsLocal}`,
)
} catch (e: unknown) {
const code = getErrnoCode(e)
if (code !== 'ENOENT') {
logForDebugging(
`Failed to copy settings.local.json: ${(e as Error).message}`,
{ level: 'warn' },
)
}
}
// Configure the worktree to use hooks from the main repository
// This solves issues with .husky and other git hooks that use relative paths
const huskyPath = join(repoRoot, '.husky')
const gitHooksPath = join(repoRoot, '.git', 'hooks')
let hooksPath: string | null = null
for (const candidatePath of [huskyPath, gitHooksPath]) {
try {
const s = await stat(candidatePath)
if (s.isDirectory()) {
hooksPath = candidatePath
break
}
} catch {
// Path doesn't exist or can't be accessed
}
}
if (hooksPath) {
// `git config` (no --worktree flag) writes to the main repo's .git/config,
// shared by all worktrees. Once set, every subsequent worktree create is a
// no-op — skip the subprocess (~14ms spawn) when the value already matches.
const gitDir = await resolveGitDir(repoRoot)
const configDir = gitDir ? ((await getCommonDir(gitDir)) ?? gitDir) : null
const existing = configDir
? await parseGitConfigValue(configDir, 'core', null, 'hooksPath')
: null
if (existing !== hooksPath) {
const { code: configCode, stderr: configError } =
await execFileNoThrowWithCwd(
gitExe(),
['config', 'core.hooksPath', hooksPath],
{ cwd: worktreePath },
)
if (configCode === 0) {
logForDebugging(
`Configured worktree to use hooks from main repository: ${hooksPath}`,
)
} else {
logForDebugging(`Failed to configure hooks path: ${configError}`, {
level: 'error',
})
}
}
}
// Symlink directories to avoid disk bloat (opt-in via settings)
const settings = getInitialSettings()
const dirsToSymlink = settings.worktree?.symlinkDirectories ?? []
if (dirsToSymlink.length > 0) {
await symlinkDirectories(repoRoot, worktreePath, dirsToSymlink)
}
// Copy gitignored files specified in .worktreeinclude (best-effort)
await copyWorktreeIncludeFiles(repoRoot, worktreePath)
// The core.hooksPath config-set above is fragile: husky's prepare script
// (`git config core.hooksPath .husky`) runs on every `bun install` and
// resets the SHARED .git/config value back to relative, causing each
// worktree to resolve to its OWN .husky/ again. The attribution hook
// file isn't tracked (it's in .git/info/exclude), so fresh worktrees
// don't have it. Install it directly into the worktree's .husky/ —
// husky won't delete it (husky install is additive-only), and for
// non-husky repos this resolves to the shared .git/hooks/ (idempotent).
//
// Pass the worktree-local .husky explicitly: getHooksDir would return
// the absolute core.hooksPath we just set above (main repo's .husky),
// not the worktree's — `git rev-parse --git-path hooks` echoes the config
// value verbatim when it's absolute.
if (feature('COMMIT_ATTRIBUTION')) {
const worktreeHooksDir =
hooksPath === huskyPath ? join(worktreePath, '.husky') : undefined
void import('./postCommitAttribution.js')
.then(m =>
m
.installPrepareCommitMsgHook(worktreePath, worktreeHooksDir)
.catch(error => {
logForDebugging(
`Failed to install attribution hook in worktree: ${error}`,
)
}),
)
.catch(error => {
// Dynamic import() itself rejected (module load failure). The inner
// .catch above only handles installPrepareCommitMsgHook rejection —
// without this outer handler an import failure would surface as an
// unhandled promise rejection.
logForDebugging(`Failed to load postCommitAttribution module: ${error}`)
})
}
}
/**
* Parses a PR reference from a string.
* Accepts GitHub-style PR URLs (e.g., https://github.com/owner/repo/pull/123,
* or GHE equivalents like https://ghe.example.com/owner/repo/pull/123)
* or `#N` format (e.g., #123).
* Returns the PR number or null if the string is not a recognized PR reference.
*/
export function parsePRReference(input: string): number | null {
// GitHub-style PR URL: https://<host>/owner/repo/pull/123 (with optional trailing slash, query, hash)
// The /pull/N path shape is specific to GitHub — GitLab uses /-/merge_requests/N,
// Bitbucket uses /pull-requests/N — so matching any host here is safe.
const urlMatch = input.match(
/^https?:\/\/[^/]+\/[^/]+\/[^/]+\/pull\/(\d+)\/?(?:[?#].*)?$/i,
)
if (urlMatch?.[1]) {
return parseInt(urlMatch[1], 10)
}
// #N format
const hashMatch = input.match(/^#(\d+)$/)
if (hashMatch?.[1]) {
return parseInt(hashMatch[1], 10)
}
return null
}
export async function isTmuxAvailable(): Promise<boolean> {
const { code } = await execFileNoThrow('tmux', ['-V'])
return code === 0
}
export function getTmuxInstallInstructions(): string {
const platform = getPlatform()
switch (platform) {
case 'macos':
return 'Install tmux with: brew install tmux'
case 'linux':
case 'wsl':
return 'Install tmux with: sudo apt install tmux (Debian/Ubuntu) or sudo dnf install tmux (Fedora/RHEL)'
case 'windows':
return 'tmux is not natively available on Windows. Consider using WSL or Cygwin.'
default:
return 'Install tmux using your system package manager.'
}
}
export async function createTmuxSessionForWorktree(
sessionName: string,
worktreePath: string,
): Promise<{ created: boolean; error?: string }> {
const { code, stderr } = await execFileNoThrow('tmux', [
'new-session',
'-d',
'-s',
sessionName,
'-c',
worktreePath,
])
if (code !== 0) {
return { created: false, error: stderr }
}
return { created: true }
}
export async function killTmuxSession(sessionName: string): Promise<boolean> {
const { code } = await execFileNoThrow('tmux', [
'kill-session',
'-t',
sessionName,
])
return code === 0
}
export async function createWorktreeForSession(
sessionId: string,
slug: string,
tmuxSessionName?: string,
options?: { prNumber?: number },
): Promise<WorktreeSession> {
// Must run before the hook branch below — hooks receive the raw slug as an
// argument, and the git branch builds a path from it via path.join.
validateWorktreeSlug(slug)
const originalCwd = getCwd()
// Try hook-based worktree creation first (allows user-configured VCS)
if (hasWorktreeCreateHook()) {
const hookResult = await executeWorktreeCreateHook(slug)
logForDebugging(
`Created hook-based worktree at: ${hookResult.worktreePath}`,
)
currentWorktreeSession = {
originalCwd,
worktreePath: hookResult.worktreePath,
worktreeName: slug,
sessionId,
tmuxSessionName,
hookBased: true,
}
} else {
// Fall back to git worktree
const gitRoot = findGitRoot(getCwd())
if (!gitRoot) {
throw new Error(
'Cannot create a worktree: not in a git repository and no WorktreeCreate hooks are configured. ' +
'Configure WorktreeCreate/WorktreeRemove hooks in settings.json to use worktree isolation with other VCS systems.',
)
}
const originalBranch = await getBranch()
const createStart = Date.now()
const { worktreePath, worktreeBranch, headCommit, existed } =
await getOrCreateWorktree(gitRoot, slug, options)
let creationDurationMs: number | undefined
if (existed) {
logForDebugging(`Resuming existing worktree at: ${worktreePath}`)
} else {
logForDebugging(
`Created worktree at: ${worktreePath} on branch: ${worktreeBranch}`,
)
await performPostCreationSetup(gitRoot, worktreePath)
creationDurationMs = Date.now() - createStart
}
currentWorktreeSession = {
originalCwd,
worktreePath,
worktreeName: slug,
worktreeBranch,
originalBranch,
originalHeadCommit: headCommit,
sessionId,
tmuxSessionName,
creationDurationMs,
usedSparsePaths:
(getInitialSettings().worktree?.sparsePaths?.length ?? 0) > 0,
}
}
// Save to project config for persistence
saveCurrentProjectConfig(current => ({
...current,
activeWorktreeSession: currentWorktreeSession ?? undefined,
}))
return currentWorktreeSession
}
export async function keepWorktree(): Promise<void> {
if (!currentWorktreeSession) {
return
}
try {
const { worktreePath, originalCwd, worktreeBranch } = currentWorktreeSession
// Change back to original directory first
process.chdir(originalCwd)
// Clear the session but keep the worktree intact
currentWorktreeSession = null
// Update config
saveCurrentProjectConfig(current => ({
...current,
activeWorktreeSession: undefined,
}))
logForDebugging(
`Linked worktree preserved at: ${worktreePath}${worktreeBranch ? ` on branch: ${worktreeBranch}` : ''}`,
)
logForDebugging(
`You can continue working there by running: cd ${worktreePath}`,
)
} catch (error) {
logForDebugging(`Error keeping worktree: ${error}`, {
level: 'error',
})
}
}
export async function cleanupWorktree(): Promise<void> {
if (!currentWorktreeSession) {
return
}
try {
const { worktreePath, originalCwd, worktreeBranch, hookBased } =
currentWorktreeSession
// Change back to original directory first
process.chdir(originalCwd)
if (hookBased) {
// Hook-based worktree: delegate cleanup to WorktreeRemove hook
const hookRan = await executeWorktreeRemoveHook(worktreePath)
if (hookRan) {
logForDebugging(`Removed hook-based worktree at: ${worktreePath}`)
} else {
logForDebugging(
`No WorktreeRemove hook configured, hook-based worktree left at: ${worktreePath}`,
{ level: 'warn' },
)
}
} else {
// Git-based worktree: use git worktree remove.
// Explicit cwd: process.chdir above does NOT update getCwd() (the state
// CWD that execFileNoThrow defaults to). If the model cd'd to a non-repo
// dir, the bare execFileNoThrow variant would fail silently here.
const { code: removeCode, stderr: removeError } =
await execFileNoThrowWithCwd(
gitExe(),
['worktree', 'remove', '--force', worktreePath],
{ cwd: originalCwd },
)
if (removeCode !== 0) {
logForDebugging(`Failed to remove linked worktree: ${removeError}`, {
level: 'error',
})
} else {
logForDebugging(`Removed linked worktree at: ${worktreePath}`)
}
}
// Clear the session
currentWorktreeSession = null
// Update config
saveCurrentProjectConfig(current => ({
...current,
activeWorktreeSession: undefined,
}))
// Delete the temporary worktree branch (git-based only)
if (!hookBased && worktreeBranch) {
// Wait a bit to ensure git has released all locks
await sleep(100)
const { code: deleteBranchCode, stderr: deleteBranchError } =
await execFileNoThrowWithCwd(
gitExe(),
['branch', '-D', worktreeBranch],
{ cwd: originalCwd },
)
if (deleteBranchCode !== 0) {
logForDebugging(
`Could not delete worktree branch: ${deleteBranchError}`,
{ level: 'error' },
)
} else {
logForDebugging(`Deleted worktree branch: ${worktreeBranch}`)
}
}
logForDebugging('Linked worktree cleaned up completely')
} catch (error) {
logForDebugging(`Error cleaning up worktree: ${error}`, {
level: 'error',
})
}
}
/**
* Create a lightweight worktree for a subagent.
* Reuses getOrCreateWorktree/performPostCreationSetup but does NOT touch
* global session state (currentWorktreeSession, process.chdir, project config).
* Falls back to hook-based creation if not in a git repository.
*/
export async function createAgentWorktree(slug: string): Promise<{
worktreePath: string
worktreeBranch?: string
headCommit?: string
gitRoot?: string
hookBased?: boolean
}> {
validateWorktreeSlug(slug)
// Try hook-based worktree creation first (allows user-configured VCS)
if (hasWorktreeCreateHook()) {
const hookResult = await executeWorktreeCreateHook(slug)
logForDebugging(
`Created hook-based agent worktree at: ${hookResult.worktreePath}`,
)
return { worktreePath: hookResult.worktreePath, hookBased: true }
}
// Fall back to git worktree
// findCanonicalGitRoot (not findGitRoot) so agent worktrees always land in
// the main repo's .claude/worktrees/ even when spawned from inside a session
// worktree — otherwise they nest at <worktree>/.claude/worktrees/ and the
// periodic cleanup (which scans the canonical root) never finds them.
const gitRoot = findCanonicalGitRoot(getCwd())
if (!gitRoot) {
throw new Error(
'Cannot create agent worktree: not in a git repository and no WorktreeCreate hooks are configured. ' +
'Configure WorktreeCreate/WorktreeRemove hooks in settings.json to use worktree isolation with other VCS systems.',
)
}
const { worktreePath, worktreeBranch, headCommit, existed } =
await getOrCreateWorktree(gitRoot, slug)
if (!existed) {
logForDebugging(
`Created agent worktree at: ${worktreePath} on branch: ${worktreeBranch}`,
)
await performPostCreationSetup(gitRoot, worktreePath)
} else {
// Bump mtime so the periodic stale-worktree cleanup doesn't consider this
// worktree stale — the fast-resume path is read-only and leaves the original
// creation-time mtime intact, which can be past the 30-day cutoff.
const now = new Date()
await utimes(worktreePath, now, now)
logForDebugging(`Resuming existing agent worktree at: ${worktreePath}`)
}
return { worktreePath, worktreeBranch, headCommit, gitRoot }
}
/**
* Remove a worktree created by createAgentWorktree.
* For git-based worktrees, removes the worktree directory and deletes the temporary branch.
* For hook-based worktrees, delegates to the WorktreeRemove hook.
* Must be called with the main repo's git root (for git worktrees), not the worktree path,
* since the worktree directory is deleted during this operation.
*/
export async function removeAgentWorktree(
worktreePath: string,
worktreeBranch?: string,
gitRoot?: string,
hookBased?: boolean,
): Promise<boolean> {
if (hookBased) {
const hookRan = await executeWorktreeRemoveHook(worktreePath)
if (hookRan) {
logForDebugging(`Removed hook-based agent worktree at: ${worktreePath}`)
} else {
logForDebugging(
`No WorktreeRemove hook configured, hook-based agent worktree left at: ${worktreePath}`,
{ level: 'warn' },
)
}
return hookRan
}
if (!gitRoot) {
logForDebugging('Cannot remove agent worktree: no git root provided', {
level: 'error',
})
return false
}
// Run from the main repo root, not the worktree (which we're about to delete)
const { code: removeCode, stderr: removeError } =
await execFileNoThrowWithCwd(
gitExe(),
['worktree', 'remove', '--force', worktreePath],
{ cwd: gitRoot },
)
if (removeCode !== 0) {
logForDebugging(`Failed to remove agent worktree: ${removeError}`, {
level: 'error',
})
return false
}
logForDebugging(`Removed agent worktree at: ${worktreePath}`)
if (!worktreeBranch) {
return true
}
// Delete the temporary worktree branch from the main repo
const { code: deleteBranchCode, stderr: deleteBranchError } =
await execFileNoThrowWithCwd(gitExe(), ['branch', '-D', worktreeBranch], {
cwd: gitRoot,
})
if (deleteBranchCode !== 0) {
logForDebugging(
`Could not delete agent worktree branch: ${deleteBranchError}`,
{ level: 'error' },
)
}
return true
}
/**
* Slug patterns for throwaway worktrees created by AgentTool (`agent-a<7hex>`,
* from earlyAgentId.slice(0,8)), workflow engine isolation:'worktree'
* (`wf_<8hex>-<3hex>-<n>` derived from sha256(runId:agentId) in
* claudeCodeBackend — taskId is `w`+base36, not a UUID, so the slug cannot
* embed runId directly and is hashed to satisfy this hex pattern), and
* bridgeMain (`bridge-<safeFilenameId>`). These leak when the parent process
* is killed (Ctrl+C, ESC, crash) before their in-process cleanup runs.
* Exact-shape patterns avoid sweeping user-named EnterWorktree slugs like `wf-myfeature`.
*/
const EPHEMERAL_WORKTREE_PATTERNS = [
/^agent-a[0-9a-f]{7}$/,
/^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/,
// Legacy wf-<idx> slugs from before workflowRunId disambiguation — kept so
// the 30-day sweep still cleans up worktrees leaked by older builds.
/^wf-\d+$/,
// Real bridge slugs are `bridge-${safeFilenameId(sessionId)}`.
/^bridge-[A-Za-z0-9_]+(-[A-Za-z0-9_]+)*$/,
// Template job worktrees: job-<templateName>-<8hex>. Prefix distinguishes
// from user-named EnterWorktree slugs that happen to end in 8 hex.
/^job-[a-zA-Z0-9._-]{1,55}-[0-9a-f]{8}$/,
]
/**
* Remove stale agent/workflow worktrees older than cutoffDate.
*
* Safety:
* - Only touches slugs matching ephemeral patterns (never user-named worktrees)
* - Skips the current session's worktree
* - Fail-closed: skips if git status fails or shows tracked changes
* (-uno: untracked files in a 30-day-old crashed agent worktree are build
* artifacts; skipping the untracked scan is 5-10× faster on large repos)
* - Fail-closed: skips if any commits aren't reachable from a remote
*
* `git worktree remove --force` handles both the directory and git's internal
* worktree tracking. If git doesn't recognize the path as a worktree (orphaned
* dir), it's left in place — a later readdir finding it stale again is harmless.
*/
export async function cleanupStaleAgentWorktrees(
cutoffDate: Date,
): Promise<number> {
const gitRoot = findCanonicalGitRoot(getCwd())
if (!gitRoot) {
return 0
}
const dir = worktreesDir(gitRoot)
let entries: string[]
try {
entries = await readdir(dir)
} catch {
return 0
}
const cutoffMs = cutoffDate.getTime()
const currentPath = currentWorktreeSession?.worktreePath
let removed = 0
for (const slug of entries) {
if (!EPHEMERAL_WORKTREE_PATTERNS.some(p => p.test(slug))) {
continue
}
const worktreePath = join(dir, slug)
if (currentPath === worktreePath) {
continue
}
let mtimeMs: number
try {
mtimeMs = (await stat(worktreePath)).mtimeMs
} catch {
continue
}
if (mtimeMs >= cutoffMs) {
continue
}
// Both checks must succeed with empty output. Non-zero exit (corrupted
// worktree, git not recognizing it, etc.) means skip — we don't know
// what's in there.
const [status, unpushed] = await Promise.all([
execFileNoThrowWithCwd(
gitExe(),
['--no-optional-locks', 'status', '--porcelain', '-uno'],
{ cwd: worktreePath },
),
execFileNoThrowWithCwd(
gitExe(),
['rev-list', '--max-count=1', 'HEAD', '--not', '--remotes'],
{ cwd: worktreePath },
),
])
if (status.code !== 0 || status.stdout.trim().length > 0) {
continue
}
if (unpushed.code !== 0 || unpushed.stdout.trim().length > 0) {
continue
}
if (
await removeAgentWorktree(worktreePath, worktreeBranchName(slug), gitRoot)
) {
removed++
}
}
if (removed > 0) {
await execFileNoThrowWithCwd(gitExe(), ['worktree', 'prune'], {
cwd: gitRoot,
})
logForDebugging(
`cleanupStaleAgentWorktrees: removed ${removed} stale worktree(s)`,
)
}
return removed
}
/**
* Check whether a worktree has uncommitted changes or new commits since creation.
* Returns true if there are uncommitted changes (dirty working tree), if commits
* were made on the worktree branch since `headCommit`, or if git commands fail
* — callers use this to decide whether to remove a worktree, so fail-closed.
*/
export async function hasWorktreeChanges(
worktreePath: string,
headCommit: string,
): Promise<boolean> {
const { code: statusCode, stdout: statusOutput } =
await execFileNoThrowWithCwd(gitExe(), ['status', '--porcelain'], {
cwd: worktreePath,
})
if (statusCode !== 0) {
return true
}
if (statusOutput.trim().length > 0) {
return true
}
const { code: revListCode, stdout: revListOutput } =
await execFileNoThrowWithCwd(
gitExe(),
['rev-list', '--count', `${headCommit}..HEAD`],
{ cwd: worktreePath },
)
if (revListCode !== 0) {
return true
}
if (parseInt(revListOutput.trim(), 10) > 0) {
return true
}
return false
}
/**
* Fast-path handler for --worktree --tmux.
* Creates the worktree and execs into tmux running Claude inside.
* This is called early in cli.tsx before loading the full CLI.
*/
export async function execIntoTmuxWorktree(args: string[]): Promise<{
handled: boolean
error?: string
}> {
// Check platform - tmux doesn't work on Windows
if (process.platform === 'win32') {
return {
handled: false,
error: 'Error: --tmux is not supported on Windows',
}
}
// Check if tmux is available
const tmuxCheck = spawnSync('tmux', ['-V'], { encoding: 'utf-8' })
if (tmuxCheck.status !== 0) {
const installHint =
process.platform === 'darwin'
? 'Install tmux with: brew install tmux'
: 'Install tmux with: sudo apt install tmux'
return {
handled: false,
error: `Error: tmux is not installed. ${installHint}`,
}
}
// Parse worktree name and tmux mode from args
let worktreeName: string | undefined
let forceClassicTmux = false
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (!arg) continue
if (arg === '-w' || arg === '--worktree') {
// Check if next arg exists and isn't another flag
const next = args[i + 1]
if (next && !next.startsWith('-')) {
worktreeName = next
}
} else if (arg.startsWith('--worktree=')) {
worktreeName = arg.slice('--worktree='.length)
} else if (arg === '--tmux=classic') {
forceClassicTmux = true
}
}
// Check if worktree name is a PR reference
let prNumber: number | null = null
if (worktreeName) {
prNumber = parsePRReference(worktreeName)
if (prNumber !== null) {
worktreeName = `pr-${prNumber}`
}
}
// Generate a slug if no name provided
if (!worktreeName) {
const adjectives = ['swift', 'bright', 'calm', 'keen', 'bold']
const nouns = ['fox', 'owl', 'elm', 'oak', 'ray']
const adj = adjectives[Math.floor(Math.random() * adjectives.length)]
const noun = nouns[Math.floor(Math.random() * nouns.length)]
const suffix = Math.random().toString(36).slice(2, 6)
worktreeName = `${adj}-${noun}-${suffix}`
}
// worktreeName is joined into worktreeDir via path.join below; apply the
// same allowlist used by the in-session worktree tool so the constraint
// holds uniformly regardless of entry point.
try {
validateWorktreeSlug(worktreeName)
} catch (e) {
return {
handled: false,
error: `Error: ${(e as Error).message}`,
}
}
// Mirror createWorktreeForSession(): hook takes precedence over git so the
// WorktreeCreate hook substitutes the VCS backend for this fast-path too
// (anthropics/claude-code#39281). Git path below runs only when no hook.
let worktreeDir: string
let repoName: string
if (hasWorktreeCreateHook()) {
try {
const hookResult = await executeWorktreeCreateHook(worktreeName)
worktreeDir = hookResult.worktreePath
} catch (error) {
return {
handled: false,
error: `Error: ${errorMessage(error)}`,
}
}
repoName = basename(findCanonicalGitRoot(getCwd()) ?? getCwd())
console.log(`Using worktree via hook: ${worktreeDir}`)
} else {
// Get main git repo root (resolves through worktrees)
const repoRoot = findCanonicalGitRoot(getCwd())
if (!repoRoot) {
return {
handled: false,
error: 'Error: --worktree requires a git repository',
}
}
repoName = basename(repoRoot)
worktreeDir = worktreePathFor(repoRoot, worktreeName)
// Create or resume worktree
try {
const result = await getOrCreateWorktree(
repoRoot,
worktreeName,
prNumber !== null ? { prNumber } : undefined,
)
if (!result.existed) {
console.log(
`Created worktree: ${worktreeDir} (based on ${(result as any).baseBranch})`,
)
await performPostCreationSetup(repoRoot, worktreeDir)
}
} catch (error) {
return {
handled: false,
error: `Error: ${errorMessage(error)}`,
}
}
}
// Sanitize for tmux session name (replace / and . with _)
const tmuxSessionName =
`${repoName}_${worktreeBranchName(worktreeName)}`.replace(/[/.]/g, '_')
// Build new args without --tmux and --worktree (we're already in the worktree)
const newArgs: string[] = []
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (!arg) continue
if (arg === '--tmux' || arg === '--tmux=classic') continue
if (arg === '-w' || arg === '--worktree') {
// Skip the flag and its value if present
const next = args[i + 1]
if (next && !next.startsWith('-')) {
i++ // Skip the value too
}
continue
}
if (arg.startsWith('--worktree=')) continue
newArgs.push(arg)
}
// Get tmux prefix for user guidance
let tmuxPrefix = 'C-b' // default
const prefixResult = spawnSync('tmux', ['show-options', '-g', 'prefix'], {
encoding: 'utf-8',
})
if (prefixResult.status === 0 && prefixResult.stdout) {
const match = prefixResult.stdout.match(/prefix\s+(\S+)/)
if (match?.[1]) {
tmuxPrefix = match[1]
}
}
// Check if tmux prefix conflicts with Claude keybindings
// Claude binds: ctrl+b (task:background), ctrl+c, ctrl+d, ctrl+t, ctrl+o, ctrl+r, ctrl+s, ctrl+g, ctrl+e
const claudeBindings = [
'C-b',
'C-c',
'C-d',
'C-t',
'C-o',
'C-r',
'C-s',
'C-g',
'C-e',
]
const prefixConflicts = claudeBindings.includes(tmuxPrefix)
// Set env vars for the inner Claude to display tmux info in welcome message
const tmuxEnv = {
...process.env,
CLAUDE_CODE_TMUX_SESSION: tmuxSessionName,
CLAUDE_CODE_TMUX_PREFIX: tmuxPrefix,
CLAUDE_CODE_TMUX_PREFIX_CONFLICTS: prefixConflicts ? '1' : '',
}
// Check if session already exists
const hasSessionResult = spawnSync(
'tmux',
['has-session', '-t', tmuxSessionName],
{ encoding: 'utf-8' },
)
const sessionExists = hasSessionResult.status === 0
// Check if we're already inside a tmux session
const isAlreadyInTmux = Boolean(process.env.TMUX)
// Use tmux control mode (-CC) for native iTerm2 tab/pane integration
// This lets users use iTerm2's UI instead of learning tmux keybindings
// Use --tmux=classic to force traditional tmux even in iTerm2
// Control mode doesn't make sense when already in tmux (would need to switch-client)
const useControlMode = isInITerm2() && !forceClassicTmux && !isAlreadyInTmux
const tmuxGlobalArgs = useControlMode ? ['-CC'] : []
// Print hint about iTerm2 preferences when using control mode
if (useControlMode && !sessionExists) {
const y = chalk.yellow
console.log(
`\n${y('╭─ iTerm2 Tip ────────────────────────────────────────────────────────╮')}\n` +
`${y('│')} To open as a tab instead of a new window: ${y('│')}\n` +
`${y('│')} iTerm2 > Settings > General > tmux > "Tabs in attaching window" ${y('│')}\n` +
`${y('╰─────────────────────────────────────────────────────────────────────╯')}\n`,
)
}
// For ants in claude-cli-internal, set up dev panes (watch + start)
const isAnt = process.env.USER_TYPE === 'ant'
const isClaudeCliInternal = repoName === 'claude-cli-internal'
const shouldSetupDevPanes = isAnt && isClaudeCliInternal && !sessionExists
if (shouldSetupDevPanes) {
// Create detached session with Claude in first pane
spawnSync(
'tmux',
[
'new-session',
'-d', // detached
'-s',
tmuxSessionName,
'-c',
worktreeDir,
'--',
process.execPath,
...newArgs,
],
{ cwd: worktreeDir, env: tmuxEnv },
)
// Split horizontally and run watch
spawnSync(
'tmux',
['split-window', '-h', '-t', tmuxSessionName, '-c', worktreeDir],
{ cwd: worktreeDir },
)
spawnSync(
'tmux',
['send-keys', '-t', tmuxSessionName, 'bun run watch', 'Enter'],
{ cwd: worktreeDir },
)
// Split vertically and run start
spawnSync(
'tmux',
['split-window', '-v', '-t', tmuxSessionName, '-c', worktreeDir],
{ cwd: worktreeDir },
)
spawnSync('tmux', ['send-keys', '-t', tmuxSessionName, 'bun run start'], {
cwd: worktreeDir,
})
// Select the first pane (Claude)
spawnSync('tmux', ['select-pane', '-t', `${tmuxSessionName}:0.0`], {
cwd: worktreeDir,
})
// Attach or switch to the session
if (isAlreadyInTmux) {
// Switch to sibling session (avoid nesting)
spawnSync('tmux', ['switch-client', '-t', tmuxSessionName], {
stdio: 'inherit',
})
} else {
// Attach to the session
spawnSync(
'tmux',
[...tmuxGlobalArgs, 'attach-session', '-t', tmuxSessionName],
{
stdio: 'inherit',
cwd: worktreeDir,
},
)
}
} else {
// Standard behavior: create or attach
if (isAlreadyInTmux) {
// Already in tmux - create detached session, then switch to it (sibling)
// Check if session already exists first
if (sessionExists) {
// Just switch to existing session
spawnSync('tmux', ['switch-client', '-t', tmuxSessionName], {
stdio: 'inherit',
})
} else {
// Create new detached session
spawnSync(
'tmux',
[
'new-session',
'-d', // detached
'-s',
tmuxSessionName,
'-c',
worktreeDir,
'--',
process.execPath,
...newArgs,
],
{ cwd: worktreeDir, env: tmuxEnv },
)
// Switch to the new session
spawnSync('tmux', ['switch-client', '-t', tmuxSessionName], {
stdio: 'inherit',
})
}
} else {
// Not in tmux - create and attach (original behavior)
const tmuxArgs = [
...tmuxGlobalArgs,
'new-session',
'-A', // Attach if exists, create if not
'-s',
tmuxSessionName,
'-c',
worktreeDir,
'--', // Separator before command
process.execPath,
...newArgs,
]
spawnSync('tmux', tmuxArgs, {
stdio: 'inherit',
cwd: worktreeDir,
env: tmuxEnv,
})
}
}
return { handled: true }
}