Compare commits

...

10 Commits

Author SHA1 Message Date
claude-code-best
ddf1acdaed chore: 2.7.1 2026-06-15 19:09:19 +08:00
claude-code-best
6c633744f4 Fix/ripgrep fallback (#1273)
* fix: tmp 目录改用 os.tmpdir() + ripgrep 缺失时自动 fallback 系统 rg

1. Shell.ts / imagePaste.ts / filesystem.ts: Linux/macOS 默认 tmp 路径
   从硬编码 '/tmp' 改为 os.tmpdir(),自动适配 Termux/Android 等无 /tmp
   的环境;macOS 桌面零变化;CLAUDE_CODE_TMPDIR 仍优先级最高。

2. ripgrep.ts: builtin rg 二进制缺失时(Android/Termux、不完整安装)
   自动 fallback 到 PATH 上的系统 rg,通过 note 字段携带人读提示;
   /doctor 渲染 note;init 启动时写一行 stderr warning。

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

* fix: review fix — ripgrep note 文案修正 + init catch 加调试日志

- ripgrep "no ripgrep available" note 去掉无意义的 USE_BUILTIN_RIPGREP=0 建议
- init.ts ripgrep status check 的空 catch 加 logForDebugging

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

---------

Co-authored-by: glm-5.2 <zai-org@claude-code-best.win>
2026-06-15 19:08:31 +08:00
claude-code-best
bb100b16b3 fix: ESC 关闭 local-jsx 面板后添加 grace-period 防止误触 cancel
/workflows 等面板通过 ESC 关闭时,React unmount 与 chat:cancel
keybinding 的 isActive 解除之间存在竞态窗口,导致同一按 ESC
会穿透到 onCancel 并中止正在执行的 Workflow 工具。

添加 500ms grace-period guard:面板关闭时打时间戳,onCancel 在窗口
内吞掉 ESC 并 reset,后续有意 ESC 仍正常取消。

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
2026-06-15 16:51:37 +08:00
claude-code-best
0eabcccce9 fix: review — Brave API key + webFetchHttpTimeoutMs 联动 + Tavily URL 推导
- braveAdapter: 读取 settings.braveApiKey (优先于环境变量)
- webFetch utils: getFetchTimeoutMs() 统一读取 settings.webFetchHttpTimeoutMs,HTTP/Tavily 两条路径均生效
- tavilyAdapter: 自定义端点自动追加 /search 路径(与 fetchContentWithTavily 一致)

Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
2026-06-15 16:51:37 +08:00
claude-code-best
9d845d77b9 feat: 重构 WebSearch/WebFetch,新增 Tavily 适配器及 /web-tools 面板
- WebSearch: 默认 Tavily,适配器优先级 WEB_SEARCH_ADAPTER > settings.webSearchAdapter > tavily
- WebFetch: 支持 Tavily /extract 返回 Markdown,移除 domain blacklist 远程检查
- 新增 /web-tools 命令面板(Search/Fetch 双 Tab + 二级配置菜单)
- 新增 settings 字段: webSearchAdapter, webFetchAdapter, tavilyEndpointUrl, braveApiKey, exaApiKey, exaEndpointUrl, webFetchHttpTimeoutMs
- 适配器联动: Tavily/Exa 从 settings 读取 endpoint 和 API key

Co-Authored-By: deepseek-v4-pro <deepseek-ai@claude-code-best.win>
2026-06-15 16:51:37 +08:00
claude-code-best
2714bbf812 docs: update contributors 2026-06-15 00:28:17 +00:00
claude-code-best
21e42e24b1 chore: 2.7.0 2026-06-14 18:14:42 +08:00
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
moy16
3e3e1de81b feat: /goal命令能力支持,参考codex实现 (#1261)
* feat: /goal命令能力支持,参考codex实现

* fix: 修复promp和提示词不一致的问题

* fix: 修复 goal 功能多项 AI 审查问题

- prompt 中 update 行为描述与运行时不一致(no-op → error)
- src/commands/goal/ 使用相对路径导入,改为 src/* 别名
- /goal 命令标记 bridgeSafe 但含交互式对话框,改为 false
- useGoalContinuation 中 origin 使用 as unknown as string 强转,改为直接传字符串
- ResumeConversation 路径缺少 goal hydration,补齐恢复逻辑
- onCancel 在非查询状态下误暂停 goal,加 queryGuard 守卫
- resumeGoal 允许从终态恢复,收紧为仅允许 paused 状态
- buildGoalContextBlock 生成畸形 XML 属性,改为合法 budget 属性

* fix: 修复剩余AI审查的问题

* fix: 防止goal状态丢失

* fix: 修复Biome规范错误问题

* fix: 修复部分情况下goal无法启动的问题

* fix: 增加断网后状态默认设置为PAUSE机制、完成暂停-恢复状态切换,且正常进行前端渲染。设置达到max turn后处理逻辑。

* fix: 修复终端异常断开情况,resume续跑;修复用户消息排队信息被goal输出信息覆盖的问题。

* fix: apply biome formatting to pass CI lint check

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: skip slash command echo in setUserInputOnProcessing to prevent UI flash

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: moyu <moyu@kingsoft.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 10:44:10 +08:00
YYMa
5bfe6fa590 feat: add China LLM providers guided login flow (#1254)
* docs: update contributors

* docs: update contributors

* feat: add China LLM providers guided login flow

Add a guided login experience for 4 domestic (China) LLM providers
in the /login command: DeepSeek, Zhipu GLM, Tongyi Qianwen, and
MiMo Xiaomi. Each provider includes model presets with pricing,
context windows, and optional Coding Plan integration.

- New file: src/utils/chinaLlmProviders.ts — provider preset configs
- Modified: src/components/ConsoleOAuthFlow.tsx — 4-step guided flow
  (select provider → select mode → select model → enter API key)

All providers are OpenAI-compatible; credentials saved as
OPENAI_BASE_URL + OPENAI_API_KEY under modelType: 'openai'.

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

* feat: add custom model input with suggestions and model listing links

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-13 22:25:18 +08:00
179 changed files with 28058 additions and 1133 deletions

View File

@@ -47,14 +47,15 @@ jobs:
test -s coverage/lcov.info test -s coverage/lcov.info
grep -q '^SF:' coverage/lcov.info grep -q '^SF:' coverage/lcov.info
- name: Upload coverage to Codecov # codecov 坏了,老是失败,先注释掉
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} # - name: Upload coverage to Codecov
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5, 2026-04-25 # if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
with: # uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5, 2026-04-25
fail_ci_if_error: true # with:
files: ./coverage/lcov.info # fail_ci_if_error: true
disable_search: true # files: ./coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }} # disable_search: true
# token: ${{ secrets.CODECOV_TOKEN }}
- name: Build - name: Build
run: bun run build:vite run: bun run build:vite

View File

@@ -6,7 +6,12 @@
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": { "files": {
"includes": ["**", "!!**/dist"] "includes": [
"**",
"!!**/dist",
"!!**/.claude/workflows",
"!!**/*.workflow.mjs"
]
}, },
"formatter": { "formatter": {
"enabled": true, "enabled": true,

View File

@@ -332,6 +332,17 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
}, },
}, },
"packages/workflow-engine": {
"name": "@claude-code-best/workflow-engine",
"version": "0.1.0",
"dependencies": {
"ajv": "^8.18.0",
"zod": "^4.3.6",
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.81.0",
},
},
}, },
"overrides": { "overrides": {
"@inquirer/prompts": "8.4.2", "@inquirer/prompts": "8.4.2",
@@ -586,6 +597,8 @@
"@claude-code-best/weixin": ["@claude-code-best/weixin@workspace:packages/weixin"], "@claude-code-best/weixin": ["@claude-code-best/weixin@workspace:packages/weixin"],
"@claude-code-best/workflow-engine": ["@claude-code-best/workflow-engine@workspace:packages/workflow-engine"],
"@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="],
"@emnapi/core": ["@emnapi/core@1.9.2", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], "@emnapi/core": ["@emnapi/core@1.9.2", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@@ -1,102 +1,183 @@
# WORKFLOW_SCRIPTS — 工作流自动化 # WORKFLOW_SCRIPTS — 确定性多 agent 工作流编排
> Feature Flag: `FEATURE_WORKFLOW_SCRIPTS=1` > Feature Flag`FEATURE_WORKFLOW_SCRIPTS=1`
> 实现状态:全部 Stub7 个文件),布线完整 > 引擎包:[`@claude-code-best/workflow-engine`](../../packages/workflow-engine/)(确定性 JS 脚本编排,零核心层运行时依赖)
> 引用数10 > 集成层:[`src/workflow/`](../../src/workflow/)
## 一、功能概述 ## 一、功能概述
WORKFLOW_SCRIPTS 实现基于文件的多步自动化工作流。用户可以定义 YAML/JSON 格式的工作流描述文件,系统将其解析为可执行的多 agent 步骤序列。提供 `/workflows` 命令管理和触发工作流 WORKFLOW_SCRIPTS 让 Claude Code 用**确定性 JavaScript 脚本**编排多个子 agent可分解/并行、多视角置信、规模超单上下文、可 resume/可审计
- **编排原语**`agent` / `parallel` / `pipeline` / `phase` / `log` / `workflow`(见引擎包)。
- **确定性**:脚本在受限沙箱内执行,禁用 `Date.now()` / `Math.random()` / 无参 `new Date()`,保证 journal 可重放。
- **深度后端**:单一 `claude-code` AgentAdapter 接入当前会话体系provider / model / agentType / 工具workflow 内的 `agent()` 调用真实子 agent。
- **监控面板**`/workflows` 双栏实时面板(见 §六)。
- **编排手册**`/ultracode` 注入编排工作法(见 §七)。
> 历史说明:早期版本为 YAML/JSON DSL + 全 Stub 实现(`WorkflowDetailDialog` 等),已全量重写为引擎驱动的 JS 方案。
## 二、实现架构 ## 二、实现架构
### 2.1 模块状态
| 模块 | 文件 | 状态 |
|------|------|------|
| WorkflowTool | `packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts` | **部分实现** — tool schema + 渲染完整call 返回运行时缺失提示 |
| Workflow 权限 | `packages/builtin-tools/src/tools/WorkflowTool/WorkflowPermissionRequest.tsx` | **部分实现** — 权限请求组件 |
| 常量 | `packages/builtin-tools/src/tools/WorkflowTool/constants.ts` | **实现** — 工具名 + 目录名 + 文件扩展名常量 |
| 命令创建 | `packages/builtin-tools/src/tools/WorkflowTool/createWorkflowCommand.ts` | **实现** — 扫描 .claude/workflows/ 目录创建 Command 对象 |
| 捆绑工作流 | `packages/builtin-tools/src/tools/WorkflowTool/bundled/index.ts` | **实现** — 内置工作流初始化 |
| 本地工作流任务 | `src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts` | **Stub** — 类型 + 空操作 |
| UI 任务组件 | `src/components/tasks/src/tasks/LocalWorkflowTask/` | **Stub** — 空导出 |
| 详情对话框 | `src/components/tasks/WorkflowDetailDialog.ts` | **Stub** — 返回 null |
| 任务注册 | `src/tasks.ts` | **布线** — 动态加载 |
| 工具注册 | `src/tools.ts` | **布线** — 动态加载 + bundled 工作流初始化 (行 131-134,235) |
| 命令注册 | `src/commands.ts` | **布线**`/workflows` 命令 (行 93-95,395,460) |
### 2.2 预期数据流
``` ```
用户定义工作流YAML/JSON 文件 .claude/workflows/<name>.ts Workflow 工具name/script/scriptPath/args/resumeFromRunId
│ │
▼ ▼
namedWorkflowCommands.ts src/workflow/wiring.ts (createWorkflowToolCore)
/<name> 命令发现) │
WorkflowService门面launch/kill/subscribe/listRuns/listNamed
┌────────────────┼─────────────────┐
▼ ▼ ▼
ports.ts registry.ts progress/
(端口聚合) AgentAdapterRegistry bus + store
│ │
▼ ▼
hostHandle.ts backends/claudeCodeBackend.ts
(不透明 host (深度读会话体系,跑真实 agent
/workflows 命令发现工作流文件 @claude-code-best/workflow-engine
runWorkflow / hooks / journal / budget / 并发信号量)
createWorkflowCommand() 解析为 Command 对象 [需要实现]
WorkflowTool 执行工作流 [需要实现]
├── 步骤 1: Agent({ task: "..." })
├── 步骤 2: Agent({ task: "..." })
└── 步骤 N: Agent({ task: "..." })
LocalWorkflowTask 协调步骤执行 [需要实现]
WorkflowDetailDialog 显示进度 [需要实现]
``` ```
### 2.3 预期工作流 DSL ### 2.1 模块清单
``` | 层 | 文件 | 职责 |
# workflow.yaml预期格式需要设计 |----|------|------|
name: "代码审查工作流" | 引擎 | `packages/workflow-engine/src/` | 确定性脚本沙箱 + hooks + journal + budget + 信号量;导出 `createWorkflowTool` |
steps: | 工具装配 | `src/workflow/wiring.ts` | `createWorkflowToolCore()` —— 用 `WorkflowService.ports` 组装 `Workflow` 工具 |
- name: "静态分析" | 服务门面 | `src/workflow/service.ts` | `WorkflowService` 单例:`launch` / `kill` / `subscribe` / `listRuns` / `listNamed` / `getWorkflowService()` |
agent: { type: "general-purpose", prompt: "运行 lint 和类型检查" } | 端口 | `src/workflow/ports.ts` | `createWorkflowPorts()` 聚合所有端口agentRunner/registry/progress/task/journal/permission/logger/hostFactory |
- name: "测试" | 后端注册 | `src/workflow/registry.ts` | `buildRegistry()` 注册 `claude-code` 后端并设为默认 |
agent: { type: "general-purpose", prompt: "运行测试套件" } | 深度后端 | `src/workflow/backends/claudeCodeBackend.ts` | AgentAdapter`agentType`/`model` 解析会话体系,跑真实子 agent结构化输出 |
- name: "综合报告" | Host 句柄 | `src/workflow/hostHandle.ts` | `buildHostBundle()` 不透明包装 `toolUseContext`/`canUseTool`/`parentMessage` |
agent: { type: "general-purpose", prompt: "综合分析结果写报告" } | 进度总线 | `src/workflow/progress/bus.ts` | 基于 Set 的进度事件发射 |
| 进度状态 | `src/workflow/progress/store.ts` | reducer`agentId` 精确关联 `agent_done`(修并发竞态) |
| 监控面板 | `src/workflow/panel/*.tsx` | `/workflows` 双栏 UI见 §六) |
| 命名命令 | `src/workflow/namedWorkflowCommands.ts` | 扫描 `.claude/workflows/` 生成 `/<name>` 命令 |
| 权限请求 | `src/workflow/WorkflowPermissionRequest.tsx` | workflow 启动权限 UI |
### 2.2 注册点
| 位置 | 内容 |
|------|------|
| `src/tools.ts:152-153,254` | `createWorkflowToolCore()` 动态加载并注册 `Workflow` 工具feature-gated |
| `src/commands.ts:95-97,392` | `/workflows` 命令local-jsx加载 `panelCall.js` |
| `src/skills/bundled/ultracode.ts` + `index.ts` | `/ultracode` 知识 skill`registerBundledSkill` |
## 三、编排原语
workflow 脚本内可用的钩子(语义详见引擎包 `engine/hooks.ts`
| 原语 | 语义 |
|------|------|
| `agent(prompt, opts?)` | 派发一个子 agent返回最终文本`opts.schema`结构化对象。opts`model` / `agentType` / `label` / `phase` / `schema` |
| `parallel([() => …])` | 并发跑 thunk 数组,**barrier**(等全部完成);单项抛错 → 该项 `null`,其余保留 |
| `pipeline(items, s1, s2, …)` | 每个 item 链式过各 stage**item 间无 barrier**stage 内顺序;单 item 某 stage 抛错 → 该 item `null` |
| `phase(title)` | 标记阶段(面板按此分组展示) |
| `log(msg)` | 进度日志(面板展示,无状态变更) |
| `workflow(name \| { scriptPath }, args?)` | 嵌套一层子 workflow仅允许一层 |
**硬限**:单次 `parallel`/`pipeline``MAX_ITEMS_PER_CALL`4096单 workflow 总 agent ≤ `MAX_TOTAL_AGENTS`1000并发 cap 默认 = `DEFAULT_MAX_CONCURRENCY`3可经 Workflow 工具的 `maxConcurrency` 入参覆盖,绝对上限 `MAX_CONCURRENCY_CAP`16
## 四、编写 workflow
脚本置于 `.claude/workflows/<name>.js|.mjs`(也接受 `.ts`,但**引擎不转译 TS**,含类型注解会报语法错——推荐 `.js`/`.mjs`),自动成为 `/<name>` 命令。
```js
// .claude/workflows/review-changes.js
export const meta = {
name: 'review-changes',
description: '按维度审查改动并对抗式验证',
phases: [{ title: 'Review' }, { title: 'Verify' }],
}
const DIMENSIONS = [
{ key: 'bugs', prompt: '找正确性 bug' },
{ key: 'perf', prompt: '找性能问题' },
]
const results = await pipeline(
DIMENSIONS,
d => agent(d.prompt, { label: `review:${d.key}`, phase: 'Review' }),
review => parallel(
(review.findings || []).map(f => () =>
agent(`对抗式验证:${f.title}`, { phase: 'Verify' })
)
)
)
return results.flat().filter(Boolean)
``` ```
## 三、需要补全的内容 **脚本执行约束**(引擎执行模型,违反直接报错):
| 优先级 | 模块 | 工作量 | 说明 | 脚本是 `new AsyncFunction` 的**函数体**,不是 ESM 模块:
|--------|------|--------|------|
| 1 | `WorkflowTool.ts` call 方法 | 中 | 实际工作流执行逻辑(当前返回运行时缺失提示) |
| 2 | `LocalWorkflowTask.ts` | 大 | 步骤协调、kill/skip/retry |
| 3 | `WorkflowDetailDialog.ts` | 中 | 进度详情 UI |
## 四、关键设计决策 - **禁 `import`**`agent`/`parallel`/`pipeline`/`phase`/`log`/`workflow``args`/`budget` 是注入的形参,直接用。
- **禁 TS 语法**:不要类型注解(`x: number`)、`interface``enum``as`、泛型。引擎不转译,即便文件是 `.ts` 也会原样报语法错。
- **只允许一处 `export const meta = {...}`**(引擎正则提取剥离);不要 `export` 其他、不要 `export default`
- **顶层 `return` 返回结果**。
1. **基于文件的 DSL**工作流定义为文件YAML/JSON版本控制友好 **确定性约束**(违反则 resume 失效):
2. **多 Agent 步骤**:每个步骤是独立的 agent 任务,支持并行/串行 -`Date.now()` / `Math.random()` / 无参 `new Date()`(沙箱强制抛错)。需时间戳/随机种子经 `args` 传入。
3. **内置工作流**`bundled/` 目录提供开箱即用的常用工作流 - `export const meta = { ... }` 必须是**纯字面量**(无变量、函数调用、模板插值)——加载期求值,否则抛 `ScriptError`
4. **/workflows 命令**:统一的发现和触发入口
## 五、使用方式 ## 五、Workflow 工具
```bash 模型通过 `Workflow` 工具启动 workflowinput schema 见引擎包 `tool/schema.ts`
# 启用 feature需要补全后才能真正使用
FEATURE_WORKFLOW_SCRIPTS=1 bun run dev
```
## 六、文件索引 | 字段 | 说明 |
|------|------|
| `script` | 内联脚本字符串 |
| `name` | 命名 workflow 名(对应 `.claude/workflows/<name>` |
| `scriptPath` | 脚本文件路径 |
| `args` | 透传给脚本的 `args`(任意 JSON 值) |
| `resumeFromRunId` | 从既有 runId 重放(已完成 `agent()` 秒回,发散点后现场重跑) |
## 六、监控面板:`/workflows`
`/workflows` 打开三区焦点面板local-jsx全屏
- **顶部 tabs**:每个 run 一个 tab状态圆点 + workflow 名 + `#runId短码`);同名脚本多次跑会多个 tab。
- **左 phase 侧栏**`All` + 合并 meta 声明的 phase未启动 `○` pending 灰)与实际 phase`●` running / `✓` done选中即决定右栏筛选。
- **右 agent 列表**:按选中 phase 过滤;状态色 + 行尾文字(`running` / `object` / `text` / `dead`)。
**键位**`Tab`/`Shift+Tab` 切 run · `←`/`→` 切左右焦点列phases ↔ agents· `↑`/`↓` 列内移动 · `r` resume · `x` kill · `n` 新建提示 · `q`/`Esc` 退出。
**视觉**:无内框,左右一条竖线分隔;聚焦列标题橙粗;选中/光标行铺橙底(`backgroundColor`),文字色不变。
进度按引擎 `agentId` 精确关联 `agent_done`(解决并发 LIFO 竞态。pending phase 来自 `run_started` 事件携带的 `meta.phases`store 落地 `declaredPhases`,面板 `mergePhases` 合并。`useSyncExternalStore` 订阅 `WorkflowService`,稳定快照,无变更不重渲染。
## 七、`/ultracode` skill
`/ultracode``src/skills/bundled/ultracode.ts`)注入多 agent workflow 编排工作法:何时用 / 何时不用、编排原语速查、质量模式库adversarial-verify / judge-panel / loop-until-dry / multi-modal-sweep / completeness-critic、确定性约束、后端路由、resume/budget、文件与命令。
**纯知识 prompt skill**:零运行时副作用,不改主循环、不切换行为开关。调用即把手册注入上下文。
## 八、resume / journal / budget
- **journal**:每次 run 记录到 `.claude/workflow-runs/<runId>/journal.jsonl``resumeFromRunId` 重放 journal已完成 `agent()` 秒回缓存结果。
- **budget**`budget.total` 为 token 硬顶(默认 `null` = 无限);`budget.spent()` / `budget.remaining()` 读实时消耗;耗尽后再发 `agent()` 抛错。
- **并发**:引擎 `Semaphore` 默认许可 3`DEFAULT_MAX_CONCURRENCY`),可经 Workflow 工具的 `maxConcurrency` 入参 per-run 覆盖(钳到 `[1, MAX_CONCURRENCY_CAP=16]`)。
- **错误**:脚本语法/meta 错 → `parseScript` 即时返错不进后台agent 抛错 → `kind:'dead'``null`workflow 继续(`parallel`/`pipeline` 容错);`WorkflowAbortedError``killed`
## 九、文件索引
| 文件 | 职责 | | 文件 | 职责 |
|------|------| |------|------|
| `packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts` | 工具定义(部分实现 | | `src/workflow/wiring.ts` | `Workflow` 工具装配(`createWorkflowToolCore` |
| `packages/builtin-tools/src/tools/WorkflowTool/WorkflowPermissionRequest.tsx` | 权限请求组件 | | `src/workflow/service.ts` | `WorkflowService` 门面 |
| `packages/builtin-tools/src/tools/WorkflowTool/constants.ts` | 常量定义 | | `src/workflow/ports.ts` | 端口聚合(`createWorkflowPorts` |
| `packages/builtin-tools/src/tools/WorkflowTool/createWorkflowCommand.ts` | 命令创建(已实现) | | `src/workflow/registry.ts` | `AgentAdapterRegistry` + 默认后端 |
| `packages/builtin-tools/src/tools/WorkflowTool/bundled/index.ts` | 内置工作流初始化 | | `src/workflow/backends/claudeCodeBackend.ts` | 深度后端 AgentAdapter |
| `src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts` | 任务协调stub | | `src/workflow/hostHandle.ts` | 不透明 host 句柄(`buildHostBundle` |
| `src/components/tasks/WorkflowDetailDialog.ts` | 详情对话框stub | | `src/workflow/progress/bus.ts` | 进度事件总线 |
| `src/tools.ts:131-134,235` | 工具注册 | | `src/workflow/progress/store.ts` | 进度 reducer`agentId` 关联) |
| `src/commands.ts:93-95,395,460` | 命令注册 | | `src/workflow/panel/*.tsx` | `/workflows` 双栏面板 |
| `src/workflow/namedWorkflowCommands.ts` | `/<name>` 命令发现 |
| `src/workflow/WorkflowPermissionRequest.tsx` | 启动权限 UI |
| `src/skills/bundled/ultracode.ts` | `/ultracode` 知识 skill |
| `src/tools.ts:152-153,254` | 工具注册 |
| `src/commands.ts:95-97,392` | `/workflows` 命令注册 |
| `packages/workflow-engine/` | 引擎包hooks / journal / budget / 并发) |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,897 @@
# EffortPanel 基础面板实施计划(第一阶段)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:**`/effort` 无参调用升级为横向 slider 选择面板,覆盖 `low/medium/high/xhigh/max/ultracode` 六档,`←/→` 移动光标、`Enter` 确认、`Esc` 取消。
**Architecture:** 新增自包含 `EffortPanel` React 组件 + 纯函数状态模块;键盘交互走项目既有的 `useKeybindings` + 自定义 `EffortPanel` keybinding context`ModelPicker` 范式一致);不修改 `src/utils/effort.ts`,复用其纯函数;改造 `src/commands/effort/effort.tsx``call()`,仅无参时挂载面板。
**Tech Stack:** Bun + TypeScript + React (Ink via `@anthropic/ink`) + `bun:test` + Biome
**Spec:** `docs/superpowers/specs/2026-06-14-effort-panel-design.md`
**范围:** 仅第一阶段(基础面板 + 键盘交互 + env override 警告 + ultracode 文案分支)。波纹动画在第二阶段单独 commit不在本计划内。
---
## 文件结构
| 文件 | 状态 | 责任 |
|---|---|---|
| `src/components/EffortPanel/effortPanelState.ts` | 新增 | `PanelPosition` 类型 + 纯函数(`moveLeft`/`moveRight`/`home`/`end`/`getInitialCursor`/`PANEL_POSITIONS`),可独立单测 |
| `src/components/EffortPanel/EffortPanel.tsx` | 新增 | 面板 React 组件:渲染布局 + `useKeybindings` + Enter/Esc 分支 + 调 `executeEffort` |
| `src/components/EffortPanel/__tests__/effortPanelState.test.ts` | 新增 | 纯函数单测 |
| `src/components/EffortPanel/__tests__/EffortPanel.test.tsx` | 新增 | 组件渲染 + 分支测试 |
| `src/keybindings/schema.ts` | 修改 | 在 `KeybindingAction` 联合类型里追加 4 个 `effortPanel:*` action |
| `src/keybindings/defaultBindings.ts` | 修改 | 追加 `EffortPanel` context 绑定(`←/→/enter/escape/home/end`|
| `src/keybindings/__tests__/`(如已有 schema/defaultBindings 测试)| 修改(如有) | 追加新 context 的回归断言 |
| `src/commands/effort/effort.tsx` | 修改 | `call()``args === ''` 时返回 `<EffortPanel>`;其他路径不变 |
**不修改的文件:** `src/utils/effort.ts``src/commands/effort/index.ts``src/state/AppState.tsx`
---
## Task 1纯函数状态模块TDD
**Files:**
- Create: `src/components/EffortPanel/effortPanelState.ts`
- Test: `src/components/EffortPanel/__tests__/effortPanelState.test.ts`
- [ ] **Step 1.1: 写失败测试(基础导出与边界)**
Create `src/components/EffortPanel/__tests__/effortPanelState.test.ts`:
```ts
import { describe, expect, test } from 'bun:test'
import {
END_POSITION,
HOME_POSITION,
PANEL_POSITIONS,
type PanelPosition,
getInitialCursor,
isUltracode,
moveLeft,
moveRight,
} from '../effortPanelState.js'
describe('effortPanelState', () => {
test('PANEL_POSITIONS 顺序为 low → ultracode', () => {
expect(PANEL_POSITIONS).toEqual([
'low',
'medium',
'high',
'xhigh',
'max',
'ultracode',
])
})
test('moveLeft 在 low 处保持 low', () => {
expect(moveLeft('low')).toBe('low')
})
test('moveLeft 正常左移', () => {
expect(moveLeft('high')).toBe('medium')
expect(moveLeft('ultracode')).toBe('max')
})
test('moveRight 在 ultracode 处保持 ultracode', () => {
expect(moveRight('ultracode')).toBe('ultracode')
})
test('moveRight 正常右移', () => {
expect(moveRight('medium')).toBe('high')
expect(moveRight('max')).toBe('ultracode')
})
test('HOME_POSITION 等于 low', () => {
expect(HOME_POSITION).toBe('low')
})
test('END_POSITION 等于 ultracode', () => {
expect(END_POSITION).toBe('ultracode')
})
test('isUltracode 守卫', () => {
expect(isUltracode('ultracode')).toBe(true)
expect(isUltracode('max')).toBe(false)
})
test('getInitialCursorenv override 存在时返回 env 值(若是合法档位)', () => {
expect(getInitialCursor({ envOverride: 'high', appStateEffort: 'medium', displayed: 'high' })).toBe('high')
})
test('getInitialCursorenv 为 nullunset时用 displayed', () => {
expect(getInitialCursor({ envOverride: null, appStateEffort: undefined, displayed: 'medium' })).toBe('medium')
})
test('getInitialCursorenv undefined 时用 displayed', () => {
expect(getInitialCursor({ envOverride: undefined, appStateEffort: 'high', displayed: 'high' })).toBe('high')
})
test('getInitialCursorenv 是数值ant-only时落回 displayed', () => {
// 数值不是合法 PanelPosition回退
expect(getInitialCursor({ envOverride: 75, appStateEffort: 'medium', displayed: 'medium' })).toBe('medium')
})
test('PanelPosition 类型编译期检查(隐式)', () => {
const p: PanelPosition = 'xhigh'
expect(p).toBe('xhigh')
})
})
```
- [ ] **Step 1.2: 运行测试,确认失败**
Run: `bun test src/components/EffortPanel/__tests__/effortPanelState.test.ts`
Expected: FAIL错误形如 `Cannot find module '../effortPanelState.js'`
- [ ] **Step 1.3: 实现纯函数模块**
Create `src/components/EffortPanel/effortPanelState.ts`:
```ts
import type { EffortValue } from '../../../utils/effort.js'
/**
* 光标在面板上的位置。仅面板内部使用,不进入 AppState / settings / API。
* 'ultracode' 不是 EffortLevel它在本面板里仅作视觉占位与文案引导。
*/
export type PanelPosition =
| 'low'
| 'medium'
| 'high'
| 'xhigh'
| 'max'
| 'ultracode'
export const PANEL_POSITIONS: readonly PanelPosition[] = [
'low',
'medium',
'high',
'xhigh',
'max',
'ultracode',
] as const
export const HOME_POSITION: PanelPosition = 'low'
export const END_POSITION: PanelPosition = 'ultracode'
const NON_ULTRACODE_POSITIONS: readonly PanelPosition[] = PANEL_POSITIONS.filter(
p => p !== 'ultracode',
)
/**
* 判断一个 EffortValue 是否可作为面板光标位置。
* 数值ant-only和 ultracode 都不是合法 PanelPositionultracode 由面板内部产生)。
*/
function isPanelPosition(value: unknown): value is PanelPosition {
return typeof value === 'string' && (PANEL_POSITIONS as readonly string[]).includes(value)
}
/**
* 把非 ultracode 的 string EffortValue 收窄为 PanelPosition 的前 5 档。
* 用于 env override 与 appState 的归一化。
*/
function normalizeToPanelPosition(value: EffortValue | null | undefined): PanelPosition | undefined {
if (value === null || value === undefined) return undefined
if (typeof value === 'number') return undefined
if (isPanelPosition(value) && value !== 'ultracode') {
return value
}
return undefined
}
export function moveLeft(cursor: PanelPosition): PanelPosition {
const idx = PANEL_POSITIONS.indexOf(cursor)
if (idx <= 0) return PANEL_POSITIONS[0]
return PANEL_POSITIONS[idx - 1]
}
export function moveRight(cursor: PanelPosition): PanelPosition {
const idx = PANEL_POSITIONS.indexOf(cursor)
if (idx === -1 || idx >= PANEL_POSITIONS.length - 1) {
return PANEL_POSITIONS[PANEL_POSITIONS.length - 1]
}
return PANEL_POSITIONS[idx + 1]
}
export function isUltracode(cursor: PanelPosition): boolean {
return cursor === 'ultracode'
}
/**
* 决定面板挂载时的初始光标位置。
* 优先级env override若是合法档位> displayed level已是 fallback 'high' 之后)
*
* @param envOverride getEffortEnvOverride() 的返回值EffortValue | null | undefined
* @param appStateEffort AppState.effortValue
* @param displayed getDisplayedEffortLevel(model, appStateEffort) —— 必传,避免此处再依赖 model
*/
export function getInitialCursor(args: {
envOverride: EffortValue | null | undefined
appStateEffort: EffortValue | undefined
displayed: PanelPosition
}): PanelPosition {
const fromEnv = normalizeToPanelPosition(args.envOverride)
if (fromEnv !== undefined) return fromEnv
// displayed 已经是 EffortLevel不含 ultracode合法
return args.displayed
}
// 保留导出,便于将来测试扩展
export { NON_ULTRACODE_POSITIONS }
```
- [ ] **Step 1.4: 运行测试,确认通过**
Run: `bun test src/components/EffortPanel/__tests__/effortPanelState.test.ts`
Expected: PASS所有 11 个 test 通过)
- [ ] **Step 1.5: 类型 + lint 检查**
Run: `bunx tsc --noEmit && bunx biome check src/components/EffortPanel/`
Expected: 0 errors
- [ ] **Step 1.6: Commit**
```bash
git add src/components/EffortPanel/effortPanelState.ts src/components/EffortPanel/__tests__/effortPanelState.test.ts
git commit -m "$(cat <<'EOF'
feat(effort): 新增 EffortPanel 纯函数状态模块PanelPosition + 移动/初始光标)
仅含纯函数与类型,无 React/Ink 依赖,便于单测。
- PANEL_POSITIONSlow → medium → high → xhigh → max → ultracode
- moveLeft/moveRight边界钳制low 不再左移、ultracode 不再右移)
- getInitialCursorenv override > displayed level
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
EOF
)"
```
---
## Task 2注册 EffortPanel keybinding context
**Files:**
- Modify: `src/keybindings/schema.ts`(在 `KeybindingAction` 联合类型追加 6 个 action
- Modify: `src/keybindings/defaultBindings.ts`(追加 `EffortPanel` context 块)
- [ ] **Step 2.1: 检查 schema.ts 现有结构与校验测试**
Run: `grep -n "modelPicker:" src/keybindings/schema.ts`
Expected: 看到三行 `modelPicker:decreaseEffort/increaseEffort/toggle1M`,附近就是合适的插入位置。
Run: `ls src/keybindings/__tests__/ 2>/dev/null`
Expected: 查看是否有 schema/defaultBindings 的回归测试文件(决定是否需要补断言)。
- [ ] **Step 2.2: 在 schema.ts 追加 6 个 action**
打开 `src/keybindings/schema.ts`,找到 `// Model picker actions (ant-only)` 块(约 line 153-156在它**后面**追加:
```ts
// Effort panel actions (slash /effort without args)
'effortPanel:decrease',
'effortPanel:increase',
'effortPanel:home',
'effortPanel:end',
'effortPanel:confirm',
'effortPanel:cancel',
```
- [ ] **Step 2.3: 在 defaultBindings.ts 追加 EffortPanel context**
打开 `src/keybindings/defaultBindings.ts`,找到 `ModelPicker` 块(约 line 320-328在它**后面**`Select` 块之前)追加:
```ts
// Effort panel (slash /effort without args)
{
context: 'EffortPanel',
bindings: {
left: 'effortPanel:decrease',
right: 'effortPanel:increase',
h: 'effortPanel:decrease',
l: 'effortPanel:increase',
home: 'effortPanel:home',
end: 'effortPanel:end',
enter: 'effortPanel:confirm',
escape: 'effortPanel:cancel',
q: 'effortPanel:cancel',
'ctrl+c': 'effortPanel:cancel',
},
},
```
注意:
- `q``escape` / `ctrl+c` 都映射到 `effortPanel:cancel`,与 spec §5 状态机一致。
- Ink 的 useInput 默认在 ctrl+c 时退出进程;但项目 useKeybindings 系统会先拦截 ctrl+c参考 `useInput` 源码中 `if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC)` 分支)。若实施时发现 ctrl+c 仍直接退出进程,**降级为只绑 q + escape**,并在 commit message 里注明。
- Step 2.2 的 6 个 action`home/end`)与此处的 8 个绑定一一对应。
- [ ] **Step 2.4: 类型 + lint 检查**
Run: `bunx tsc --noEmit`
Expected: 0 errors如果 schema 校验是 type-level 的,新增 action 会被识别)
Run: `bun test src/keybindings/ 2>/dev/null`
Expected: 已有测试不破。
- [ ] **Step 2.5: Commit**
```bash
git add src/keybindings/schema.ts src/keybindings/defaultBindings.ts
git commit -m "$(cat <<'EOF'
feat(keybindings): 注册 EffortPanel context 与 6 个 action
绑定 ←/→/h/l/home/end/enter/escape 到 effortPanel:* action。
与 ModelPicker context 范式一致,避免左右键被全局 keybinding 拦截。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
EOF
)"
```
---
## Task 3实现 EffortPanel React 组件
**Files:**
- Create: `src/components/EffortPanel/EffortPanel.tsx`
- Create: `src/components/EffortPanel/__tests__/EffortPanel.test.tsx`
- [ ] **Step 3.1: 写失败测试(渲染基础形态)**
Create `src/components/EffortPanel/__tests__/EffortPanel.test.tsx`:
```tsx
import { describe, expect, mock, test } from 'bun:test'
import React from 'react'
import { render } from '../../../test-utils/ink-render.js'
import { EffortPanel } from '../EffortPanel.js'
// 复用项目共享 mock避免 bootstrap/state 副作用)
mock.module('src/utils/log.ts', () => {
const { logMock } = require('../../../../tests/mocks/log')
return logMock()
})
const baseProps = {
model: 'claude-opus-4-7',
appStateEffort: undefined as undefined | string,
onDone: () => {},
}
describe('EffortPanel 渲染', () => {
test('显示标题 Effort、两极 Faster/Smarter、6 个档位、底栏提示', () => {
const { stdout } = render(<EffortPanel {...baseProps} appStateEffort={undefined} />)
const out = stdout.join('')
expect(out).toContain('Effort')
expect(out).toContain('Faster')
expect(out).toContain('Smarter')
expect(out).toContain('low')
expect(out).toContain('medium')
expect(out).toContain('high')
expect(out).toContain('xhigh')
expect(out).toContain('max')
expect(out).toContain('ultracode')
expect(out).toContain('xhigh + workflows')
expect(out).toContain('←/→ adjust')
expect(out).toContain('Enter confirm')
expect(out).toContain('Esc cancel')
})
test('光标 ▲ 初始指向当前生效档high', () => {
const { stdout } = render(<EffortPanel {...baseProps} appStateEffort="high" />)
// 找到 high 那一行上方有 ▲
expect(stdout.join('')).toContain('▲')
})
})
```
> 注:`ink-render.js` 路径在 Step 3.2 探查;如项目无现成 helper退化为不依赖渲染的纯逻辑测试仅测 onDone 分支回调)。
- [ ] **Step 3.2: 探查 Ink 测试 helper**
Run:
```bash
find src packages -name "*.ts*" -path "*test*" -exec grep -l "render.*Ink\|@anthropic/ink" {} \; 2>/dev/null | head -5
grep -rn "render(" src/components/**/__tests__/*.tsx 2>/dev/null | head -10
```
Expected要么找到现成 helper用之要么确认项目里 Ink 组件测试都用"调用 onDone 回调断言"而非 ink render。如果后者**Step 3.1 改写为回调断言式测试**(见 Step 3.3 备注)。
- [ ] **Step 3.3: 实现组件**
Create `src/components/EffortPanel/EffortPanel.tsx`:
```tsx
import * as React from 'react'
import { Box, Text } from '@anthropic/ink'
import { useKeybindings } from '../../keybindings/useKeybinding.js'
import {
type EffortValue,
getDisplayedEffortLevel,
getEffortEnvOverride,
} from '../../utils/effort.js'
import {
type PanelPosition,
getInitialCursor,
isUltracode,
moveLeft,
moveRight,
PANEL_POSITIONS,
} from './effortPanelState.js'
import { executeEffort } from '../../commands/effort/effort.js'
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'
import { useSetAppState } from '../../state/AppState.js'
// 终端 ≥ 80 cols 时使用;窄屏适配第二阶段处理
const PANEL_WIDTH = 76
type Props = {
appStateEffort: EffortValue | undefined
onDone: (message: string) => void
}
// ▲ 落在每档中心列:均匀分布
function cursorColumn(cursor: PanelPosition): number {
const segment = Math.floor(PANEL_WIDTH / PANEL_POSITIONS.length)
const idx = PANEL_POSITIONS.indexOf(cursor)
return segment * idx + Math.floor(segment / 2)
}
function renderPaddedLine(cursor: PanelPosition): string {
const col = cursorColumn(cursor)
// ▲ 上方的"分隔线 + 光标"行:左侧 ─,到列处 ▲,右侧继续 ─
return `${'─'.repeat(col)}${'─'.repeat(Math.max(0, PANEL_WIDTH - col - 1))}`
}
export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode {
const setAppState = useSetAppState()
const model = useMainLoopModel()
const envOverride = getEffortEnvOverride()
const displayed = getDisplayedEffortLevel(model, appStateEffort)
const initialCursor = getInitialCursor({ envOverride, appStateEffort, displayed })
const [cursor, setCursor] = React.useState<PanelPosition>(initialCursor)
const [done, setDone] = React.useState(false)
const handleConfirm = React.useCallback(() => {
if (done) return
setDone(true)
if (isUltracode(cursor)) {
onDone(
'ultracode 不是 effort 档位。请使用 /ultracode <context> 启动多 agent workflow。',
)
return
}
const result = executeEffort(cursor)
if (result.effortUpdate) {
setAppState(prev => ({
...prev,
effortValue: result.effortUpdate!.value,
}))
}
onDone(result.message)
}, [cursor, done, onDone, setAppState])
const handleCancel = React.useCallback(() => {
if (done) return
setDone(true)
onDone('Effort unchanged.')
}, [done, onDone])
useKeybindings(
{
'effortPanel:decrease': () => setCursor(c => moveLeft(c)),
'effortPanel:increase': () => setCursor(c => moveRight(c)),
'effortPanel:home': () => setCursor('low'),
'effortPanel:end': () => setCursor('ultracode'),
'effortPanel:confirm': handleConfirm,
'effortPanel:cancel': handleCancel,
},
{ context: 'EffortPanel' },
)
const envActive = envOverride !== null && envOverride !== undefined
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL
// 两极文字行:左 Faster + 中间空格 + 右 Smarter
const fasterLen = 'Faster'.length
const smarterLen = 'Smarter'.length
const gap = Math.max(0, PANEL_WIDTH - fasterLen - smarterLen)
const poleLine = `Faster${' '.repeat(gap)}Smarter`
return (
<Box flexDirection="column" paddingX={1}>
<Text bold>Effort</Text>
{envActive && (
<Text color="yellow">
CLAUDE_CODE_EFFORT_LEVEL={envRaw} overrides this session
</Text>
)}
<Box marginTop={1}>
<Text>{poleLine}</Text>
</Box>
<Text>{renderPaddedLine(cursor)}</Text>
<Text>
{PANEL_POSITIONS.map(p => (p as string).padEnd(11)).join('').trimEnd()}
</Text>
<Text dimColor>
{' '.repeat(Math.max(0, PANEL_WIDTH - 'xhigh + workflows'.length))}
xhigh + workflows
</Text>
<Box marginTop={1}>
<Text dimColor>/ adjust · Enter confirm · Esc cancel</Text>
</Box>
</Box>
)
}
```
> ⚠️ 对齐是粗糙实现padEnd 11 假设每档名宽度 ≤ 11实际 'ultracode' = 9 字符OK'xhigh' = 5。第一版允许略微错位视觉精度在第二阶段调优。重点是标题、6 档名、底栏提示、▲ 标记必须出现。
> **Step 3.3 备注(如无 ink render helper** Step 5 走纯函数抽取方案测分支;渲染层只做"包含字符串"断言。
- [ ] **Step 3.4: 运行测试,确认通过**
Run: `bun test src/components/EffortPanel/__tests__/EffortPanel.test.tsx`
Expected: PASS
如失败:检查 `useKeybindings` import 路径、`executeEffort` 是否能从 effort.tsx 导出(必要时在 effort.tsx 加 `export`)、`useMainLoopModel` hook 是否在测试环境工作(可能需要 mock
- [ ] **Step 3.5: 类型 + lint 检查**
Run: `bunx tsc --noEmit && bunx biome check src/components/EffortPanel/`
Expected: 0 errors如有 lint 警告,按提示修;`useKeybindings` 未使用变量之类的需移除)
- [ ] **Step 3.6: Commit**
```bash
git add src/components/EffortPanel/EffortPanel.tsx src/components/EffortPanel/__tests__/EffortPanel.test.tsx
git commit -m "$(cat <<'EOF'
feat(effort): 实现 EffortPanel 组件主体(渲染 + 键盘交互 + 确认/取消分支)
- 横向 slider 布局Faster ↔ Smarter 两极6 档刻度
- useKeybindings 注册 EffortPanel context←/→/h/l/home/end/enter/escape
- Enter 在 5 档之一 → 调 executeEffort 写 settings + AppState
- Enter 在 ultracode → 输出引导文案,不写状态
- Esc → "Effort unchanged."
- env override 时顶部黄色警告
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
EOF
)"
```
---
## Task 4改造 `/effort` 命令挂载面板
**Files:**
- Modify: `src/commands/effort/effort.tsx`
- [ ] **Step 4.1: 阅读现状**
Run: `cat src/commands/effort/effort.tsx`
确认 `call()` 当前签名与 `ShowCurrentEffort` / `ApplyEffortAndClose` 组件结构。无参分支当前走 `<ShowCurrentEffort>`
- [ ] **Step 4.2: 改造 call() 无参分支**
打开 `src/commands/effort/effort.tsx`,找到 `call()` 函数(约 line 153-169。在文件顶部新增 import
```tsx
import { EffortPanel } from '../../components/EffortPanel/EffortPanel.js'
```
`call()` 改为(替换无参分支):
```tsx
export async function call(
onDone: LocalJSXCommandOnDone,
_context: unknown,
args?: string,
): Promise<React.ReactNode> {
args = args?.trim() || ''
if (COMMON_HELP_ARGS.includes(args)) {
onDone(
'Usage: /effort [low|medium|high|xhigh|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- xhigh: Extended reasoning beyond high, short of max; including ChatGPT Codex models\n- max: Maximum capability with deepest reasoning; maps to xhigh for ChatGPT Codex models\n- auto: Use the default effort level for your model',
)
return
}
// 无参 / /effort current / /effort status原行为是显示当前档位
// 现在拆分:完全无参 → 打开面板current/status → 仍显示文本
if (args === '') {
return <EffortPanelWrapper onDone={onDone} />
}
if (args === 'current' || args === 'status') {
return <ShowCurrentEffort onDone={onDone} />
}
const result = executeEffort(args)
return <ApplyEffortAndClose result={result} onDone={onDone} />
}
```
在文件底部追加 `EffortPanelWrapper`(桥接面板到 AppState 与 onDone
```tsx
function EffortPanelWrapper({
onDone,
}: {
onDone: (result: string) => void
}): React.ReactNode {
const effortValue = useAppState(s => s.effortValue)
return <EffortPanel appStateEffort={effortValue} onDone={onDone} />
}
```
注意:`EffortPanel` 内部已经自己读 model + env override + 写 AppState所以 wrapper 只是把 `effortValue` 透传。
- [ ] **Step 4.3: 类型 + lint 检查**
Run: `bunx tsc --noEmit && bunx biome check src/commands/effort/`
Expected: 0 errors
- [ ] **Step 4.4: 手动验证pipe mode 快速跑)**
Run:
```bash
echo "/effort" | bun run src/entrypoints/cli.tsx -p 2>&1 | head -30
```
Expected看到面板渲染输出标题 Effort、6 档、底栏提示。pipe 模式下键盘交互不能测,只验证渲染。
> 如果 pipe 模式不渲染面板(因为非交互式 TTY改成 `bun run dev` 手测。
- [ ] **Step 4.5: 跑相关测试**
Run:
```bash
bun test src/commands/effort/ 2>/dev/null
bun test tests/integration/message-pipeline* 2>/dev/null
```
Expected: 已有测试不破。
- [ ] **Step 4.6: Commit**
```bash
git add src/commands/effort/effort.tsx
git commit -m "$(cat <<'EOF'
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>
EOF
)"
```
---
## Task 5补集成测试键盘交互 + 分支)
**Files:**
- Modify/Create: `src/components/EffortPanel/__tests__/EffortPanel.test.tsx`(在 Task 3 基础上追加)
- [ ] **Step 5.1: 决定测试路径(二选一)**
Ink 组件键盘测试在项目里没有现成 helper已通过 Task 3.2 探查确认)。直接走 **Step 5.2 的纯函数抽取方案**——把确认/取消决策逻辑抽到 `effortPanelState.ts`,用纯函数测试覆盖分支。键盘 → handler 的连接由 `useKeybindings` 注册保证,**不**单独测(与 `ModelPicker` 测试策略一致)。
- [ ] **Step 5.2: 抽取确认/取消为可测纯函数(注入 applyFn 避免循环依赖)**
`handleConfirm`/`handleCancel` 的决策逻辑抽到 `effortPanelState.ts`**接受 `applyFn` 作为参数注入**,避免 `effortPanelState.ts``effort.tsx``EffortPanel.tsx``effortPanelState.ts` 的循环依赖,也避免测试触碰真实 settings。
`effortPanelState.ts` 末尾追加:
```ts
export type ConfirmOutcome =
| {
kind: 'apply'
message: string
effortUpdate?: { value: EffortValue | undefined }
}
| { kind: 'ultracode-hint'; message: string }
export type ApplyFn = (
cursor: PanelPosition,
) => { message: string; effortUpdate?: { value: EffortValue | undefined } }
export const ULTRACODE_HINT =
'ultracode 不是 effort 档位。请使用 /ultracode <context> 启动多 agent workflow。'
export const CANCEL_MESSAGE = 'Effort unchanged.'
export function computeConfirmOutcome(cursor: PanelPosition, applyFn: ApplyFn): ConfirmOutcome {
if (isUltracode(cursor)) {
return { kind: 'ultracode-hint', message: ULTRACODE_HINT }
}
const result = applyFn(cursor)
return {
kind: 'apply',
message: result.message,
effortUpdate: result.effortUpdate,
}
}
```
然后在 `EffortPanel.tsx` 里改用:
```tsx
// 顶部 import 新增
import {
type PanelPosition,
computeConfirmOutcome,
getInitialCursor,
isUltracode, // 不再需要computeConfirmOutcome 内部已用
moveLeft,
moveRight,
PANEL_POSITIONS,
} from './effortPanelState.js'
import { executeEffort } from '../../commands/effort/effort.js'
// handleConfirm 改为
const handleConfirm = React.useCallback(() => {
if (done) return
setDone(true)
const outcome = computeConfirmOutcome(cursor, executeEffort)
if (outcome.kind === 'apply' && outcome.effortUpdate) {
setAppState(prev => ({
...prev,
effortValue: outcome.effortUpdate!.value,
}))
}
onDone(outcome.message)
}, [cursor, done, onDone, setAppState])
// handleCancel 改为
const handleCancel = React.useCallback(() => {
if (done) return
setDone(true)
onDone(CANCEL_MESSAGE)
}, [done, onDone])
```
注意 import 里也加 `CANCEL_MESSAGE`
- [ ] **Step 5.3: 写分支测试(用注入版纯函数)**
`effortPanelState.test.ts` 末尾追加:
```ts
import {
CANCEL_MESSAGE,
computeConfirmOutcome,
ULTRACODE_HINT,
type ApplyFn,
} from '../effortPanelState.js'
describe('computeConfirmOutcome', () => {
const mockApply: ApplyFn = cursor => ({
message: `applied:${cursor}`,
effortUpdate: { value: cursor as any },
})
test('ultracode → kind=ultracode-hint含 /ultracode 引导', () => {
const out = computeConfirmOutcome('ultracode', mockApply)
expect(out.kind).toBe('ultracode-hint')
if (out.kind === 'ultracode-hint') {
expect(out.message).toBe(ULTRACODE_HINT)
expect(out.message).toContain('/ultracode')
}
})
test('low → kind=applymessage 来自 applyFneffortUpdate 透传', () => {
const out = computeConfirmOutcome('low', mockApply)
expect(out.kind).toBe('apply')
if (out.kind === 'apply') {
expect(out.message).toBe('applied:low')
expect(out.effortUpdate?.value).toBe('low')
}
})
test('high → apply 路径不调 ultracode 分支', () => {
const out = computeConfirmOutcome('high', mockApply)
expect(out.kind).toBe('apply')
})
})
test('常量字符串', () => {
expect(CANCEL_MESSAGE).toBe('Effort unchanged.')
expect(ULTRACODE_HINT).toContain('/ultracode <context>')
})
```
注意:因注入 mockApply**完全不需要 mock settings**——这是注入方案的最大红利。
- [ ] **Step 5.4: 跑测试**
Run: `bun test src/components/EffortPanel/__tests__/`
Expected: PASS
- [ ] **Step 5.5: Commit**
```bash
git add src/components/EffortPanel/
git commit -m "$(cat <<'EOF'
test(effort): 补 EffortPanel 分支测试ultracode 引导 / 取消文案 / apply 路径)
抽 computeConfirmOutcome 为纯函数便于测试,避开 Ink 键盘事件模拟。
Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
EOF
)"
```
---
## Task 6precheck 全量 + 验收
**Files:** 无修改
- [ ] **Step 6.1: 跑 precheck**
Run: `bun run precheck`
Expected: typecheck + lint fix + test 全绿,零错误
如有失败:按错误信息修,**不要**用 `as any``// biome-ignore` 绕过(除非确实是反编译代码遗留问题)。
- [ ] **Step 6.2: 手动验收**
Run: `bun run dev`
输入 `/effort`,确认:
- 面板出现,光标 `▲` 停在当前生效档
- `←` / `→` 移动光标到边界low / ultracode不再继续
- Enter 在 high 时输出 `Set effort level to high: ...`
- 把光标移到 ultracodeEnter → 输出引导文案
- Esc → 输出 `Effort unchanged.`
-`CLAUDE_CODE_EFFORT_LEVEL=high bun run dev`,再 `/effort` → 顶部黄色警告
- `/effort low``/effort auto``/effort current``/effort help` 仍按原行为工作
- [ ] **Step 6.3: 推送(可选,等用户决定)**
Run: `git log --oneline -10` 检查 commit 历史
Run: `git push` **仅在用户确认后**
---
## Self-Review 清单
实施完毕后,对照 spec 自检:
- [ ] §4 文件结构:`EffortPanel/``effortPanelState.ts`、测试文件都存在
- [ ] §5 交互:←/→/Home/End/Enter/Esc/q 全部实现;触发与初始光标正确
- [ ] §5 分支 A5 档 Enter 调 executeEffort
- [ ] §5 分支 Bultracode Enter 输出引导文案
- [ ] §5 取消:`Effort unchanged.`
- [ ] §6 视觉标题、Faster/Smarter、6 档、ultracode 副标签、底栏提示
- [ ] §6 双标记env override 时 cursor `▲` 与 active `(high) active` 同时显示(如未实现双标记,作为已知缺陷,第二阶段补)
- [ ] §6 模型不支持:禁用面板,仅 Esc 可退出(如未实现,第二阶段补,但 spec 写明要实现)
- [ ] §9 边界env override、模型不支持、settings 写入失败(沿用 executeEffort 现有错误路径)
- [ ] §10 测试:纯函数 + 组件 + 分支
- [ ] precheck 零错误
- [ ] 两阶段切分清晰:本计划只做基础,波纹动画第二阶段
---
## 已知首版可接受简化
为了控制首版范围,以下细节**允许暂时不完美**,第二阶段或后续 commit 再调:
1. `▲` 与档位文字的对齐(窄屏 / 不同终端宽度下可能错位)
2. 双标记 `(high) active` 的精确渲染(首版可只显示 cursor `▲`env override 顶部警告保证用户知情)
3. 模型不支持时的禁用态(首版可允许面板仍可操作,但顶部加提示)
4. 终端 < 60 cols 的垂直布局退化
5. 数字键 1-6 快速跳转spec 中标为可选增强,本计划不做)
这些不影响主功能,第一版以"能用、稳定、可提交"为目标。

View File

@@ -0,0 +1,492 @@
# Ripgrep System Fallback Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `getRipgrepConfig()` automatically fall back to system `rg` on `PATH` when the builtin/bundled ripgrep is missing (e.g. on Android/Termux), and surface the fallback via `/doctor` plus a one-time startup warning.
**Architecture:** Add an `existsSync` check on the builtin rg path before returning it. If missing, query `findExecutable('rg', [])`; if found, use system rg with a new human-readable `note` field on `RipgrepConfig` / `getRipgrepStatus()`. Consumers (`/doctor`, startup) read `note` and render it. No new modes — `mode` stays `'system' | 'builtin' | 'embedded'`; `note` carries the fallback narrative.
**Tech Stack:** TypeScript, Bun runtime, `bun:test`, Biome, lodash `memoize`.
**Spec:** `docs/superpowers/specs/2026-06-15-ripgrep-system-fallback-design.md`
---
## File Structure
- **Modify** `src/utils/ripgrep.ts` — extend `RipgrepConfig` type with `note?`; extend internal `ripgrepStatus` singleton with `note?`; extend `getRipgrepStatus()` return type with `note?`; rewrite the `builtin` branch of `getRipgrepConfig()` to add `existsSync` + system-rg fallback; sync `note` into the singleton inside `testRipgrepOnFirstUse`.
- **Create** `src/utils/__tests__/ripgrepConfig.test.ts` — five-branch decision coverage for `getRipgrepConfig()`.
- **Modify** `src/utils/doctorDiagnostic.ts` — propagate `note` from `getRipgrepStatus()` into the diagnostic object.
- **Modify** `src/screens/Doctor.tsx` — render `note` in the `Search:` line.
- **Modify** `src/entrypoints/init.ts` — emit a one-time stderr warning when `note` is set.
Each file has a single clear responsibility and changes stay inside that file's existing role.
---
## Task 1: Extend types with optional `note` field (no behavior change)
**Files:**
- Modify: `src/utils/ripgrep.ts:22-27` (type), `:29-63` (function — minimal shape only), `:523-527` (singleton), `:533-544` (public getter)
This task only adds the optional field everywhere it's needed and populates it with `undefined` for existing branches. Behavior stays identical. Task 2 fills in the real values.
- [ ] **Step 1: Extend `RipgrepConfig` type**
File: `src/utils/ripgrep.ts`, replace lines 22-27.
```ts
type RipgrepConfig = {
mode: 'system' | 'builtin' | 'embedded'
command: string
args: string[]
argv0?: string
note?: string
}
```
- [ ] **Step 2: Extend the `ripgrepStatus` singleton shape**
File: `src/utils/ripgrep.ts`, replace lines 522-527.
```ts
// Singleton to store ripgrep availability status
let ripgrepStatus: {
working: boolean
lastTested: number
config: RipgrepConfig
note?: string
} | null = null
```
- [ ] **Step 3: Extend `getRipgrepStatus()` return type**
File: `src/utils/ripgrep.ts`, replace lines 533-544.
```ts
/**
* Get ripgrep status and configuration info
* Returns current configuration immediately, with working status if available
*/
export function getRipgrepStatus(): {
mode: 'system' | 'builtin' | 'embedded'
path: string
working: boolean | null // null if not yet tested
note?: string
} {
const config = getRipgrepConfig()
return {
mode: config.mode,
path: config.command,
working: ripgrepStatus?.working ?? null,
note: ripgrepStatus?.note ?? config.note,
}
}
```
- [ ] **Step 4: Verify typecheck**
Run: `bunx tsc --noEmit`
Expected: 0 errors. (All `note` fields are optional; existing code is unaffected.)
- [ ] **Step 5: Commit**
```bash
git add src/utils/ripgrep.ts
git commit -m "refactor: add optional note field to RipgrepConfig and getRipgrepStatus"
```
---
## Task 2: Implement fallback decision in `getRipgrepConfig()` (TDD)
**Files:**
- Modify: `src/utils/ripgrep.ts:1-20` (imports), `:56-63` (builtin branch)
- Test: `src/utils/__tests__/ripgrepConfig.test.ts`
- [ ] **Step 1: Write the failing test file**
Create `src/utils/__tests__/ripgrepConfig.test.ts` with this exact content:
```ts
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
// Mock shared side-effect modules. log.ts pulls in bootstrap/state which has
// realpathSync side effects at import time. See project CLAUDE.md "Mock 使用规范".
mock.module('src/utils/log.ts', () => ({
logError: () => {},
logEvent: () => {},
}))
mock.module('src/utils/debug.ts', () => ({
logForDebugging: () => {},
}))
// Overridable fakes. Defaults match the "builtin exists" happy path on the
// runner's actual platform (no process.platform override — avoids polluting
// other tests in the same Bun process, see CLAUDE.md mock contamination note).
let fakeExistsSync = (): boolean => true
let fakeWhich: string | null = '/usr/local/bin/rg'
let fakeBundled = false
mock.module('node:fs', () => ({
existsSync: (p: string) => fakeExistsSync(p),
realpathSync: (p: string) => p,
constants: {},
}))
mock.module('src/utils/which.ts', () => ({
whichSync: () => fakeWhich,
}))
mock.module('src/utils/bundledMode.ts', () => ({
isInBundledMode: () => fakeBundled,
}))
mock.module('src/utils/envUtils.ts', () => ({
isEnvDefinedFalsy: (v: string | undefined) =>
v !== undefined &&
['0', 'false', 'no', 'off'].includes(v.toLowerCase().trim()),
isEnvTruthy: (v: string | undefined) =>
v !== undefined &&
['1', 'true', 'yes', 'on'].includes(v.toLowerCase().trim()),
}))
mock.module('src/utils/distRoot.ts', () => ({
distRoot: '/fake/dist',
}))
mock.module('os', () => ({
homedir: () => '/fake/home',
tmpdir: () => '/tmp',
}))
// Disable memoize so each call re-evaluates with current fakes.
mock.module('lodash-es/memoize.js', () => ({
default: <T extends (...args: any[]) => any>(fn: T): T => fn,
}))
const { getRipgrepConfig } = await import('../ripgrep.ts')
describe('getRipgrepConfig', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
fakeExistsSync = () => true
fakeWhich = '/usr/local/bin/rg'
fakeBundled = false
delete process.env.USE_BUILTIN_RIPGREP
})
afterEach(() => {
process.env = { ...originalEnv }
})
test('USE_BUILTIN_RIPGREP=0 with system rg -> mode=system, no note', () => {
process.env.USE_BUILTIN_RIPGREP = '0'
const cfg = getRipgrepConfig()
expect(cfg.mode).toBe('system')
expect(cfg.command).toBe('rg')
expect(cfg.note).toBeUndefined()
})
test('bundled mode -> mode=embedded, no note', () => {
fakeBundled = true
const cfg = getRipgrepConfig()
expect(cfg.mode).toBe('embedded')
expect(cfg.note).toBeUndefined()
})
test('builtin path exists -> mode=builtin, no note', () => {
fakeExistsSync = () => true
const cfg = getRipgrepConfig()
expect(cfg.mode).toBe('builtin')
expect(cfg.note).toBeUndefined()
})
test('builtin missing + system rg available -> mode=system, note set', () => {
fakeExistsSync = () => false
fakeWhich = '/usr/local/bin/rg'
const cfg = getRipgrepConfig()
expect(cfg.mode).toBe('system')
expect(cfg.command).toBe('rg')
expect(typeof cfg.note).toBe('string')
expect(cfg.note).toContain('fallback')
// Note contains process.platform verbatim — assert the substring shape,
// not a specific platform, so the test is portable.
expect(cfg.note).toMatch(/builtin rg unavailable on \w+, using system rg/)
})
test('builtin missing + system rg missing -> mode=builtin, note set', () => {
fakeExistsSync = () => false
fakeWhich = null
const cfg = getRipgrepConfig()
expect(cfg.mode).toBe('builtin')
expect(typeof cfg.note).toBe('string')
expect(cfg.note).toContain('no ripgrep available')
})
})
```
- [ ] **Step 2: Run test to verify it fails**
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
Expected: The fourth and fifth tests FAIL — currently `getRipgrepConfig()` returns `mode='builtin'` with no `note` when the builtin path is missing, instead of falling back to system rg.
- [ ] **Step 3: Add `existsSync` import to `ripgrep.ts`**
File: `src/utils/ripgrep.ts`, replace lines 1-2.
```ts
import type { ChildProcess, ExecFileException } from 'child_process'
import { execFile, spawn } from 'child_process'
import { existsSync } from 'fs'
```
- [ ] **Step 4: Rewrite the builtin branch with fallback logic**
File: `src/utils/ripgrep.ts`, replace lines 56-63.
```ts
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
const command =
process.platform === 'win32'
? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe')
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
// Builtin binary missing (e.g. Android/Termux, or incomplete install).
// Fall back to system rg on PATH. If neither is available, keep the
// (nonexistent) builtin path so upper layers still see ENOENT, but
// surface a human-readable note so /doctor and startup can explain.
if (!existsSync(command)) {
const { cmd: systemPath } = findExecutable('rg', [])
if (systemPath !== 'rg') {
return {
mode: 'system',
command: 'rg',
args: [],
note: `fallback: builtin rg unavailable on ${process.platform}, using system rg`,
}
}
return {
mode: 'builtin',
command,
args: [],
note: `no ripgrep available on ${process.platform}; install via apt/pkg/brew or set USE_BUILTIN_RIPGREP=0`,
}
}
return { mode: 'builtin', command, args: [] }
})
```
- [ ] **Step 5: Run test to verify it passes**
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
Expected: PASS (5/5).
- [ ] **Step 6: Run full precheck to ensure no regression**
Run: `bun run precheck`
Expected: 0 typecheck errors, 0 lint errors, all tests pass.
- [ ] **Step 7: Commit**
```bash
git add src/utils/ripgrep.ts src/utils/__tests__/ripgrepConfig.test.ts
git commit -m "feat: ripgrep falls back to system rg when builtin binary missing"
```
---
## Task 3: Sync `note` into the singleton inside `testRipgrepOnFirstUse`
**Files:**
- Modify: `src/utils/ripgrep.ts:549-615`
Currently `testRipgrepOnFirstUse` writes `ripgrepStatus = { working, lastTested, config }` without `note`. The new `getRipgrepStatus()` in Task 1 already falls back to `config.note` if the singleton has none, so this task is mostly belt-and-suspenders: persist the note explicitly so consumers reading the singleton directly also see it.
- [ ] **Step 1: Update the success-path assignment**
File: `src/utils/ripgrep.ts`, replace lines 592-596.
```ts
ripgrepStatus = {
working,
lastTested: Date.now(),
config,
note: config.note,
}
```
- [ ] **Step 2: Update the catch-path assignment**
File: `src/utils/ripgrep.ts`, replace lines 608-612.
```ts
ripgrepStatus = {
working: false,
lastTested: Date.now(),
config,
note: config.note,
}
```
- [ ] **Step 3: Run precheck**
Run: `bun run precheck`
Expected: 0 errors.
- [ ] **Step 4: Commit**
```bash
git add src/utils/ripgrep.ts
git commit -m "refactor: persist ripgrep config.note in testRipgrepOnFirstUse singleton"
```
---
## Task 4: Propagate `note` through `/doctor`
**Files:**
- Modify: `src/utils/doctorDiagnostic.ts:588-597`
- Modify: `src/screens/Doctor.tsx:224-232`
- [ ] **Step 1: Extend the diagnostic object**
File: `src/utils/doctorDiagnostic.ts`, replace lines 588-597.
```ts
// Get ripgrep status and configuration info
const ripgrepStatusRaw = getRipgrepStatus()
// Provide simple ripgrep status info
const ripgrepStatus = {
working: ripgrepStatusRaw.working ?? true, // Assume working if not yet tested
mode: ripgrepStatusRaw.mode,
systemPath:
ripgrepStatusRaw.mode === 'system' ? ripgrepStatusRaw.path : null,
note: ripgrepStatusRaw.note ?? null,
}
```
- [ ] **Step 2: Render `note` in Doctor.tsx**
File: `src/screens/Doctor.tsx`, replace lines 224-232.
```tsx
<Text>
Search: {diagnostic.ripgrepStatus.working ? 'OK' : 'Not working'} (
{diagnostic.ripgrepStatus.mode === 'embedded'
? 'bundled'
: diagnostic.ripgrepStatus.mode === 'builtin'
? 'vendor'
: diagnostic.ripgrepStatus.systemPath || 'system'}
)
</Text>
{diagnostic.ripgrepStatus.note && (
<Text color="warning">
Note: {diagnostic.ripgrepStatus.note}
</Text>
)}
```
- [ ] **Step 3: Run precheck (lint + typecheck)**
Run: `bun run precheck`
Expected: 0 errors.
- [ ] **Step 4: Manual smoke check (optional)**
Run: `bun run dev -- doctor 2>&1 | grep -i search`
Expected: prints the `Search:` line; on dev machine `note` should be empty so no `Note:` line appears.
- [ ] **Step 5: Commit**
```bash
git add src/utils/doctorDiagnostic.ts src/screens/Doctor.tsx
git commit -m "feat: /doctor shows ripgrep fallback note"
```
---
## Task 5: Emit one-time startup warning from `init.ts`
**Files:**
- Modify: `src/entrypoints/init.ts:240-243`
- [ ] **Step 1: Add the warning right before `profileCheckpoint('init_function_end')`**
File: `src/entrypoints/init.ts`, replace lines 240-243.
```ts
// Surface ripgrep fallback (e.g. Android/Termux) once per session.
// Goes to stderr so it doesn't corrupt pipe-mode (`-p`) stdout.
try {
const { getRipgrepStatus } = await import('../utils/ripgrep.js')
const status = getRipgrepStatus()
if (status.note) {
process.stderr.write(`[ripgrep] ${status.note}\n`)
}
} catch {
// Ripgrep status is best-effort; never block init.
}
logForDiagnosticsNoPII('info', 'init_completed', {
duration_ms: Date.now() - initStartTime,
})
profileCheckpoint('init_function_end')
```
- [ ] **Step 2: Run precheck**
Run: `bun run precheck`
Expected: 0 errors.
- [ ] **Step 3: Manual smoke check**
Simulate fallback by pointing vendor at a missing path is non-trivial; instead verify no warning fires on the dev machine (where builtin exists):
Run: `bun run dev -- --version`
Expected: `[ripgrep]` line does NOT appear on stderr.
- [ ] **Step 4: Commit**
```bash
git add src/entrypoints/init.ts
git commit -m "feat: warn on stderr when ripgrep falls back to system rg"
```
---
## Task 6: Final full precheck + verification
**Files:** None (verification only)
- [ ] **Step 1: Run full precheck**
Run: `bun run precheck`
Expected: `XXXX pass / 0 fail`, 0 typecheck errors, 0 lint errors.
- [ ] **Step 2: Verify the five-branch test still passes**
Run: `bun test src/utils/__tests__/ripgrepConfig.test.ts`
Expected: PASS (5/5).
- [ ] **Step 3: Verify decision logic via REPL sanity (optional)**
Run:
```bash
bun -e "import('./src/utils/ripgrep.ts').then(m => console.log(JSON.stringify(m.getRipgrepStatus(), null, 2)))"
```
Expected on macOS dev machine: `mode: "builtin"`, `note: undefined`.
---
## Self-Review Notes
**Spec coverage:**
- Decision chain with 5 branches → Task 2 ✓
- `note` field on `RipgrepConfig` / singleton / `getRipgrepStatus()` → Tasks 1, 3 ✓
- `/doctor` rendering → Task 4 ✓
- Startup warning → Task 5 ✓
- Tests for 5 branches → Task 2 Step 1 ✓
- Acceptance criteria 1-5 cross-checked against spec section "Acceptance Criteria"
**Placeholder scan:** None. Each step contains the exact code or command.
**Type consistency:** `note?: string` consistently used across `RipgrepConfig`, `ripgrepStatus` singleton, `getRipgrepStatus()` return, `doctorDiagnostic.ripgrepStatus.note`. In Doctor.tsx the diagnostic object's `note` is `string | null` (Task 4 Step 1 uses `?? null`), accessed with a truthy check (`{note && ...}`) which handles both `null` and `undefined`.
**Mock hygiene note:** Task 2's test mocks `node:fs`, `src/utils/which.ts`, `src/utils/bundledMode.ts`, `src/utils/envUtils.ts`, `src/utils/distRoot.ts`, `os`, and `lodash-es/memoize.js`. These are process-global mocks (Bun limitation — see project CLAUDE.md "Mock 使用规范"). The test file lives at `src/utils/__tests__/ripgrepConfig.test.ts` and there is no existing `ripgrep.test.ts` to collide with, so no contamination risk.

View File

@@ -0,0 +1,159 @@
# Commit 审查报告0768d4dc8f69023b55adf2f5c176c766640600cb
- **Commit**: `0768d4dc8f69023b55adf2f5c176c766640600cb`
- **Title**: `feat(workflow): add workflow engine, /workflows panel, /ultracode skill`
- **Author**: claude-code-best <claude-code-best@proton.me>
- **Date**: 2026-06-13
- **规模**: 90 文件,+12925 / -833
- **审查日期**: 2026-06-13
- **审查方法**: 多视角对抗式 workflow 编排7 个并行 reviewer → consolidator 合并 → refuter 反驳 → final judgejournal `run_id = wtujwahzf`
---
## TL;DR
这个 commit 引入的 workflow engine **架构干净、引擎层测试覆盖率高**,但**脚本沙箱和路径校验存在真实漏洞**,并且在本次审查过程中**我亲身实证发现了多个 judge report 没覆盖的 host 集成 bug**(其中包括 workflow 状态变更通知根本没有接进 host 通知系统,导致"完成时自动通知"承诺落空)。受信 LLM 威胁模型下无严格 blocker但建议合并前修 4 项。
**严重度计数**(综合 judge + 我的实证):
- CRITICAL: 0
- HIGH: 2
- MEDIUM: 9
- LOW: 4
- INFO: 6
---
## 审查方法
用 commit 自身引入的 workflow engine 跑了一个对抗式审查 workflow
1. **Phase 1 — MultiPerspectiveScan**: 7 个并行 reviewerarchitecture / runtime / types / test-quality / integration / security / removal-docs用 Explore agentType独立扫各自维度
2. **Phase 2 — Consolidation**: opus consolidator 合并去重,按主题归类
3. **Phase 3 — AdversarialRefutation**: general-purpose refuter 对每个 CRITICAL/HIGH 用新证据反驳
4. **Phase 4 — FinalReport**: opus judge 综合输出最终报告
journal 完整 10 条 agent 记录在 `.claude/workflow-runs/wtujwahzf/journal.jsonl`
**审查过程中实证发现的额外 bug**judge 没覆盖,因为我正好用这个引擎跑审查才暴露):见下一节。
---
## 我实证发现的 bugjudge report 之外)
这些是跑审查过程中亲身踩到的judge 的 7 个 reviewer 没看到,因为这些 bug 涉及 host 集成层(`src/workflow/*``src/tasks/LocalWorkflowTask/*`)和实际工具调用语义,需要"真正用一次"才能暴露。
### [HIGH] `args` schema 回归:旧 `z.string()` → 新 `z.unknown()`prompt 未同步
- **文件**: `packages/workflow-engine/src/tool/schema.ts:14-19``packages/workflow-engine/src/tool/WorkflowTool.ts:38-49, 114`
- **现象**: 调用 Workflow 工具传 `args: {"commit": "..."}`,脚本里 `args.commit === undefined`。子 agent 端到端复现:当 args 是 object 时全链路 OK是 string 时丢字段。
- **根因**: 旧 `packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts`(本 commit 删除)的 schema 是 `args: z.string().optional()`,模型按旧契约发字符串。本 commit 改成 `z.unknown().optional()` 但 prompt 没强约束"必须传对象",模型继续按旧契约发字符串 → 运行时 `args` 是 string → 脚本里 `args.commit` 拿不到。
- **影响**: 任何依赖 `args` 透传的命名 workflow 都会拿到 undefined 字段,直接 throw 或 silently 拿不到参数。我不得不在脚本里把 commit hash 写死绕过。
- **修复方向**:
- `WorkflowTool.call` 加防御:`if (typeof input.args === 'string') input.args = JSON.parse(input.args)`
- 或 schema 用 `z.preprocess((v) => typeof v === 'string' ? JSON.parse(v) : v, z.unknown())`
- 同步 prompt明确"args 必须是 JSON 对象,禁止传字符串化的 JSON"
### [HIGH] Workflow 状态变更通知未接入 host 通知系统
- **文件**: `packages/workflow-engine/src/tool/WorkflowTool.ts:127-140``src/workflow/ports.ts:84-135``src/workflow/wiring.ts`
- **现象**: WorkflowTool 的工具返回文本承诺"完成时会自动通知。用 /workflows 查看实时进度。",但本次审查中:
- smoke test (`w17jmnsq3`) 完成时,我没收到任何 task-notification
- review-commit (`wtujwahzf`) 完成时,我没收到任何 task-notification是用户手动告诉我"结束了"我才知道
- 失败的 review-commit (`wpv9nu2eo``w2tvwj0ka`) 也没收到失败通知
- 同期启动的 Agent 工具(非 workflow完成时**有**收到 `<task-notification>`
- **根因**: 引擎确实通过 `ports.progressEmitter.emit({ type: 'run_done', ... })` 发了事件,`taskRegistrar.complete/fail/kill` 也被调了,但**没有任何代码把这些事件桥接到 host 的通知机制**AgentTool 完成时通过 `runAgent.ts` 的 finally 触发 task-notification。Workflow tool detached 执行后host 没有订阅 taskRegistrar 的状态变更。
- **影响**: 任何 workflow特别是耗时长的跑完用户都不知道用户必须主动 `/workflows` 查看workflow 失败时用户完全感知不到。这直接违背了 commit message 和 prompt 中"完成时会自动通知"的承诺。
- **修复方向**:
-`src/workflow/wiring.ts`(或 host bundle 构造处)订阅 `WorkflowService.subscribe`,对 `status``running``completed/failed/killed` 的转换发 host 通知
- 或在 `WorkflowTool.ts:124``.then(result => onFinish(...))` 内,根据 result.status 触发 host notification参考 `runAgent.ts` 的 task-notification 路径)
### [MEDIUM] `failWorkflowTask` 丢弃 error message
- **文件**: `src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts:96-107`
- **现象**: workflow 失败时 progress store 的 `RunProgress.error` 字段在 `/workflows` 面板能看到(`WorkflowDetail.tsx:63-67` 渲染 `run.error`),但 `BackgroundTasksDialog` 用的 `LocalWorkflowTask` 状态对象没有 error 字段——`failWorkflowTask(taskId, setAppState)` 完全丢弃 error。两套状态系统不一致。
- **影响**: 用户在 `BackgroundTasksDialog` 看到 workflow 标记为 failed但不知道为什么 failed必须切到 `/workflows` panel 才能看到 error 文字。
- **修复方向**: `failWorkflowTask` 签名加 `error?: string` 参数,存入 `LocalWorkflowTaskState`,并在 `BackgroundTasksDialog` 渲染。
### [LOW] WorkflowTool 的 run_id 提示与实际 run 目录解析路径不一致
- **文件**: `src/workflow/ports.ts:69``packages/workflow-engine/src/tool/WorkflowTool.ts:121`
- **现象**: `WorkflowTool.ts:121``cwd: host.cwd` 来自 `getCwd()`(运行时 cwd可能在 worktree 切换时变化);而 `ports.ts:69``runsDir = ${getProjectRoot()}/.claude/workflow-runs` 用的是 session 启动时的 project root。两者在某些路径下不一致如 mid-session `EnterWorktreeTool`)。
- **影响**: 命名 workflow 文件解析(用 cwd和 journal 持久化路径(用 projectRoot可能落到不同目录调试时混乱。
- **修复方向**: 统一用 `getProjectRoot()`,或在文档里明确两者的语义差异。
---
## Judge 报告核心 finding
### HIGH脚本沙箱可被动态 `import()` 绕过
- **文件**: `packages/workflow-engine/src/engine/script.ts:166-221`
- **问题**: `assertScriptBody` 只屏蔽**静态** `import` 语句regex `/^\s*import\b/m`),但 `new AsyncFunction()` 体内可 `await import('node:child_process')`、可直接访问 `process.env` / `Buffer` / `globalThis`。Node 和 Bun 实测都能逃逸。
- **降级理由**: LLM 本就有 `BashTool``src/constants/tools.ts:139`),沙箱逃逸不扩大能力;但破坏了 resume 的确定性假设 + 未来若引入半信任脚本源会致命。
- **修复**: `import(` 加进 regex 黑名单 + 文档明确"沙箱保确定性,不保安全"。
### MEDIUM7 项,按价值排序)
1. **`scriptPath` 任意文件读,无路径校验** — `WorkflowTool.ts:184-188``service.ts:104-109``input.scriptPath` 来自 LLM无 containment check可读 `/etc/passwd``~/.ssh/id_rsa``FileReadTool` 已有此能力,但 `scriptPath` 绕过权限提示。
2. **命名 workflow 路径遍历**`namedWorkflows.ts:18-19``name` 参数未过滤 `../``name = "../../etc/passwd"` 可逃出 `workflowDir`(虽然 `.ts/.js/.mjs` 扩展名限制缓解了利用)。
3. **Budget 检查竞态**`hooks.ts:53, 95-106``assertCanSpend()` 在 semaphore 之前N 个并发都能过检 → 实测 4 并发 100 token budget 实花 200100% 超支)。默认 `budget = null` 时不触发,显式设 budget 才暴露。
4. **`parallel`/`pipeline` 静默吞错** — `hooks.ts:126-134, 148-160``catch {}` 完全无日志workflow 作者无法知道 agent 为何失败。"null on error"契约本身是对的,但应该 log。
5. **双重类型断言掩盖 schema/type 漂移**`WorkflowTool.ts:56``workflowInputSchema as unknown as z.ZodType<WorkflowInput>`,应该 `export type WorkflowInput = z.infer<typeof workflowInputSchema>`
6. **Service 层测试 mock adapter 永远返回 ok**`service.test.ts:39-68``fakePorts()` 永远返回 `{kind: 'ok', output: 'mock-out'}`service 层的失败路由(`service.ts:164-173`)未测。
7. **Journal 并发写入顺序非确定**`hooks.ts:111-113``push` + `index++` 同步原子,但 `await append()` 落盘顺序是完成顺序而非调用顺序。resume 时若并发完成顺序不同key 不匹配 → journal 失效 → 全重跑。**对 parallel workflow 来说 resume 几乎无效**。
### LOW / INFO
- LOW: Semaphore permit 在 abort 时延迟释放queued waiter 阻塞至 permit 到来)
- LOW: `WorkflowsPanel.tsx:40-45``useSyncExternalStore` 无 error boundary
- LOW: WorkflowService singleton 无 shutdown 清理
- INFO: `AgentRunParams.schema``object` 而非 `Record<string, unknown>`
- INFO: `WorkflowInputSchema` 类型未从 package index 导出
- INFO: 旧 `builtin-tools/WorkflowTool` 删除干净,无残留 import
- INFO: workflow-engine 包零 host 依赖(只 ajv + zod
- INFO: HostHandle 用 Symbol-based opacity 是合理的 seam
### 被反驳的发现refuter 用新证据推翻)
- ~~**CRITICAL**: 并发 journal 索引腐蚀~~ — 误判 JS 单线程执行模型。`push``index++` 之间无 `await`,不可被抢占。
- ~~**HIGH**: 键盘 stale reference 竞态~~ — 误判 `useEventCallback` 语义。`usehooks-ts` 的 ref 在 layout phase 同步更新,键盘 handler 总能拿到最新 `focused`
- ~~**HIGH**: sub-agent 默认 `acceptEdits` 权限~~ — 全代码库约定(`resumeAgent.ts:161` 同样写法),非 workflow 特有漏洞。
---
## 做得好的地方
1. **架构干净**workflow-engine 包零 host 依赖(只 ajv + zod教科书级 hexagonal。所有 host 交互通过注入的 `Ports` / `HostHandle`
2. **Journal 离散检测健壮**`hooks.ts:65-81` 的 key mismatch → 优雅降级到全重跑,不会产生错误结果。
3. **Budget API 设计良好**`Budget` 类的 `assertCanSpend` / `addOutputTokens` / `remaining` API 表面正确(虽然实现有竞态),后续加 reservation 机制容易。
4. **Engine 层测试覆盖扎实**`hooks.test.ts` 覆盖 dead / skipped / budget exhaust / abort / adapter 错误 / parallel-pipeline error suppression这是 engine 层该有的覆盖深度。
5. **旧代码删除干净**commit 正确删除 `builtin-tools/WorkflowTool`,保留 `bundled/` 作为扩展点,更新 `biome.json` 排除项匹配新架构,无残留 import。
6. **设计文档完备**`docs/features/workflow-scripts.md``docs/superpowers/specs/2026-06-12-workflow-engine-design.md``docs/superpowers/plans/2026-06-12-workflow-engine.md` 配套齐全。
---
## 推荐 merge 前修复(按优先级)
1. **[HIGH] Workflow 状态变更通知接入 host** — 在 `src/workflow/wiring.ts` 订阅 `WorkflowService.subscribe`,对 status 转换发 host notification这是 commit message 和 prompt 已承诺但未实现的功能。
2. **[HIGH] `args` schema 防御性 parse** — `WorkflowTool.call``if (typeof input.args === 'string') JSON.parse(...)` + 同步 prompt。
3. **[HIGH] 脚本沙箱黑名单加 `import(`** — `script.ts:166` 一行修复 + 文档明确"沙箱保确定性不保安全"。
4. **[MEDIUM] `scriptPath` / `name` 路径校验** — containment check拒绝 `../`、绝对路径越界。
5. **[MEDIUM] `failWorkflowTask` 保存 error** — 签名加 error 参数,存入 task state与 progress store 对齐。
6. **[MEDIUM] `assertCanSpend()` 挪到 semaphore critical section 内** — 关闭 budget 超支竞态。
7. **[MEDIUM] service.test.ts 加 dead/skipped 路由测试** — 关闭 service 层失败路由覆盖盲区。
8. **[MEDIUM] `WorkflowInput = z.infer<typeof workflowInputSchema>`** — 消除双重断言,防 schema/type 漂移。
前 5 项都是几行到几十行的小改动,建议合并前完成。第 6-8 项可以 follow-up。
---
## 审查过程的元观察dogfooding 发现)
用 commit 自身引入的 workflow engine 跑这个审查,等于把引擎当 dogfood。除了上述具体 bug还有一些元观察
- **"完成时自动通知"承诺落空**是最影响用户体验的一条——workflow 跑完了用户不知道,跑挂了用户也不知道,必须主动 `/workflows`。这违背了工具描述里写的契约。
- **journal 落盘路径与命名 workflow 解析路径用了不同根**`getProjectRoot()` vs `getCwd()`),调试时容易找不到 journal 文件。
- **smoke test 能跑通、review-commit 不能跑通**——区别在于 review-commit 读 `args.commit`,这暴露了 schema 回归。说明现有测试覆盖(即使是 99.65% 的引擎覆盖率)无法替代真实使用场景的 dogfooding。
- **refuter 反驳掉 2 个 CRITICAL/HIGH** 是对抗式审查的价值证明:单 reviewer 视角会基于错误假设JS 并发模型、React ref 语义)报假阳性,多一层反驳能纠偏。
完整 journal10 条 agent 输出):`.claude/workflow-runs/wtujwahzf/journal.jsonl`

View File

@@ -0,0 +1,231 @@
# Workflow Engine — 重建设计
- 日期2026-06-12
- 状态:已通过 brainstorming待 writing-plans
- 范围:把被掏空的「清单推进」版 WorkflowTool 重建为**完整忠实的确定性 JS 脚本编排引擎**,并**独立成包**,解除与核心层的深度依赖。
## 1. 背景与现状
当前 `packages/builtin-tools/src/tools/WorkflowTool/WorkflowTool.ts` 是个被阉割的版本:把 `.claude/workflows/` 里的 `.md`/`.yaml` 解析成清单,靠模型手动调用 `advance` 推进,**没有任何子 agent 编排能力**。
真正的 Workflow 能力是一个**确定性 JS 脚本编排引擎**:后台执行脚本,提供 `agent()`/`parallel()`/`pipeline()`/`phase()`/`log()` 钩子,真正 spawn 子 agent支持 schema 校验、并发上限、journaling/resume、token budget、进度流。
### 可复用的现有基础设施
- `src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts`完整的后台任务生命周期register/complete/fail/kill/skip/retry/orphan 清理)。**完好,复用**。
- `packages/builtin-tools/src/tools/AgentTool/runAgent.ts`:子 agent 执行核心async generator接收 `agentDefinition`+`promptMessages`+`toolUseContext`+`canUseTool`,运行完整 query 循环)。**作为 `agent()` 钩子后端**。
- `assembleToolPool``src/tools.ts`):构建子 agent 工具池。
- `finalizeAgentTool` / `extractTextContent``agentToolUtils.ts`):抽取 agent 最终消息 + usage。
- `WorkflowPermissionRequest.tsx`:权限 UI核心侧 React复用
- `tools.ts` 已用 `WORKFLOW_SCRIPTS` feature flag 接好注册位;`constants/tools.ts``CORE_TOOLS` 在 flag 开启时含 `workflow`
## 2. 关键决策brainstorming 结论)
1. **范围**:完整忠实引擎——全部钩子 + schema 结构化输出 + 并发上限16/1000/4096+ journaling/resume + token budget + worktree 隔离 + named-workflow 加载 + 进度流到 `/workflows`
2. **包边界****严格端口适配(依赖倒置)**。`packages/workflow-engine/``src/*` / `builtin-tools` 运行时导入;只声明端口接口;核心侧提供一个 adapter 模块实现这些接口;`tools.ts` 装配时注入。
3. **文件模型**`.claude/workflows/<name>.ts|.js|.mjs` 脚本文件 → 命名 workflow`Workflow` 工具 `name` 参数解析到它)+ 生成 `/<name>` 斜杠命令;`/workflows` 变为实时进度查看器。**删除** 现有 `.md`/`.yaml` 清单逻辑。
4. **执行路径****async 函数包装 + 信号量 + 注入端口**(方案 A。进程内 async 模型,与 `runAgent` 的 async generator 天然契合,端口可 mock 测试。不用 `vm` 沙箱或 worker 进程。
## 3. 架构与依赖方向
```
┌─────────────────────────────────────────────────────────────┐
│ packages/workflow-engine/ ← 新包,零 src/* 运行时导入 │
│ 声明端口(接口),持有引擎/钩子/并发/journal/budget/schema │
│ + 自包含的 WorkflowTool 描述符zod schema/desc/prompt
└──────────────▲──────────────────────────▲───────────────────┘
│ 实现implements │ 注入DI
┌──────────────┴──────────────────────────┴───────────────────┐
│ src/workflow/ ← 核心侧薄层 │
│ adapter.ts: 用 runAgent/assembleToolPool/LocalWorkflowTask │
│ /AppState 实现端口 │
│ wiring.ts: createWorkflowTool(adapter) → 适配为 Tool │
│ 注册到 tools.tsWORKFLOW_SCRIPTS flag 之后) │
└─────────────────────────────────────────────────────────────┘
```
包**不认识** `buildTool` / `toolUseContext` / `runAgent` / `Message` 类型。仅通过端口接口与不透明 host 句柄对话。
### 端口契约(包内 `ports.ts`
| 端口 | 职责 | 核心侧 adapter 实现 |
|---|---|---|
| `AgentRunner` | `agent()` 后端:`runAgentToResult(params, hostHandle) → AgentRunResult` | 委托 `runAgent` + `assembleToolPool`schema 时注入 StructuredOutput 工具;`finalizeAgentTool` 抽取最终消息 + usage |
| `ProgressEmitter` | `emit(event)` 推进度事件 | 写 `LocalWorkflowTaskState.progress` + `rootSetAppState` |
| `TaskRegistrar` | 后台任务生命周期 + 读 `pendingAgentAction` | 复用 `LocalWorkflowTask` API |
| `JournalStore` | journal 读写(按 runId | 文件 fs`.claude/workflow-runs/<runId>/journal.jsonl`),走端口便于 mock |
| `PermissionGate` | `agent()` 前置权限/取消检查 | abort signal + `pendingAgentAction` |
| `Logger` | 调试日志 + 遥测 | `logForDebugging` / `logEvent` |
**不透明 host 句柄**`HostHandle = { readonly __workflowHost: unique symbol }`。核心侧每次工具调用构造一个句柄(内含 `toolUseContext`/`canUseTool`/`agentId` 等),包内绝不检视,只透传给 `AgentRunner`adapter 把它 cast 回核心上下文。包对核心类型零依赖的唯一缝隙,且是不透明的。
### 包结构
```
packages/workflow-engine/
package.json @claude-code-best/workflow-engine (workspace:*)
tsconfig.json
src/
index.ts 公共导出
ports.ts 端口接口 + HostHandle
types.ts 纯类型WorkflowInput/Run/JournalEntry/ProgressEvent/AgentRunParams…
tool/
WorkflowTool.ts createWorkflowTool(ports) → 自包含描述符
schema.ts 输入 schemascript/name/scriptPath/args/resumeFromRunId/desc/title
constants.ts WORKFLOW_TOOL_NAME 等
engine/
runWorkflow.ts 引擎入口:校验/包装/执行/journal/resume
context.ts 执行上下文(端口/信号量/budget/journal/计数器/host
hooks.ts agent/parallel/pipeline/phase/log/workflow 实现
script.ts meta 字面量提取 + async 包装 + 沙箱 shim
concurrency.ts Semaphore + 上限16 / 1000 总 / 4096 每次调用)
journal.ts hash + 读/写 journal
budget.ts budget 累加器total/spent/remaining
structuredOutput.ts JSON Schema → 结果校验(纯函数)
namedWorkflows.ts name → .claude/workflows/<name>.ts|js|mjs 解析(仅 fs
constants.ts 目录/上限常量
progress/events.ts ProgressEvent 类型 + emit 委托
__tests__/ …
```
核心侧薄层:`src/workflow/adapter.ts` + `src/workflow/wiring.ts``packages/builtin-tools` 从新包 re-export 描述符。
## 4. 引擎内部
### 4.1 钩子语义
| 钩子 | 语义 | 失败行为 |
|---|---|---|
| `agent(prompt, opts?)` | 取信号量 → 查 journal命中即返回缓存→ 调 `AgentRunner` → 写 journal → 返回 | 终态 API 错耗尽重试 → `null`(不抛) |
| `parallel(thunks)` | **屏障**`Promise.all` 所有 thunk每个内部各自过信号量wall-clock = 最慢项 | 单项抛错/agent 错 → 该项 `null`;调用本身永不 reject |
| `pipeline(items, …stages)` | **无屏障**:每项跑 `stage1→stage2→…` 异步链多链并发stage 回调收 `(prevResult, originalItem, index)` | 某 stage 抛错 → 该项 `null`、跳过后续 stage |
| `phase(title)` | 开启新阶段,后续 agent/log 归入该组直到下次 `phase()` | — |
| `log(message)` | 向用户发一行旁白进度 | — |
| `workflow(nameOrRef, args?)` | 内联跑子 workflow返回其返回值共享并发/计数/budget`/workflows` 显示为 `▸ name` 组 | 子 workflow 内再嵌套 → 抛错(仅一层) |
`agent``opts``label``phase`(显式分组)、`schema`JSON Schema`model``isolation:'worktree'``agentType`(自定义子 agent 类型)、`allowedTools`
- 无 schema 返回 `string`;有 schema 返回校验对象;用户 skip / agent 终态死亡 → 返回 `null`
### 4.2 并发与上限(`concurrency.ts`
- `Semaphore` 许可数 = `min(16, cpuCores - 2)``agent()` 取 1。
- 单个 workflow 生命周期**总 agent 数 ≤ 1000** → 超出抛错。
- 单次 `parallel`/`pipeline` 调用 **items ≤ 4096** → 超出抛错(显式错误,不静默截断)。
### 4.3 Journal / Resume`journal.ts`
- journal = 按**执行顺序**的 `{ key, result }` 列表,存 `.claude/workflow-runs/<runId>/journal.jsonl`
- `key` = `hash(prompt + canonical(opts 去掉 label/phase 等纯展示字段))`
- 命中:`agent()` 先算 key与 journal 下一项 key 比对 → **匹配则返回缓存并前进**,不匹配则丢弃后续 journal、现场重跑。
- 因 JS 去掉 `Date.now`/`random` 后确定,执行顺序确定 → 自然得到「最长未变前缀命中、首个发散点之后全重跑」。
- `resumeFromRunId`:载入该 run 的 journal 重放。脚本源码 hash 一致 → 100% 命中;脚本改动 → 全重跑。脚本 hash 存入 run 记录。
### 4.4 Budget`budget.ts`
- `budget.total`:来自用户 `+500k` 式 turn 级 token 指令,由 **host/turn 上下文注入**adapter 从 turn 的 token 指令读取,经 `HostHandle` 传入),**不是** 工具 input 参数。无指令则 `null`
- `budget.spent()`:本 turn 所有 agent 输出 token 之和(`AgentRunResult.usage`adapter 从 subagent usage 填)。
- `budget.remaining()``max(0, total - spent)`,无 total 则 `Infinity`
- **硬上限**`spent()``total` 后,`agent()` 抛错。预算是主循环与 workflow 共享池。
### 4.7 AgentRunResult 类型(`types.ts`
`AgentRunner.runAgentToResult` 的返回,包内明确定义为联合类型:
```ts
type AgentRunResult =
| { kind: 'ok'; output: string | object; usage: { outputTokens: number } }
| { kind: 'skipped' } // 用户 skip → agent() 返回 null
| { kind: 'dead' } // 终态 API 错耗尽重试 → agent() 返回 null
```
`output``string`(无 schema或已校验对象有 schema`agent()` 据此映射:`ok`→返回 output`skipped`/`dead`→返回 `null`
### 4.5 脚本包装与沙箱(`script.ts`
1. 提取 `export const meta = { … }`——**必须是纯字面量**(无变量/插值/展开),解析为对象;缺失或非字面量 → 抛错。
2. 剥离 `export const meta` 语句。
3. 剩余 body含顶层 `return`)包进 `async function(agent, parallel, pipeline, phase, log, workflow, args, budget, Date, Math){ <body> }`
4. 以**抛异常的 shim** 传入 `Date``now()`/无参 `new Date()` 抛)、`Math``random()` 抛)——靠函数参数 shadow 全局,使裸 `Date.now()` 命中 shim。这是确定性保障非密码学级沙箱与真实引擎意图一致阻断 resume 破坏性的非确定性)。
5. meta 的 `phases` 可用于进度预声明(可选)。
### 4.6 进度事件(`progress/events.ts`
`ProgressEmitter.emit(event)` 类型:`run_started``phase_started/done``agent_started/done{label,phase,result摘要}``log``run_done{returnValue/status}`。adapter 写入 task 进度结构 + AppState`/workflows` 视图消费。
## 5. 错误处理
| 场景 | 行为 |
|---|---|
| 脚本无 `meta` / `meta` 非字面量 / 语法错 | 引擎抛错 → task `failed` → 通知带错误信息 |
| `Date.now`/`Math.random`/`new Date()` | shim 抛 → 冒泡为脚本错误 → task failed |
| `agent()` 终态 API 错(重试耗尽) | 返回 `null`**不杀** workflow |
| `parallel`/`pipeline` 单项抛错 | 该项 `null`workflow 继续 |
| budget 耗尽 | `agent()` 抛错(脚本可 try/catch |
| 并发/1000/4096 上限 | 抛错 |
| killabort | signal 传播;`agent()` 检查 signalworkflow 停task `killed`;通知 partial |
| 工具调用层(`call`)脚本非法 | 直接返回错误给模型(不进后台) |
## 6. 测试策略
包内全量单测,**无需真实 LLM**mock 端口——解耦的核心收益):
- `engine.test.ts`mock `AgentRunner`(按 prompt 返回预设结果)端到端跑脚本,断言返回值 + 进度事件序列。
- `hooks.test.ts`parallel 单项错→null、pipeline 无屏障顺序、agent schema 校验、skip/dead→null。
- `concurrency.test.ts`信号量限并发、1000/4096 上限抛错。
- `journal.test.ts`hash 稳定、resume 命中前缀、脚本变更全重跑、中途发散重跑尾部。
- `budget.test.ts`spent 累加、触顶抛错。
- `script.test.ts`meta 字面量提取、非字面量/语法错、shim 抛。
- `structuredOutput.test.ts``namedWorkflows.test.ts`
核心侧最小冒烟adapter 用 `runAgent` 真接线的重 mock 测试wiring 注册测试。重量级逻辑都在包内。可选:`tests/integration/` 加一个 workflow tool-chain 集成测试feature-gated
## 7. 核心侧实现
### 7.1 adapter`src/workflow/adapter.ts`
`createWorkflowAdapter()` 返回端口实现:
- **AgentRunner.runAgentToResult(params, hostHandle)**cast 句柄→`{toolUseContext, canUseTool, assistantMessage}`;按 `params.agentType` 从 registry 解析 agentDefinition缺省=通用 workflow 子 agent`assembleToolPool`;有 schema→注入 StructuredOutput 工具+系统指令;调 `runAgent` 收消息→`finalizeAgentTool` 抽 text+usageschema→解析校验返回对象处理 `pendingAgentAction`(skip)→`null`、终态死亡→`null`;返回 `{kind:'ok', text/object, usage}`
- **ProgressEmitter**:写 `LocalWorkflowTaskState.progress` + `rootSetAppState`
- **TaskRegistrar**:复用现有 `registerLocalWorkflowTask/complete/fail/kill` + 读 `pendingAgentAction`
- **JournalStore / Logger / PermissionGate**fs / `logForDebugging`+`logEvent` / abort+pendingAction。
### 7.2 wiring`src/workflow/wiring.ts`
- `createWorkflowTool()`:建 adapter → 调包的 `createWorkflowTool(adapter)` 得描述符 → 包成 `buildTool` 兼容 `Tool` 返回。
- `tools.ts``const WorkflowTool = feature('WORKFLOW_SCRIPTS') ? require('./workflow/wiring.js').createWorkflowTool() : null`(替换现有清单版)。
`call` 流程校验脚本inline/file/named 解析)→ meta 校验失败直接返错给模型 → 持久化脚本 + 算 hash → resume 则载入 run+journal → 注册后台 task → **立即返回 `{runId, scriptPath}`** → 脱离执行引擎、流进度 → 完成时 complete + 通知(返回值/错误)。
## 8. 现有文件迁移
| 文件 | 处理 |
|---|---|
| `builtin-tools/.../WorkflowTool/WorkflowTool.ts`(清单版) | 删除,逻辑移入新包 |
| `constants.ts`WORKFLOW_TOOL_NAME | 移入包 `tool/constants.ts`core 侧 re-export |
| `WorkflowPermissionRequest.tsx`React UI | 移到 `src/workflow/`(依赖 src 权限组件,属核心侧) |
| `createWorkflowCommand.ts`.md/.yaml 扫描) | 改为扫 `.ts/.js/.mjs` → 生成 `/<name>` 命令,调用时以脚本启动引擎 |
| `bundled/index.ts`no-op | 保留为包的 bundled-workflow 扩展点 |
| `src/utils/workflowRuns.ts`(清单记录) | 重写为 run+journal 模型(或并入包 JournalStore |
| `src/commands/workflows/index.ts` | 改为**实时进度查看器**,复用 `WorkflowDetailDialog.tsx` |
| `src/tasks.ts` LocalWorkflowTask 门控 | 保持不变 |
| `constants/tools.ts` CORE_TOOLS 含 `workflow` | 保持 |
## 9. 工作分解writing-plans 将细化)
1. 新建包 `packages/workflow-engine/`package.json/tsconfig/类型/端口/常量)。
2. 引擎核心script 包装、concurrency、journal、budget、structuredOutput、namedWorkflows。
3. 钩子实现 + runWorkflow 编排 + 进度事件。
4. 自包含工具描述符schema/desc/prompt/result 映射)。
5. 包内全量单测。
6. 核心侧 adapter + wiring + 句柄构造。
7. 迁移现有文件、改 `/workflows` 为进度查看器、改 named-workflow 命令。
8. `bun run precheck` 零错误;手动 dev 冒烟。
## 10. 非目标 / 风险
- **非密码学沙箱**:函数参数 shadow 全局 `Date`/`Math``globalThis.Date` 仍可达。可接受——目标是阻断 resume 破坏性的非确定性,不是隔离恶意代码。若未来需强隔离再上 `vm`/worker方案 B/C
- **resume 正确性依赖确定性执行**:用户脚本若绕过 shim 用 `globalThis.Date` 制造非确定性resume 可能命中错缓存。属可接受的边界,文档提示。
- **预算共享语义**`budget.spent()` 与主循环的 token 计数共享,需 adapter 正确上报 subagent usage若 provider 不报 usage 则 budget 降级为 `Infinity`
- **StructuredOutput 工具**:核心侧需存在/实现一个按 JSON Schema 强制结构化输出的子 agent 工具(注入 + 解析。若当前无现成实现wiring 阶段补一个最小版本。

View File

@@ -0,0 +1,200 @@
# `/workflows` 面板重设计:顶 tab + 左 phase 侧栏 + 右 agent 列表
> 状态:草案(待用户 review → writing-plans 产出实施计划)
> 日期2026-06-13
> 关联:上一期整体设计 `docs/superpowers/specs/2026-06-13-workflow-tui-ultracode-design.md`(其 §9 双栏面板已实现,本 spec 取代该 §9 的面板部分)
---
## 1. 背景与现状
上一期整体设计已落地:`WorkflowService` 门面、`claude-code` AgentAdapter、进度 bus+store、引擎 `agentId` 关联、`/ultracode` skill 全部实现完成。`/workflows` 面板按旧 spec §9 实现为**双栏**
- `src/workflow/panel/WorkflowsPanel.tsx`:左栏 `WorkflowList`(扁平 run 列表)+ 右栏 `WorkflowDetail`phase 横条 + 扁平 agent 列表)。
- 键位 `j/k` 在左栏选 run选中即聚焦、右栏随之切换。
**问题**:监控「单个 run 内多 phase / 多 agent」时左右是「run 列表 vs 单 run 详情」——切换 run 与查看 agent 共用一对键位phase 仅一行横条,无法按 phase 筛选 agent多个 run 间切换要上下翻列表。
本 spec 把面板**原地重写**为三区焦点模型:**顶部 run tab + 左 phase 筛选侧栏 + 右 agent 列表**,贴合「聚焦一个 run → 按 phase 收窄 → 看 agent 状态」的实际监控动线。
## 2. 目标与非目标
**目标**
1. 顶 tab 按 **run**(同名脚本多次跑会多个 tab标签附 runId 短码消歧如 `review-changes#a3f`)。
2. 左 phase 侧栏:合并 `meta` 声明 phasepending `○`)与 store phaserunning `●` / done `✓`+ 一个固定 `All` 项;选中即决定右栏筛选。
3. 右 agent 列表:按选中 phase 过滤(`All` 则全显);状态用颜色 + 文字标记(`object` / `text` / `dead`)。
4. 焦点轮转键位:`Tab`/`Shift+Tab` 切 run、`←/→` 切 phases↔agents、`↑/↓` 列内移动、`x` kill / `r` resume / `q`/`Esc` quit。
5. 视觉极简:无内框,左右栏中间**一条竖线**;选中/光标行用**底色条**`backgroundColor`,非反白);聚焦列标题橙粗、非聚焦灰。
6. 显示 **pending phase**meta 声明但未启动)。
**非目标**
- 不改引擎包(`run_started` 已携带 `meta.phases`,见 §3
- 不动 `service`/`registry`/`backends`/`ports`/`wiring`/Workflow 工具/`/ultracode`
- 不做 per-agent 操作 UI仅 run 级 `kill`/`resume`)。
- 不改 `BackgroundTasksDialog`Shift+Down跳转协议。
- 不做 agent 输出详情抽屉(留未来)。
## 3. 关键发现:零引擎改动
`ProgressEvent.run_started` **已携带** `meta: WorkflowMeta | null``packages/workflow-engine/src/types.ts:60-66`emit 点 `engine/runWorkflow.ts:72-77`),且 `WorkflowMeta.phases` 已是 `Array<{ title: string; detail?: string }>``types.ts:22-27`)。
→ pending phase 所需数据全在事件流里。面板只需让 store 在 `run_started` 时落地 `declaredPhases`,再与 store 的 `run.phases`running/done合并即可。**不触碰引擎包**。
## 4. 数据模型变更(`src/workflow/progress/store.ts`
- `RunProgress` 新增字段:
```ts
declaredPhases: string[] // 来自 run_started.meta.phases[].title无 meta → []
```
- reducer `run_started` 分支补一行(当前第 74-77 行只用 `event.workflowName`,忽略 `event.meta`
```ts
case 'run_started':
p.workflowName = event.workflowName
p.status = 'running'
p.declaredPhases = event.meta?.phases?.map(ph => ph.title) ?? []
break
```
- `ensure()` 初始化 `declaredPhases: []`。
- 其余 reducer 分支、`AgentProgress`、快照排序逻辑不变。
**测试**`progress/store.test.ts` 或对应测试文件):
- `run_started` 带 `meta.phases` → `declaredPhases` 落地且顺序保留。
- `run_started` 的 `meta` 为 `null` → `declaredPhases === []`。
- 已有 `agentId` 关联、phase 切换、`run_done` 终态用例保持绿。
## 5. 面板布局(定稿 ASCII
焦点在 PHASES默认进入态
```
╭─ Workflows ──────────────────────────── 2 running · 3 done ─╮
│ │
│ ● review-changes ✓ find-bugs ● migrate-auth │
│ ═════════════════ ← Tab / Shift+Tab 切 │
│ │
│ PHASES │ AGENTS · Review │
│ │ │
│ ✓ Find 3/3 │ ● review:bugs running │
│ ▓▶● Review 2/5▓ │ ● review:perf running │
│ ○ Verify 0/2 │ ✓ review:sec object │
│ │ ✗ review:api dead │
│ All 10 │ ✓ review:auth text │
│ │ │
│ Tab 切 run · ←/→ 切焦点 · ↑/↓ 移动 · x kill · q quit │
╰─────────────────────────────────────────────────────────────╯
```
按 `` 焦点到 AGENTS`PHASES` 标题变灰、`AGENTS` 变橙、光标行铺底色):
```
phases (灰) │ AGENTS · Review (橙)
✓ Find 3/3 │ ● review:bugs running
● Review 2/5 │ ▓● review:perf running ▓ ← 光标行底色
○ Verify 0/2 │ ✓ review:sec object
All 10 │ ✗ review:api dead
```
## 6. 焦点与键位状态机
**面板状态**`WorkflowsPanel` 内 `useState`
| 状态 | 含义 | 默认 |
|---|---|---|
| `activeRunId` | 当前 tab 的 runId | 首个 run无则 null |
| `focusColumn` | `'phases'` \| `'agents'` | `'phases'`(该 run 无任何 phase 则 `'agents'` |
| `selectedPhaseIndex` | phase 侧栏选中项(`0` = `All` | `0` |
| `selectedAgentIndex` | agent 列表光标行 | `0` |
**键位**
| 键 | 作用 |
|---|---|
| `Tab` / `Shift+Tab` | 切顶部 run tab正/反);切 tab 时重置 `selectedPhaseIndex=0`、`selectedAgentIndex=0`、`focusColumn` 回默认 |
| `` / `` | `phases` ↔ `agents` 焦点切换tabs 不参与左右,由 `Tab` 管) |
| `` / `` | 当前焦点列内移动选中phase 改筛选agent 滚光标) |
| `x` | kill 当前 tab 的 run |
| `r` | resume 当前 tab 的 run缺 `canUseTool` 时 `onDone` 提示用 `/<name> resume` |
| `q` / `Esc` | 退出面板 |
**夹紧**:复用 `WorkflowsPanel` 已导出的 `clampSelected`——切 tab / 列表变动后把 `selectedPhaseIndex`、`selectedAgentIndex` 夹到有效区间。
**筛选语义**`selectedPhaseIndex===0``All`)→ 右栏显示全部 agent否则按 `phase === 选中 phase title` 过滤。
## 7. 组件拆分(`src/workflow/panel/`
| 文件 | 动作 | 职责 |
|---|---|---|
| `WorkflowsPanel.tsx` | 重写 | 订阅 store、持焦点状态、渲染 `TabsBar` + 左右双栏、绑 `useWorkflowKeyboard`;保留导出 `clampSelected` |
| `TabsBar.tsx` | 新建 | 顶部 run tab 行(状态点 + 名 + runId 短码;当前 tab 橙色 `═══` 下划线) |
| `PhaseSidebar.tsx` | 新建 | 左 phase 列表:`All` + 合并 `declaredPhases`pending ``)与 `run.phases```/``),每行附 `done/total` agent 计数 |
| `AgentList.tsx` | 新建 | 右 agent 列表:按选中 phase 过滤;状态色 + 行尾 `object`/`text`/`dead` 文字标记 |
| `status.ts` | 新建 | 共享状态→字符/颜色映射(`STATUS_DOT`、phase/agent mark 函数),三组件复用 |
| `useWorkflowKeyboard.ts` | 改写 | 焦点模型键位(见 §6 |
| `WorkflowList.tsx` | 删除 | run 列表职责迁入 `TabsBar` |
| `WorkflowDetail.tsx` | 删除 | phase+agent 职责拆入 `PhaseSidebar`+`AgentList` |
| `panelCall.ts` | 不变 | local-jsx 入口仍渲染 `WorkflowsPanel` |
**外部接口不变**`/workflows` 命令注册、`panelCall`、`getWorkflowService()` 订阅协议、`BackgroundTasksDialog` 跳转均不动。
## 8. 视觉规则
- **无内框**:左右两栏中间一条 `` 竖线,仅此一条分割线;最外层保留最朴素的 round border 界定面板。
- **聚焦列**:标题 `claude` 橙粗体;非聚焦列标题 `subtle` 灰。
- **选中/光标行**:整行铺 `backgroundColor="claude"` 橙底ASCII 用 `` 示意),**文字色不变**,状态点保留各自颜色。
- **状态色**(沿用现有 Ink theme token无新增
| 元素 | 状态 | 字符 | 颜色 |
|---|---|---|---|
| Tab (run) | running | `` | `warning` |
| | completed | `` | `success` |
| | failed | `` | `error` |
| | killed | `` | `subtle` |
| | 当前 | `═══` | `claude` 下划线 |
| Phase | running | `` | `warning` |
| | done | `` | `success` |
| | pending | `` | `subtle` |
| | 选中 | `` | `claude` + 底色 |
| Agent | running | `` | `warning` |
| | done·text | `` | `success` + 行尾 `text` |
| | done·object | `` | `success` + 行尾 `object` |
| | dead | `` | `error` + 行尾 `dead` |
- **object 标记**:行尾纯文字 `object`(不用 `` 符号)。
- **左窄右宽**phase 栏约 20%、agent 栏约 80%(或固定 phase 栏 ~20 字符agent 栏吃剩余宽度)。
## 9. 测试策略
- **store**`declaredPhases` 落地 + null meta 回归§4
- **面板**`WorkflowsPanel.test.tsx`ink-testing-library遵循仓库 mock 规范):
- 多 run → tab 渲染 + 当前 tab 下划线;`Tab`/`Shift+Tab` 切换且重置子选择。
- `←/→` 切 `focusColumn`(标题颜色 / 光标落点)。
- phase 侧栏选中 → 右栏 agent 按 phase 过滤;`All` 显全部。
- pending phase`declaredPhases` 有、store 无)显示 ``。
- 选中行/光标行底色条(断言对应 `<Text backgroundColor>`)。
- `x` kill、`r` resumemock service、`q`/`Esc` 退出。
- 空态(无 run占位文案 + `n` 提示。
- 订阅刷新store 变更后面板重渲染agent 状态 running→done
- **回归**`bun run precheck` 零错误;现有 workflow 集成测试canonical scripts / review / loop / resume保持绿。
## 10. 里程碑与提交切分
每个里程碑结束 `bun run precheck` 必须零错误。
1. **M1 store**`RunProgress.declaredPhases` + reducer `run_started` 落地 + 测试。
2. **M2 panel 组件**:新建 `status.ts` / `TabsBar` / `PhaseSidebar` / `AgentList``WorkflowsPanel` 重写为焦点状态机;`useWorkflowKeyboard` 改焦点模型;删除 `WorkflowList` / `WorkflowDetail`。
3. **M3 测试**`WorkflowsPanel.test.tsx` 全量用例 + precheck 绿。
4. **M4 文档**`docs/features/workflow-scripts.md` §六 更新为三区布局/键位;旧 spec §六/§9 加注「面板部分已被 `2026-06-13-workflow-panel-redesign.md` 取代」。
## 11. 未做 / 未来工作
- per-agent skip/retry 的 UI 接线(引擎 seam 已在)。
- agent 详情抽屉:选中 agent 后展开其 prompt/输出/token。
- 多 run 并排对比视图。
- `declaredPhases` 与实际 `phase()` 调用不一致时的告警(如脚本声明了 phase 却没调用)。

View File

@@ -0,0 +1,191 @@
# Workflow Run State Persistence — Design
**Date**: 2026-06-13
**Status**: Approved (brainstorming), pending implementation plan
**Related**: `2026-06-12-workflow-engine-design.md`, `2026-06-13-workflow-panel-redesign.md`
## 问题陈述
Workflow 脚本的 `return` 值和终态 `RunProgress`status / agents / phases / returnValue / error只活在 `ProgressStore``src/workflow/progress/store.ts`)的内存 Map 里。一旦 Claude Code 进程关闭/重启,全部丢失。
已落盘的 `.claude/workflow-runs/<runId>/journal.jsonl` 只记录每个 `agent()` 调用的结构化结果,**不**包含脚本顶层 `return` 值,也无法重建 `/workflows` 面板需要的 `RunProgress` 摘要。重启后面板为空,对话 agent 也无法按 runId 取回 return 值。
## 目标
- **(a) 重启后按 runId 取 return** — 对话 agent 在新进程里能拿到已完成 run 的 `returnValue``error`
- **(b) 面板跨重启展示历史** — `/workflows` 面板重启后能列出历史 run 及其状态/agents/phases/耗时。
## 非目标
- **(c) 跨进程 resume 明确排除** — 不重建 abort controller、agent binding、未完成 phase 的中间态。当前 resume 机制(同进程内 journal replay保持不变跨进程续跑是独立大特性不在本 spec 范围。
- **自动清理** — `.claude/workflow-runs/` 持续累积,依赖项目 `.gitignore` 与用户手动清理。生命周期管理是后续特性。
## 架构
新增一个 host 侧持久化模块 + 三处接入点。**引擎层 `@claude-code-best/workflow-engine` 零改动**——持久化是 host 侧关注,不污染引擎接口。
### 组件
| 文件 | 改动 | 职责 |
|---|---|---|
| `src/workflow/persistence.ts` | 新增 | `writeRunState` / `readRunState` / `listPersistedRuns`原子覆盖写tmp + rename`getRunsDir()` 统一 runsDir 来源 |
| `src/workflow/progress/store.ts` | 改 | 新增 `hydrate(run: RunProgress): void` —— 绕过 bus 直接注入磁盘 run用于 `loadPersistedRuns` |
| `src/workflow/service.ts` | 改 | 订阅 bus `run_done``writeRunState``getRun(id)` 内存 miss → `readRunState` fallback新增 `loadPersistedRuns(): Promise<void>` |
| `src/workflow/panel/WorkflowsPanel.tsx` | 改 | mount 时调一次 `svc.loadPersistedRuns()`flag 在 service 单例内部守护panel 无脑调,重复调用是 no-op |
| `src/workflow/ports.ts` | 改 | `${getProjectRoot()}/.claude/workflow-runs` 提取为 `getRunsDir()` 共享(消除重复拼接,与 persistence.ts 同源) |
## 数据流
### 写入(终态触发,单一入口覆盖 A+ 所有终态)
```
engine runWorkflow
└─ progressEmitter.emit({type:'run_done', status, returnValue, error})
└─ bus.emit
├─ store.apply(event) [store 先订阅,内存 RunProgress 已更新]
└─ service 订阅 listener [后订阅store.get(runId) 拿到最新快照]
└─ writeRunState(runsDir, runId, snapshot)
└─ writeFile(state.json.tmp) → rename(state.json) [原子]
```
**订阅顺序**bus 是 `Set<listener>`,注册顺序 = 触发顺序。`createProgressStoreFromBus(bus)` 在 service 创建之前先订阅 storeservice 后订阅。因此 service 的 `run_done` listener 执行时,`store.get(event.runId)` 已是 apply 后的最新值,直接序列化写盘即可。
**为什么不需要单独的 shutdown 钩子**`taskRegistrar.kill``abortController.abort()``runWorkflow` 看到 signal → 发 `run_done killed` → 走同一个订阅。`service.shutdown()` 显式 kill running run 时同样触发 `run_done`。三种终态completed / failed / killed共用一个写盘入口。
### 读取① — 面板跨重启展示
```
CLI 重启 → 用户 /workflows → WorkflowsPanel mount
└─ useEffect: svc.loadPersistedRuns() [service 内部 persistedLoaded flag 守护,仅一次实际扫盘]
└─ listPersistedRuns(runsDir) [扫所有子目录的 state.json]
└─ store.hydrate(run) [已存在的 runId 跳过,内存优先]
```
**`persistedLoaded` flag 归属**:放在 `WorkflowService` 单例上(`makeService` 闭包变量),不是 panel 模块级。理由service 是进程单例flag 跟随单例生命周期最稳panel 可能多次 mount/unmountflag 在 service 上可避免重复扫盘。panel `useEffect` 无脑调 `loadPersistedRuns()`service 内部判断"已加载过则立即返回 resolved Promise"。
### 读取② — agent 按 runId 取 return
```
service.getRun(id)
├─ store.get(id) 命中 → 返回(本次会话的 run
└─ miss → readRunState(runsDir, id) → 返回(历史 run不注入内存
```
**不注入内存的取舍**:历史 run 进入内存会污染本次会话的 store / 面板列表语义("内存 = 本次会话产生的 run"这条不变量要保留)。代价是同会话内反复查同一历史 run 会反复读盘——可接受(查询频率低,文件小)。
## state.json 格式
包一层 `schemaVersion` 留 migration 空间payload 是终态 `RunProgress` 全字段:
```json
{
"schemaVersion": 1,
"run": {
"runId": "w12tp1rrk",
"workflowName": "audit-agent-system-vs-ultracode",
"status": "completed",
"phases": [
{"title": "Review", "status": "done"},
{"title": "Verify", "status": "done"}
],
"declaredPhases": ["Review", "Verify"],
"currentPhase": null,
"agents": [
{
"id": 1,
"label": "review:hooks",
"phase": "Review",
"status": "done",
"outputShape": "object",
"tokenCount": 12345,
"toolCount": 3,
"model": "claude-sonnet-4-6"
}
],
"agentCount": 11,
"returnValue": {"dimensionsAudited": 9, "confirmedCount": 2, "confirmed": []},
"startedAt": 1718277600000,
"updatedAt": 1718278000000,
"description": "Audit workflow engine against ultracode skill spec"
}
}
```
### 字段决策
- `agents[]` 写完整 `AgentProgress`(含 `label` / `phase` / `status` / `tokenCount` / `toolCount` / `model` / `outputShape` / `resultKind`**不含 agent 实际 output 内容**——output 已在 `journal.jsonl`,避免冗余。
- 失败 run 的 `error` 字段直接进 `run.error``RunProgress` 已有该字段)。
- `returnValue?: unknown` 原样序列化,**不截断**。用户对自己的 return 大小负责(脚本若 return 整个数据库 dump磁盘占用自负
## 错误处理
| 场景 | 行为 |
|---|---|
| `writeRunState` IO 失败(磁盘满 / 权限) | `logForDebugging('[workflow warn] ...')` 吞掉,**不阻断 workflow 完成**——workflow 本身已成功,持久化失败只意味着重启后取不到,可接受 |
| `readRunState` 文件不存在 | 返回 `null`,调用方按 miss 处理 |
| `readRunState` JSON 解析失败 | 返回 `null`log warn当 miss不崩 |
| `readRunState` schema 结构不匹配(缺字段/类型错) | 返回 `null`log warn当 miss |
| `schemaVersion` 未来不匹配 | 当前是 `1`,无迁移链,任何非 1 的版本 → 返回 `null` 当 miss向前兼容兜底。未来升级版本时再引入迁移函数链 |
| 原子写中途崩溃 | `writeFile(state.json.tmp)` + `rename(tmp, state.json)`rename 原子;最坏留下 `.tmp` 文件,下次写覆盖 |
| `loadPersistedRuns` 扫到子目录无 `state.json`(只有 journal | 跳过,不报错(半残 run |
| `loadPersistedRuns` 扫到某 `state.json` 损坏 | 跳过该单个文件,继续扫其余(一个坏文件不阻塞整体加载) |
## 关键不变量
1. **内存 run 永远优先于磁盘 run**`store.hydrate` 跳过已存在 runId`getRun` 内存命中则不读盘。
2. **磁盘是纯终态快照** — 本次会话 running 中的 run 不写盘;进程在 run 终态前被 SIGKILL/断电/crash该 run 在磁盘上缺失(连 `run_done` 都来不及发)。这是 A+ 接受的边缘情况。
3. **磁盘 run 不注入 `getRun` 路径的内存** — 只有 `loadPersistedRuns`(面板 mount会 hydrate`getRun` fallback 仅返回,不 hydrate。
4. **持久化失败不阻断 workflow** — 写盘是 best-effortIO 异常只 log 不抛。
5. **引擎层零改动** — 所有持久化逻辑在 host 侧(`src/workflow/`),引擎 `@claude-code-best/workflow-engine` 接口不变。
## 测试策略
### `src/workflow/__tests__/persistence.test.ts`(新增)— 纯 fs用 tmpdir
- `writeRunState``readRunState` 往返一致(含 `returnValue` 为对象 / 数组 / 字符串 / null 各形态)
- `writeRunState` 原子性:构造 tmp 残留场景,验证 `state.json` 要么完整要么不存在,无半写
- `readRunState` 损坏 JSON / 缺文件 / schemaVersion 不符 / 必需字段缺失 → 均返回 `null`
- `listPersistedRuns` 扫多子目录、跳过无 `state.json` 的目录、跳过损坏文件、按 `updatedAt` 降序返回
### `src/workflow/__tests__/store.test.ts`(扩展)
- `hydrate(run)` 注入新 runId → `get` 命中、`list` 含该项
- `hydrate(run)` 已存在 runId → 跳过(内存值不被磁盘覆盖)
- `hydrate``subscribe` listener 被通知
### `src/workflow/__tests__/service.test.ts`(新增 / 扩展)— 注入 fake bus / ports / tmpdir
- bus emit `run_done completed` + returnValue → `readRunState(runId)` 命中且 returnValue 一致
- bus emit `run_done failed` + error → state.json 写入 status=failed + error 字段
- bus emit `run_done killed` → state.json 写入 status=killed
- bus emit `run_done``writeRunState` 抛 IO 错 → service 不抛、其他订阅者store仍正常
- `getRun(id)` 内存命中 → 不读盘spy 断言 readRunState 未被调)
- `getRun(id)` 内存 miss + 磁盘命中 → 返回磁盘值;再次 `getRun(id)` 仍读盘(未注入内存)
- `getRun(id)` 内存 miss + 磁盘 miss → 返回 undefined
- `loadPersistedRuns()` 扫盘后 `listRuns()` 含历史 run已有内存 runId 不被磁盘覆盖
### `src/workflow/__tests__/WorkflowsPanel.test.tsx`(扩展)
- WorkflowsPanel mount → 调一次 `loadPersistedRuns`spy 断言调用次数 = 1
- 重复 mount / 重渲染 → 不重复调用(`persistedLoaded` flag 防重入)
### 回归
- `bun test src/workflow/` 全套通过
- `bun run precheck` 零错误typecheck + lint fix + test
## 实现顺序提示(供 writing-plans 展开)
1. `persistence.ts` + 单测(最底层,无依赖)
2. `store.ts``hydrate` + 单测
3. `ports.ts` 提取 `getRunsDir()`
4. `service.ts` 订阅 `run_done` + `getRun` fallback + `loadPersistedRuns` + 单测
5. `WorkflowsPanel.tsx` mount 触发 + 测试
6. 全量 `precheck`
## 未来工作(明确不在本 spec
- **跨进程 resume (c)** — 需重建 agent binding / abort / 中间态,独立特性
- **生命周期管理** — 数量 cap / 时间 cap / 手动清理命令
- **return 值大小限制** — 若发现滥用,再加 schema 级 cap 与截断策略
- **schema migration 链** — 当 `schemaVersion` 升到 2 时再引入

View File

@@ -0,0 +1,287 @@
# Workflow 集成层重写 + `/workflows` 面板 + `/ultracode` skill 设计
> 状态:草案(待 writing-plans 据此产出实施计划)
> 日期2026-06-13
> 关联:上一期引擎重建计划 `docs/superpowers/plans/2026-06-12-workflow-engine.md`、spec `docs/superpowers/specs/2026-06-12-workflow-engine-design.md`
---
## 1. 背景与现状
引擎包 `packages/workflow-engine/``@claude-code-best/workflow-engine`)已重建完成:`runWorkflow`、hooks`agent`/`parallel`/`pipeline`/`phase`/`log`/`workflow`、journal 确定性 resume、budget、concurrency、structuredOutput、`AgentAdapter` + `AgentAdapterRegistry`commit `c2253dcb`)、端口契约(`WorkflowPorts`)与自包含工具描述符(`createWorkflowTool`),单测覆盖 99.65%。
`src/` 侧的集成层(`src/workflow/`)虽已接上引擎,但**没有用上引擎的全部能力**,且 TUI/命令层是占位质量:
- `src/workflow/adapter.ts`:硬编码单一 `WORKFLOW_AGENT`(不查 `AgentAdapterRegistry`,也没接真实 agent 注册表);`taskRegistrar.pendingAction` 恒返回 `null`skip/retry 未接线);`permissionGate.isAborted``false``budgetTotal``null`;末尾有 `_AppStateUsed` 这类抑制未用导入的补丁。
- `src/workflow/progressStore.ts``agent_done` 把"最后一个 running 的 agent"标完成——并发下会标错(真竞态)。
- `/workflows``local` 命令,返回**纯文本**清单,不是监控面板——本设计将其原地重写为全屏面板。
- `/ultracode`**不存在**。
本设计把 `src/workflow/` 集成层**全量重写**,使其真正用上引擎能力,并交付全屏监控+控制面板与 ultracode 启动 skill。
## 2. 目标与非目标
**目标**
1. 全量重写 `src/workflow/` 集成层(引擎包为地基,不动其核心)。
2. 后端为单一 `claude-code` `AgentAdapter`,但**深度接入会话体系**provider/model/agentType/tools/telemetry 全从活的 `AppState` 解析。
3.`/workflows` **原地重写**为全屏**双栏**面板:左栏=各 workflow 的阶段树(光标移动),右栏=聚焦 workflow 的 agent 运行状况 + 基础信息;监控 + 控制(启动命名/resume/kill/展开)。
4. 新增 `/ultracode` **纯知识 prompt skill**:把 workflow 编排工作法注入上下文,零运行时副作用。
5.`/workflows` 文本命令重写为面板;接线点切换到新 wiring外部 `Tool`/命令接口不变。
**非目标**
- 不改引擎包核心逻辑(唯一例外:给进度事件加 `agentId`,见 §5
- 不实现多 provider adapterv1 单后端Registry 留扩展点但不预填路由规则)。
- 不做 per-agent skip/retry 的 UI 接线(引擎 seam 保留,见 §12
- 不翻转 `ultracode` 运行时行为开关(纯知识 skill
- 不做跨进程持久化的进度恢复live runs 留内存resume 走 journal
## 3. 范围与迁移清单
**新建**
| 路径 | 职责 |
|---|---|
| `src/workflow/service.ts` | `WorkflowService` 单例门面 |
| `src/workflow/registry.ts` | 建 `AgentAdapterRegistry`,注册单一 `claude-code` adapter |
| `src/workflow/backends/claudeCodeBackend.ts` | 深度集成的 `AgentAdapter`runAgent 委托 + 体系解析) |
| `src/workflow/backends/types.ts` | 后端/host 解析类型 |
| `src/workflow/ports.ts` | 组装 `WorkflowPorts`registry + 任务生命周期 + journal + progress bus |
| `src/workflow/progress/bus.ts` | 类型化发布/订阅事件总线 |
| `src/workflow/progress/store.ts` | reducer`ProgressEvent``RunProgress[]`(按 `agentId` 关联) |
| `src/workflow/panel/WorkflowsPanel.tsx` | 双栏全屏面板local-jsx |
| `src/workflow/panel/WorkflowList.tsx` / `WorkflowDetail.tsx` / `useWorkflowKeyboard.ts` | 左栏 workflow 扁平列表 / 右栏 phase 条+agent 列表 / 键位 |
| `src/skills/bundled/ultracode/SKILL.md` | `/ultracode` 知识 skill |
**重写(整体替换,非打补丁)**
- `src/workflow/adapter.ts` → 拆解进 `backends/`+`ports.ts`+`registry.ts`
- `src/workflow/wiring.ts` → 薄包装,走 `service`
- `src/workflow/progressStore.ts` → 拆进 `progress/{bus,store}.ts`
- `src/workflow/hostHandle.ts` → 清理(保留不透明 bundle 语义)
- `src/workflow/namedWorkflowCommands.ts` → 重写(扫 `.claude/workflows/``/<name>`
- `src/commands/workflows/index.ts` → 原地重写:`local` 文本命令 → `local-jsx` 面板入口(命令名仍为 `workflows`
**改接线点(接口不变,换实现来源)**
`src/tools.ts``src/commands.ts``src/tasks.ts``src/constants/tools.ts``src/utils/permissions/classifierDecision.ts``src/components/permissions/PermissionRequest.tsx``src/components/tasks/BackgroundTasksDialog.tsx`workflow 详情入口改为打开 `/workflows <runId>`)。
**删除**
- `src/components/tasks/WorkflowDetailDialog.tsx`(详情视图被 `/workflows` 右栏 `WorkflowDetail` 取代;逻辑并入,`BackgroundTasksDialog` 改为跳转 `/workflows`)。
**引擎微调**
- `packages/workflow-engine/src/types.ts``src/engine/hooks.ts``agent_started`/`agent_done``agentId: number`(见 §5
## 4. 架构总览
```
src/workflow/
├─ service.ts # launch/resume/kill/listRuns/getRun/subscribe/listNamed
├─ registry.ts # AgentAdapterRegistry单一 claude-code adapterdefault 路由)
├─ hostHandle.ts # 不透明 host bundletoolUseContext/canUseTool/parentMessage/agentId
├─ ports.ts # WorkflowPorts = { hostFactory, agentRunner(registry), progressEmitter(bus+store), taskRegistrar, journalStore, permissionGate, logger }
├─ backends/
│ ├─ claudeCodeBackend.ts # AgentAdapter深度解析 + runAgent 委托
│ └─ types.ts
├─ progress/
│ ├─ bus.ts # emit→多订阅者store / 面板 / 遥测)
│ └─ store.ts # RunProgress[] reduceragentId 关联)
├─ panel/
│ ├─ WorkflowsPanel.tsx # 双栏useSyncExternalStore 订阅 store
│ ├─ WorkflowList.tsx # 左栏:扁平 workflow 列表(名字+状态+当前 phase+计数)
│ ├─ WorkflowDetail.tsx # 右栏:聚焦 workflow 的 phase 横条 + 扁平 agent 列表
│ └─ useWorkflowKeyboard.ts
├─ wiring.ts # createWorkflowToolCore(): buildTool(引擎描述符)
└─ namedWorkflowCommands.ts # 扫描→/<name>
```
**依赖方向**`panel``wiring`(工具)只依赖 `service``service` 依赖 `registry`+`ports`+`progress`+引擎;`backends` 依赖 `hostHandle`+核心 `runAgent`。引擎包零 `src/*` 导入不变。
## 5. 引擎微调:进度事件加 `agentId`
当前 `agent_started`/`agent_done` 只带 `label`/`phase`reducer 只能 LIFO 猜匹配。改为:
```ts
// packages/workflow-engine/src/types.ts变体加字段
| { type: 'agent_started'; runId: string; agentId: number; label?: string; phase?: string }
| { type: 'agent_done'; runId: string; agentId: number; label?: string; phase?: string; result: AgentRunResult }
```
`makeHooks``engine/hooks.ts`)维护引擎内递增计数器(非脚本沙箱内,可用普通计数器,不受 Date/Math 禁令影响),在 `agent()` 内为每次调用分配 `agentId`,同时盖戳 `agent_started``agent_done``pipeline`/`parallel` 内并发调用各自独立 idreducer 按 id 精确落位。补 `hooks.test.ts`:并发 agent 的 started/done id 配对回归。
## 6. WorkflowService
```ts
type HostContext = { handle: HostHandle; cwd: string; budgetTotal: number | null; toolUseId?: string }
type WorkflowService = {
launch(opts: {
source: { script: string } | { name: string } | { scriptPath: string }
args?: unknown
hostContext: HostContext // 调用方构造(工具/面板各自)
description?: string
resumeFromRunId?: string
}): Promise<{ runId: string }> // 立即返回,后台 detached
resume(runId: string, hostContext: HostContext): Promise<void>
kill(runId: string): void // AbortController.abort() → WorkflowAbortedError → killed
listRuns(): RunProgress[]
getRun(runId: string): RunProgress | undefined
subscribe(listener: () => void): () => void // 供 useSyncExternalStore
listNamed(): Promise<string[]> // 委托 namedWorkflows
}
```
**数据流**`launch` → 解析脚本源 → `parseScript` 快速校验 → 注册 `LocalWorkflowTask`(拿 runId + AbortSignal`progress.bus.emit(run_started)``runWorkflow({ ports, host, signal, runId, ... })` detached → 引擎经 hooks 发 `ProgressEvent``ports.progressEmitter.emit` 同时喂 `bus`(订阅者)与 `store`reducer→ 面板 `useSyncExternalStore` 重渲染。
**host context 来源(关键解耦)**service 不自造 host由调用方传 `HostContext`
- **工具路径**`wiring.ts``call` 用引擎 `ports.hostFactory({ context, canUseTool, parentMessage })` 构造(沿用现状)。
- **面板路径**`/workflows` 是 local-jsx回调拿 `ToolUseContext`;面板用它 + 会话 `canUseTool`(按当前权限模式)构造 host使面板启动的 workflow 子 agent 享有与主会话一致的工具池与权限。
单例:`service``ports``registry``bus``store` 全进程共享,保证工具与面板同源(修掉旧"每实例一套 adapter/bindings"的隐患)。
## 7. 后端深度集成depth B单一 adapter深度读体系
`claudeCodeBackend.ts` 实现引擎 `AgentAdapter` 接口,`run(params, ctx)` 内**主动从活会话体系解析**,再委托核心 `runAgent`
```ts
// backends/claudeCodeBackend.ts签名级草图
export const claudeCodeBackend: AgentAdapter = {
id: 'claude-code',
capabilities: { structuredOutput: true, modelOverride: true },
async run(params: AgentRunParams, ctx: AgentAdapterContext): Promise<AgentRunResult> {
const { toolUseContext, canUseTool } = unwrapHostBundle(ctx.host)
const appState = toolUseContext.getAppState()
// 1) agentType → 真实 agent 注册表(不再硬编码 WORKFLOW_AGENT
const agentDef = resolveAgentDefinition(params.agentType, toolUseContext) // activeAgents 命中WORKFLOW_AGENT 兜底
// 2) model → provider 模型映射
const resolvedModel = params.model ? mapWorkflowModel(params.model, appState) : undefined
// 3) 工具池(活权限上下文)
const tools = assembleToolPool(workerPermissionContext(appState, agentDef), appState.mcp.tools)
// 4) schema → StructuredOutput 指令prompt 组装
// 5) runAgent({ agentDefinition, promptMessages, toolUseContext, canUseTool,
// isAsync: true, availableTools: tools, override: { agentId, model: resolvedModel } })
// 6) finalizeAgentTool → 取 outputTokens / 文本 / 结构化对象 → AgentRunResult
// 失败 → { kind: 'dead' }
},
}
```
要点:
- **provider 感知**`mapWorkflowModel``src/utils/model/``claude-haiku-*` 这类别名解析为当前 provider 的实际 model idprovider 来自 `src/utils/model/providers.ts` 的会话判定。
- **agentType → 真实注册表**`resolveAgentDefinition``toolUseContext.options.agentDefinitions.activeAgents`命中即用Explore/code-reviewer 等内置 + 用户 agent未命中或无 `agentType` 退 `WORKFLOW_AGENT` 兜底。
- **工具池/权限**worker 权限上下文取 agent 定义或 `acceptEdits``assembleToolPool` 生成。
- **遥测/token**`finalizeAgentTool``usage.output_tokens` 喂 engine budget`logEvent('tengu_workflow_agent', {…})` 逐 agent 计量。
- **Registry**`registry.ts` = `new AgentAdapterRegistry().register(claudeCodeBackend).default('claude-code')``ports.agentRunner.runAgentToResult = (params, host) => registry.resolve(params).run(params, { host })`。v1 不预填路由规则depth B单 adapter不预留多 provider 路由)。
## 8. 进度模型bus + store + agentId 关联)
- `progress/bus.ts``createProgressBus()` 返回 `{ emit(event), subscribe(fn) }`。emit 广播给所有订阅者store、面板、遥测。替换旧"只有 in-memory Map"的单消费者模型。
- `progress/store.ts``RunProgress[]` reducer沿用 `RunProgress` 形状runId/status/phases/currentPhase/agents/logs/agentCount/returnValue/error/updatedAt。新增 `AgentProgress.id: number``agent_done``event.agentId` 精确匹配 `agents[].id`(修掉旧 LIFO 竞态)。`subscribe()` 暴露给 React `useSyncExternalStore`
- 状态为进程内live runsresume 读磁盘 journal`.claude/workflow-runs/<runId>/journal.jsonl`)。
## 9. `/workflows` 双栏面板(左列表 / 右 phase+agent
`/workflows` 命令**原地重写**为 `local-jsx`(替换原文本命令),渲染**双栏**面板:走 `FullscreenLayout.modal` 路径(底部锚定、向上生长,`maxHeight ≈ terminalRows`,留 2 行 transcript peek`/model``/config` 一致),`useSyncExternalStore` 订阅 `service.subscribe` 实时刷新。**左栏=扁平 workflow 列表(极简),右栏=聚焦 workflow 的 phase 横条 + 扁平 agent 列表**。无树、无嵌套。
```
Workflows · 2 running · 1 done q quit
▸ ● review-pipeline Verify 2/3 8/12
● smoke-test Pong 3/3
✓ code-audit done 11/11
Named: research-report · smoke
─────────────────────────────────────────────────
review-pipeline ● running
Phases ✓Find ✓Review ●Verify
● verify:api 1.2k · verify:db —
✓ find:src 3.1k ✓ verify:auth 2.0k
j/k run · r resume · x kill · n new
```
**导航模型**:左栏是扁平 workflow 列表——每行一个 run状态点 + 名称 + 当前 phase + `done/total` agent 计数),光标 `▸``j/k` 上下选 run选中即聚焦、右栏随之切换。底部 NAMED 区(`service.listNamed()``n` 启动)。无展开/收起、无嵌套。
**组件**
- `WorkflowList.tsx`:左栏。`service.listRuns()` → 每行 `●`/`✓` 状态点 + workflow 名 + 当前 phase + agent 计数;底部 NAMED。
- `WorkflowDetail.tsx`右栏。一行头workflow 名 + 状态)+ **Phases 横条**`✓`/`●`/`○` 内联)+ **扁平 agent 列表**(每项状态符 + label + token自动换行排版不嵌套。终态显示 `returnValue`/`error`
- `useWorkflowKeyboard.ts`:键位见下。
**键位**`j/k` 选 run · `r` resume 聚焦 workflow读 journal· `x` kill · `n` 选命名 workflow 启动 · `q`/`esc``onDone()` 关闭。空 run 时左栏聚焦 NAMED右栏给"新建脚本到 `.claude/workflows/`"提示。
**颜色Impeccable 体系)**running = Claude Orange `#D77757` 动态点done = 绿failed = 红killed = 灰;底栏键位 `subtle`
**与 `WorkflowDetailDialog.tsx` 的关系**:该旧组件删除,详情逻辑并入右栏 `WorkflowDetail``BackgroundTasksDialog`Shift+Down保留为后台任务总览其 workflow 详情跳转改为打开 `/workflows <runId>`,面板以该 run 为初始聚焦。
**命令注册**`src/commands/workflows/index.ts` 导出 `local-jsx` 命令(`load: () => import('../../workflow/panel/WorkflowsPanel.js')`),在 `src/commands.ts``feature('WORKFLOW_SCRIPTS')` 条件注册(替换原文本 `workflowsCmd`)。
## 10. Workflow 工具 wiring
`wiring.ts` 仍薄:`createWorkflowToolCore(): Tool = buildTool(引擎描述符)`,描述符 = `createWorkflowTool(service.ports)`。保持 `Tool` 接口name/inputSchema/isEnabled/isReadOnly/description/prompt/call/renderToolUseMessage/mapToolResultToToolResultBlockParam。**关键变化**:描述符不再各自 `createWorkflowAdapter()`,统一走 `service` 单例。工具 `call` 返回 `run_id` + 提示"用 /workflows 查看实时进度"。工具仍在 `CORE_TOOLS`/`ALL_AGENT_DISALLOWED_TOOLS`,权限分类、`WorkflowPermissionRequest` 接新 wiring。
## 11. `/ultracode` skill
`src/skills/bundled/ultracode/SKILL.md``type: prompt``user-invocable: true`(自动成 `/ultracode`)。内容 = 蒸馏后的 workflow 编排 playbook
- **frontmatter**`name: ultracode``description: 进入多 agent workflow 编排模式何时用、编排原语、质量模式、确定性约束、后端路由、resume/budget、文件与命令``user-invocable: true`
- **何时用 workflow**:可分解/并行、需多视角置信、规模超单上下文、需 resume/审计;何时**不**用(琐碎单文件、单次问答)。
- **编排原语速查**`agent`/`parallel`/`pipeline`/`phase`/`log`/`workflow` 语义与陷阱pipeline 默认无 barrier、parallel 单项抛错→null、budget 硬上限、并发 cap、`MAX_TOTAL_AGENTS=1000`/`MAX_ITEMS_PER_CALL=4096`)。
- **质量模式库**每种给最小可运行片段adversarial-verify多数票 refute、perspective-diverse verify、judge panel、loop-until-dry、multi-modal sweep、completeness critic。
- **确定性约束**:脚本内禁 `Date.now()`/`Math.random()`(经 `args` 传时间戳/种子);`meta` 必须纯字面量。
- **后端路由**`AgentAdapterRegistry` 按 model/agentType 路由v1 默认 `claude-code`,深度读会话 provider/model/agent 体系。
- **resume/budget**`resumeFromRunId` 重放 journal`budget.total` 硬顶(默认无限)。
- **文件与命令**`.claude/workflows/``.claude/workflow-runs/<runId>/journal.jsonl``/workflows` 面板、`/<name>` 命名命令。
调用即注入上下文,**不改主循环、零运行时副作用**。
## 12. 错误处理 / 权限 / 生命周期 / 并发 / budget / skip-retry
- **错误**:脚本语法/meta 错 → `parseScript` 即时返错不进后台agent 抛错 → `kind:'dead'``null`workflow 继续parallel/pipeline 容错);`WorkflowAbortedError``killed`;其它 → `failed`+error。终态走 `run_done` + `LocalWorkflowTask` complete/fail/kill。
- **权限**worker 用 `assembleToolPool(workerPermissionContext, mcp.tools)`,权限模式取 agent 定义或 `acceptEdits`;面板启动的 run 用面板 `ToolUseContext``canUseTool``WorkflowPermissionRequest.tsx` 保留并接新 wiring。
- **生命周期/并发/budget**:复用引擎 `Semaphore``min(16, cores-2)`)、`MAX_TOTAL_AGENTS=1000``MAX_ITEMS_PER_CALL=4096``Budget`(默认 `null` 无限;可经 settings/env 注入 turn 级上限,留参数)。
- **skip/retryper-agent**:引擎 `taskRegistrar.pendingAction` seam 保留v1 返 `null`。面板控制诉求由 kill/resume 覆盖。
## 13. 测试策略
- **引擎**`hooks.test.ts` 加"并发 agent 的 started/done id 配对"回归。
- **集成层**`src/workflow/__tests__/`
- `service.test.ts`launch→completed/failed/killed、resume 走 journal、kill 中止、subscribe 通知mock 端口,无 LLM
- `registry.test.ts`:默认路由命中 `claude-code``resolve` 对未知规则回落默认。
- `claudeCodeBackend.test.ts`agentType→真实定义命中/兜底model→映射失败→`dead`mock `runAgent`)。
- `progressStore.test.ts`**并发 `agent_done``agentId` 精确关联**回归旧竞态、phase 切换、`run_done` 终态。
- `WorkflowsPanel.test.tsx`ink-testing-library扁平列表渲染、光标 j/k 切换聚焦 workflow、右栏 phase 条+agent 列表、键位 x/r/n、空态、订阅刷新。
- **回归**`bun run precheck` 零错误;现有 workflow 集成测试canonical scripts/review/loop/resume仍绿。
- 遵循仓库 mock 规范(共享 `tests/mocks/log.ts``debug.ts`mock 底层 HTTP/副作用,不 mock 业务模块;注意 `mock.module` 进程全局污染,集成测试 mock axios 而非源 API 模块)。
## 14. 里程碑与提交切分
每个里程碑结束 `bun run precheck` 必须零错误。
1. **M1 引擎微调**`ProgressEvent.agentId` + hooks 盖戳 + 单测。
2. **M2 进度层**`progress/bus.ts` + `store.ts`agentId 关联)+ 测试。
3. **M3 后端 + Registry + ports + hostHandle**`claudeCodeBackend`(深度解析)、`registry``ports` 组装 + 测试。
4. **M4 Service 门面**`service.ts`launch/resume/kill/subscribe/listNamed+ 测试。
5. **M5 工具 wiring 切换 + 接线点更新**`wiring.ts` 走 service更新 tools/commands/tasks/constants/classifier/PermissionRequest/BackgroundTasksDialog。`precheck` 绿。
6. **M6 `/workflows` 面板(原地重写命令)**panel 组件(`PhaseTree`/`AgentStatus`+ 键位 + 把 `src/commands/workflows/` 重写为 local-jsx + 测试。
7. **M7 `/ultracode` skill**`SKILL.md` playbook。
8. **M8 文档**:更新 `docs/features/workflow-scripts.md`,新增面板/skill 说明。
## 15. 未做 / 未来工作
- 多 provider adapterOpenAI/Gemini/Grok/Bedrock/Vertex 等真后端 + model 路由分流)——引擎 Registry 机制本身在用(单 adapter扩第二个 adapter 时再补 `route` 规则;本期按 depth B 不预填。
- per-agent skip/retry 的 UI 接线(引擎 seam 已在)。
- `ultracode` 运行时行为开关(默认倾向 Workflow 工具)——本期为纯知识 skill。
- 跨进程/重启的 live 进度恢复当前内存resume 走 journal
- `budgetTotal` 从 settings/env 注入 turn 级预算。

View File

@@ -0,0 +1,394 @@
# Effort 交互面板EffortPanel设计
**日期**: 2026-06-14
**作者**: brainstorming session 产物
**状态**: 待实施
**关联**: `src/commands/effort/``src/utils/effort.ts``src/components/EffortPanel/`(新增)
---
## 1. 概述
把当前的 `/effort` slash 命令从纯文本式交互升级为终端内的可视化选择面板。
- 触发:`/effort`(无参)打开面板;`/effort <level>` 直跳路径保留
- 视觉:横向 slider两端标 `Faster` / `Smarter`,刻度为 `low / medium / high / xhigh / max / ultracode`
- 交互:`←/→` 移动光标,`Enter` 确认,`Esc` 取消
- ultracode 仅作视觉占位,确认后提示用户走 `/ultracode <context>` 启动
- 第二阶段加波纹动画(详见 §6
## 2. 用户故事
- 作为开发者,我希望按 `/effort` 就能可视化地选择努力等级,而不用记 5 个枚举值
- 作为高频用户,我希望 `/effort high` 这种直跳仍可用,避免脚本/习惯被打断
- 作为设置了 `CLAUDE_CODE_EFFORT_LEVEL` 的用户,我希望面板提示我"env 优先级更高",而不是默默忽略我的选择
- 作为想试 ultracode 的用户,我希望面板让我知道这个"档位"存在,但落地要走它自己的命令
## 3. 不在本期范围
- 不修改 `EffortValue` / `EffortLevel` 类型
- 不修改 `src/utils/effort.ts` 的任何纯函数
- 不新增专用全局热键(仅通过 `/effort` 触发)
- 不在面板里包含 `auto` 选项(仍走 `/effort auto`
- 不真正"启用 ultracode"——面板对 ultracode 仅作视觉提示与文案引导
## 4. 架构与文件结构
```
src/
├── commands/effort/
│ ├── effort.tsx ← 改造call() 在 args 为空时返回 <EffortPanel>
│ │ 有参时维持原 executeEffort() 路径
│ └── index.ts ← 不变
├── components/EffortPanel/
│ ├── EffortPanel.tsx ← 新增:面板主体(渲染 + 键盘交互 + onDone 通道)
│ ├── effortPanelState.ts ← 新增:纯函数 reducer移动光标、确定选项
│ │ 抽离便于单测
│ └── __tests__/
│ ├── EffortPanel.test.tsx ← 渲染 / 键盘交互 / env 警告 / ultracode 提示
│ └── effortPanelState.test.ts ← reducer 纯函数测试
```
### 复用清单(不重写)
- `executeEffort()` / `setEffortValue()` / `unsetEffortLevel()`:留在 `effort.tsx`,面板确认时调用
- `EFFORT_LEVELS` / `getDisplayedEffortLevel()` / `getEffortEnvOverride()` / `getEffortValueDescription()` / `modelSupportsEffort()`:从 `src/utils/effort.ts` 直接 import
- `useInput``useKeyboard`:从 `@anthropic/ink`
- `<ApplyEffortAndClose>` 组件:作为面板 Enter 后的"写入并退出"流程组件复用(或迁入 EffortPanel 内部)
### 类型层面
不动 `EffortValue` / `EffortLevel`。面板内部用一个新类型 `PanelPosition` 表示光标位置:
```ts
type PanelPosition = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultracode';
```
它仅在面板内部使用,不进入 AppState、不进入 settings.json、不参与 API 调用。
## 5. 交互流程
### 触发与初始光标
```
/effort<回车>(无参)
→ call() 检测 args === ''
→ 渲染 <EffortPanel onDone={onDone} appStateEffort={effortValue} model={model} />
→ 光标初始位置:
env override 存在时 → env 设定的档位(让用户立刻看到生效值)
否则 → getDisplayedEffortLevel(model, appStateEffort)
```
### 状态机
```
状态:{ cursor: PanelPosition }
事件:
← (ArrowLeft) → cursor 左移一位low 处不左移,保持 low
→ (ArrowRight) → cursor 右移一位ultracode 处不右移,保持 ultracode
Home / h → cursor = low
End / l → cursor = ultracode
Enter → 确认分支(见下)
Esc / Ctrl+C / q → 取消onDone("Effort unchanged.")
```
### 确认后的两条分支
**分支 Acursor ∈ {low, medium, high, xhigh, max}**
```
调 executeEffort(cursor)
→ setEffortValue 写 settings + AppState
→ 拿到 result.message
onDone(result.message)
```
(与现有 `/effort high` 完全一致的消息体例,含 env override 警告)
**分支 Bcursor === 'ultracode'**
```
不调 executeEffort
onDone("ultracode 不是 effort 档位。请使用 /ultracode <context> 启动多 agent workflow。")
```
### 取消路径
不调 executeEffort、不写 AppState、不写 settings。`onDone("Effort unchanged.")`
### 不变路径(仍走原 effort.tsx 逻辑)
- `/effort low|medium|high|xhigh|max`:直跳
- `/effort auto|unset`unsetEffortLevel
- `/effort help|-h|--help`help 文本
- `/effort current|status`ShowCurrentEffort
### 焦点与键盘独占
面板挂载时通过 Ink `useInput` 抢占键盘;卸载时自动释放(与 `AskUserQuestionPermissionRequest` 一致)。
## 6. 视觉布局
### 基本形态(无 env override
```
Effort
Faster Smarter
─────────────────────────▲──────────────────────────────────────────────
low medium high xhigh max ultracode
xhigh + workflows
←/→ adjust · Enter confirm · Esc cancel
```
### 视觉规则
| 元素 | 规则 |
|---|---|
| `▲` 光标 | 跟随 cursor 状态移动,永远指向当前 cursor 位置 |
| 当前生效档位active | 当 cursor ≠ active 时active 档渲染为加粗 + 旁标 `(active)`;当 cursor === active 时只显示 `▲`,避免双标记 |
| ultracode 副标签 | 固定字符串 `xhigh + workflows`dim 色 |
| 两极文字 `Faster` / `Smarter` | 与面板等宽左右对齐;中间用一行 `─` 填充 |
| 底栏提示 | `←/→ adjust · Enter confirm · Esc cancel`dim 色 |
| 标题 `Effort` | 加粗,居中或左对齐 |
### 双标记渲染cursor ≠ active
env override 时会出现,例如:
```
Effort
⚠ CLAUDE_CODE_EFFORT_LEVEL=high overrides this session
Faster Smarter
────────────────────────▲────────────────────────▲──────────────────────
low medium (high) active xhigh max ultracode
xhigh + workflows
←/→ adjust · Enter confirm · Esc cancel
```
- `▲` 上方cursor 位置xhigh
- `(high) active`env 锁定的真实生效档位
两个标记视觉上必须区分cursor 用三角符号active 用括号文字 + 颜色。
### 模型不支持 effort 时(`modelSupportsEffort(model) === false`
```
Effort
当前模型 <model> 不支持 effort 参数。面板已禁用。
Faster Smarter
────────────────────────────────────────────────────────────────────────
low medium high xhigh max ultracode
Esc to close
```
光标不显示左右键无效Enter 无效,只能 Esc 退出。
### 终端窄屏(< 60 cols适配
简化策略:宽度 < 60 时退化为垂直列表,每档一行;否则保持横向 slider。这一项**不阻塞首版**,先按横向渲染,必要时溢出,后续看实际效果再调。
## 7. 背景波纹动画(第二阶段,单独 commit
### 触发条件
仅在 cursor 停在 `ultracode` 时启动波纹;移开时立即停止(不淡出,干脆)。常态零干扰。
### 视觉概念
ultracode 是面板的"能量溢出口"。波纹从 ultracode 字符位置(右下区域)为震源,向左/向上辐射同心圆波,铺满整个面板的留白区域(文字字符之间的空隙、`─` 分隔线的空白段)。文字层永远清晰可读。
### 字符集(强度 → 字符)
| 强度 | 字符 |
|---|---|
| 0.0 | ` ` (空格) |
| 0.1 | `·` |
| 0.3 | `∙` |
| 0.5 | `░` |
| 0.7 | `▒` |
| 0.9 | `▓` |
| 波峰 | `~``◌``○``◑``●` 循环 |
### 波纹数学
```
对每个字符格:
dx = x - sourceX
dy = (y - sourceY) * 1.5
dist = sqrt(dx*dx + dy*dy)
phase = dist * 0.4 - time * 0.012
wave = sin(phase)
falloff = max(0, 1 - dist / 40)
intensity = max(0, wave) * falloff
if (dist < 6): // 震源附近高频涟漪
intensity = max(intensity, 0.5 + 0.5 * sin(time * 0.02 - dist * 1.2))
char = pick(intensity)
```
参数上线后调。
### 渲染策略(双层不冲突)
Ink 不支持真正的 z-index 层叠,用**字符替换**模拟:
1. 每帧生成 `height × width` 字符矩阵(背景层)
2. 渲染每个面板行时,先取该行对应的波纹字符序列,然后在文字字符应该出现的位置**覆盖**背景字符
3. 文字字符永远胜出,波纹只占空隙
### 实现位置
新增(第二阶段):
- `src/components/EffortPanel/rippleAnimation.ts``pickChar` / `computeRippleLine` / `mergeLayers` 纯函数
- `src/components/EffortPanel/useRippleFrame.ts` — hook内部调 `useAnimationFrame(60)` 返回当前帧矩阵
-`EffortPanel.tsx` 的 render 中叠加(仅 cursor === 'ultracode' 时启用)
### 性能预算
- 面板 80×10 = 800 格,每帧 800 次 sin/sqrt ≈ 0.05ms
- Ink 重绘 10 行 `<Text>` 节点,与现有 Spinner 同量级
- 帧率 16fps`useAnimationFrame` 自带 viewport 不可见暂停 + 失焦减速
### 风险与对策
| 风险 | 对策 |
|---|---|
| 波纹干扰文字可读性 | 文字字符覆盖背景字符,永远胜出;波纹颜色用 `theme.textDisabled` |
| 终端窄屏 < 60 cols | sourceX 跟随 ultracode 实际位置;窄屏时降级为单行波纹 |
| 性能(旧机器) | `useAnimationFrame` 已自带暂停/减速 |
| 测试稳定性 | 字符选择是纯函数,可固定 `time` 注入做帧快照测试 |
## 8. 数据流
### 状态来源
```
┌─────────────────────────────────────────────────┐
│ src/state/AppState.tsx │
│ effortValue: EffortValue | undefined │
└─────────────────────────────────────────────────┘
│ useAppState(s => s.effortValue)
┌─────────────────────────────────────────────────┐
│ EffortPanel.tsx │
│ props: appStateEffort, model, onDone │
│ local: cursor: PanelPosition │
└─────────────────────────────────────────────────┘
│ Enter 确认
┌─────────────────────────────────────────────────┐
│ executeEffort(cursor) │
│ → updateSettingsForSource('userSettings', …) │
│ → logEvent('tengu_effort_command', …) │
│ → 返回 { message, effortUpdate? } │
└─────────────────────────────────────────────────┘
│ <ApplyEffortAndClose> setAppState(...)
┌─────────────────────────────────────────────────┐
│ onDone(result.message) │
│ → REPL 渲染 assistant 消息 │
└─────────────────────────────────────────────────┘
```
### 优先级链(不修改)
```
env CLAUDE_CODE_EFFORT_LEVEL > AppState.effortValue > model default
```
面板只写 AppState + settings.json不直接操作 env。env 存在时,面板可操作但顶部警告(详见 §6 双标记)。
## 9. 边界与错误处理
| 场景 | 行为 |
|---|---|
| 模型不支持 effort | 面板挂载但禁用,文字提示 + 仅允许 Esc详见 §6 |
| env override 设定 | 顶部加黄色警告行 `⚠ CLAUDE_CODE_EFFORT_LEVEL=<value> overrides this session`光标可移动Enter 仍写 settings 但顶部警告解释生效值不变 |
| cursor === 'ultracode' 时 Enter | 走分支 B输出引导文案不调 executeEffort |
| settings 写入失败(磁盘满/权限) | `executeEffort` 现有错误路径会返回 `result.error`面板沿用onDone 输出错误消息 |
| 终端窄屏 < 60 cols | 退化为垂直列表,不阻塞首版 |
| 用户按 Ctrl+C 之外的中断信号 | 视同 Esc`onDone("Effort unchanged.")` |
| 面板挂载后 AppState 被外部改变(如 `/model` 切换) | cursor **不订阅** active 变化,挂载时计算一次初始值后只跟随用户操作。若用户切了 model 想看新档位,关掉面板重开即可。简化实现,行为可预测 |
## 10. 测试计划
### 纯函数(`effortPanelState.test.ts`
- `moveLeft(cursor)` 在 low 处保持 low
- `moveRight(cursor)` 在 ultracode 处保持 ultracode
- `home(cursor)` / `end(cursor)` 边界
- `getInitialCursor(appStateEffort, envOverride, model)` 优先级
- `isUltracode(cursor)` 守卫
### 组件(`EffortPanel.test.tsx`
渲染:
- 无 env 时显示基本形态
- env override 时顶部警告 + 双标记
- 模型不支持时禁用面板
- ultracode 副标签 `xhigh + workflows` 出现
键盘:
- `←` 移动光标、`→` 移动光标、`Home/End` 跳转
- Enter 在普通档位 → 调用 executeEffort、onDone 收到正确 message
- Enter 在 ultracode → 不调 executeEffort、onDone 收到引导文案
- Esc → 不调 executeEffort、onDone 收到 `"Effort unchanged."`
集成(`effort.tsx` 的 call 函数):
- 无参 → 返回 `<EffortPanel>` JSX
- 有参 → 不渲染面板,走 executeEffort
### 波纹相关(第二阶段)
- `pickChar(intensity)` 各强度边界
- `computeRippleLine` 固定 time 快照
- `mergeLayers` 文字覆盖背景、文字字符永远胜出
- `useRippleFrame` 仅在 cursor === 'ultracode' 时订阅时钟
## 11. 实现阶段划分(两个 commit
### Commit 1基础面板先做
- 新增 `src/components/EffortPanel/EffortPanel.tsx`
- 新增 `src/components/EffortPanel/effortPanelState.ts`
- 新增 `src/components/EffortPanel/__tests__/EffortPanel.test.tsx`
- 新增 `src/components/EffortPanel/__tests__/effortPanelState.test.ts`
- 改造 `src/commands/effort/effort.tsx`:无参时返回 `<EffortPanel>`,有参维持原状
- 运行 `bun run precheck`,必须零错误通过
- commit message: `feat(effort): /effort 无参时打开横向 slider 选择面板`
### Commit 2波纹动画基础稳定后再做
- 新增 `src/components/EffortPanel/rippleAnimation.ts`
- 新增 `src/components/EffortPanel/useRippleFrame.ts`
- 新增对应测试
-`EffortPanel.tsx` 中叠加渲染(仅 cursor === 'ultracode' 时)
- 运行 `bun run precheck`
- commit message: `feat(effort): ultracode 档位铺满波纹背景动画`
两阶段切开的好处:动画是创意工作,可能在调参上反复;基础功能稳定后即使动画翻车也能直接 revert 第二个 commit不影响主功能。
## 12. 验收清单
- [ ] `/effort` 无参打开面板,光标停在当前生效档
- [ ] `←/→` 移动光标,到边界不再继续
- [ ] Enter 在 5 档之一时写 settings + AppState + 输出与 `/effort X` 同款消息
- [ ] Enter 在 ultracode 时输出引导文案,不写任何状态
- [ ] Esc 时不写任何状态,输出 `"Effort unchanged."`
- [ ] env override 时顶部警告 + 双标记
- [ ] 模型不支持时面板禁用,仅 Esc 可退出
- [ ] `/effort low|auto|help|current` 等原有路径行为不变
- [ ] `bun run precheck` 零错误

View File

@@ -0,0 +1,132 @@
# Ripgrep System Fallback — Design
**Date:** 2026-06-15
**Status:** Approved (pending spec review)
**Topic:** Make ripgrep gracefully degrade to system `rg` when the bundled/builtin binary is unavailable on the current platform (e.g. Android/Termux).
## Problem
`src/utils/ripgrep.ts` `getRipgrepConfig()` has three resolution branches:
1. `USE_BUILTIN_RIPGREP=0` → look up `rg` on `PATH`
2. `isInBundledMode()` → bun-internal embedded rg
3. Otherwise → `vendor/ripgrep/<arch>-<platform>/rg` (builtin)
On Android/Termux, all three fail:
- The user has not opted into system rg.
- Bun does not publish Android builds, so `isInBundledMode()` is false.
- `scripts/postinstall.cjs:81` throws `Unsupported platform: android`, so no builtin binary is ever downloaded. `vendor/ripgrep/` contains no `arm64-android` directory.
Net effect: spawn of a nonexistent path → `ENOENT` → user sees "ripgrep 缺失" with no recovery path other than manually setting `USE_BUILTIN_RIPGREP=0`. The discovery pipeline (`Grep`/`Glob` tools, file suggestions, hooks) all fail in the same way.
More generally, the same breakage occurs on any platform where the builtin binary is missing for any reason (incomplete install, custom platform, deleted vendor directory). The current code has no graceful degradation.
## Goals
- On any platform, when the builtin/bundled ripgrep is unavailable, automatically fall back to `rg` on `PATH`.
- Surface the fallback clearly to the user via `/doctor` and a one-line startup warning, so they understand why they are not on the bundled rg and what to do if the system rg is also missing.
- Do not change behavior on platforms where the builtin rg works (macOS, Linux, Windows).
## Non-Goals
- Downloading or shipping an Android-native ripgrep binary.
- Adding a REPL persistent status indicator.
- Touching `USE_BUILTIN_RIPGREP` semantics for users who already opt into system rg.
- Modifying build / `postinstall.cjs` platform mapping.
## Design
### Decision chain (`getRipgrepConfig`)
The function gains an existence check and a system-rg fallback. The order of existing branches is preserved.
```
1. USE_BUILTIN_RIPGREP=0 (user-opt) → system rg mode='system' note=undefined
2. isInBundledMode() → bun embedded rg mode='embedded' note=undefined
3. Compute builtin path; existsSync(rgPath)?
✓ true → builtin rg mode='builtin' note=undefined
✓ false → findExecutable('rg', [])
✓ found → system rg (auto fallback) mode='system' note='fallback: builtin rg unavailable on <platform>, using system rg'
✗ missing → keep builtin path (let upper layer ENOENT) mode='builtin' note='no ripgrep available on <platform>; install via apt/pkg/brew/...'
```
Rationale for the missing-system-rg branch returning the (nonexistent) builtin path: it preserves the historical spawn behavior so existing error-handling paths in `ripGrepRaw` and callers continue to see `ENOENT`. The new `note` field carries the human-readable explanation; the spawn itself still fails the same way.
`existsSync` is a single synchronous syscall; `getRipgrepConfig` is already memoized via lodash, so the cost is paid once per process.
### Status API (`getRipgrepStatus`)
```ts
type RipgrepStatus = {
mode: 'system' | 'builtin' | 'embedded' // unchanged
path: string // unchanged
working: boolean | null // unchanged
note?: string // NEW — human-readable hint
}
```
The internal `ripgrepStatus` singleton also gains `note?: string`. `testRipgrepOnFirstUse` propagates the note from the active config.
The `note` value is sourced from `getRipgrepConfig()` (the source of truth), so the API remains a single read; no second lookup.
### UI — `/doctor`
`src/screens/Doctor.tsx` renders the existing `Search:` line plus the note when present. Two example outputs:
```
Search: OK (system rg fallback — builtin ripgrep unavailable on android)
Search: Not working (no ripgrep available on android — install via apt/pkg/brew)
```
`src/utils/doctorDiagnostic.ts` extends the `ripgrepStatus` object it returns to include `note`.
### UI — startup warning
A single check near the end of `src/entrypoints/init.ts` reads `getRipgrepStatus()`. If `note` is set, it writes one line to stderr:
```
[ripgrep] fallback: builtin rg unavailable on android, using system rg
```
Constraints:
- Non-blocking — does not throw or exit.
- Fires at most once per process (memoized config + idempotent init).
- Goes to stderr so it does not corrupt pipe mode (`-p`) stdout.
- No retry, no telemetry beyond existing `tengu_ripgrep_availability`.
### Testing
New test file `src/utils/__tests__/ripgrepDecision.test.ts` (or extend an existing one) covers the five branches:
1. `USE_BUILTIN_RIPGREP=0` and `rg` on PATH → `mode='system'`, `note=undefined`.
2. `isInBundledMode()``mode='embedded'`, `note=undefined`.
3. Builtin path exists → `mode='builtin'`, `note=undefined`.
4. Builtin path missing, `rg` on PATH → `mode='system'`, `note` set.
5. Builtin path missing, `rg` not on PATH → `mode='builtin'`, `note` set (path is the nonexistent builtin path).
Mocks: `existsSync` (via `fs` module), `findExecutable`, `isInBundledMode`, `process.env.USE_BUILTIN_RIPGREP`, `process.platform`. Follow the project's mock conventions (see `tests/mocks/`); no business-module mocking.
Existing `doctorDiagnostic` tests: extend to assert `note` is propagated; update any snapshots.
## Risks
- **Behavior preservation on supported platforms:** the `existsSync` check only changes the path when the builtin file is genuinely absent. On macOS/Linux/Windows the builtin binary always exists post-install, so the decision chain resolves to `mode='builtin'` exactly as today. Verified by the test for branch 3.
- **`note` field addition is backward-compatible:** optional field; existing consumers ignore it.
- **Memoization:** `getRipgrepConfig` is memoized for the process lifetime. If a user installs ripgrep mid-session, the fallback will not trigger until restart. Acceptable — matches existing behavior for `USE_BUILTIN_RIPGREP` changes.
- **Platform string in `note`:** uses `process.platform` directly (`'android'`, `'linux'`, `'darwin'`, `'win32'`). No translation; the message is diagnostic, not user-facing marketing copy.
## Out of Scope (YAGNI)
- Android prebuilt binary download.
- Persistent REPL status indicator.
- Build-time vendor changes.
- Telemetry beyond what `testRipgrepOnFirstUse` already emits.
## Acceptance Criteria
- On a platform where the builtin rg binary is missing and `rg` is on `PATH`, `getRipgrepStatus()` returns `mode='system'`, `path=<resolved system rg>`, `note` set to a non-empty human-readable string.
- On a platform where neither builtin nor system rg is available, `/doctor` displays `Not working` plus the install hint.
- The startup warning fires exactly once per session when `note` is set.
- All existing ripgrep tests pass unchanged on macOS/Linux dev machines.
- `bun run precheck` is green.

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-best", "name": "claude-code-best",
"version": "2.6.13", "version": "2.7.1",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module", "type": "module",
"author": "claude-code-best <claude-code-best@proton.me>", "author": "claude-code-best <claude-code-best@proton.me>",

View File

@@ -20,6 +20,7 @@ export { FileEditTool } from './tools/FileEditTool/FileEditTool.js'
export { FileReadTool } from './tools/FileReadTool/FileReadTool.js' export { FileReadTool } from './tools/FileReadTool/FileReadTool.js'
export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js' export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js'
export { GlobTool } from './tools/GlobTool/GlobTool.js' export { GlobTool } from './tools/GlobTool/GlobTool.js'
export { GoalTool } from './tools/GoalTool/GoalTool.js'
export { GrepTool } from './tools/GrepTool/GrepTool.js' export { GrepTool } from './tools/GrepTool/GrepTool.js'
export { LSPTool } from './tools/LSPTool/LSPTool.js' export { LSPTool } from './tools/LSPTool/LSPTool.js'
export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js' export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js'
@@ -61,9 +62,16 @@ export { TeamDeleteTool } from './tools/TeamDeleteTool/TeamDeleteTool.js'
export { TerminalCaptureTool } from './tools/TerminalCaptureTool/TerminalCaptureTool.js' export { TerminalCaptureTool } from './tools/TerminalCaptureTool/TerminalCaptureTool.js'
export { VerifyPlanExecutionTool } from './tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.js' export { VerifyPlanExecutionTool } from './tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.js'
export { WebBrowserTool } from './tools/WebBrowserTool/WebBrowserTool.js' export { WebBrowserTool } from './tools/WebBrowserTool/WebBrowserTool.js'
export { WorkflowTool } from './tools/WorkflowTool/WorkflowTool.js' // WorkflowTool 实现已迁移到 @claude-code-best/workflow-engine独立包端口适配
export { initBundledWorkflows } from './tools/WorkflowTool/bundled/index.js' // 注意:本 commit 移除了 builtin-tools 的 WorkflowTool 值导出和 getWorkflowCommands。
export { getWorkflowCommands } from './tools/WorkflowTool/createWorkflowCommand.js' // - WorkflowTool 工厂:改由 @claude-code-best/workflow-engine 的 createWorkflowTool 提供
// - getWorkflowCommands已移除功能迁至 src/workflow/namedWorkflowCommands.ts
// 第三方若从本包 import 这两个符号,需切换到新路径。
export {
createWorkflowTool,
WORKFLOW_TOOL_NAME,
type WorkflowToolDescriptor,
} from '@claude-code-best/workflow-engine'
// Constants // Constants
export { export {

View File

@@ -52,7 +52,6 @@ import { lazySchema } from 'src/utils/lazySchema.js'
import { logError } from 'src/utils/log.js' import { logError } from 'src/utils/log.js'
import { isAutoMemFile } from 'src/utils/memoryFileDetection.js' import { isAutoMemFile } from 'src/utils/memoryFileDetection.js'
import { createUserMessage } from 'src/utils/messages.js' import { createUserMessage } from 'src/utils/messages.js'
import { getCanonicalName, getMainLoopModel } from 'src/utils/model/model.js'
import { import {
mapNotebookCellsToToolResult, mapNotebookCellsToToolResult,
readNotebook, readNotebook,
@@ -409,9 +408,7 @@ export const FileReadTool = buildTool({
renderToolResultMessage, renderToolResultMessage,
// UI.tsx:140 — ALL types render summary chrome only: "Read N lines", // UI.tsx:140 — ALL types render summary chrome only: "Read N lines",
// "Read image (42KB)". Never the content itself. The model-facing // "Read image (42KB)". Never the content itself. The model-facing
// serialization (below) sends content + CYBER_RISK_MITIGATION_REMINDER // serialization (below) sends content + line prefixes; UI shows none of it.
// + line prefixes; UI shows none of it. Nothing to index. Caught by
// the render-fidelity test when this initially claimed file.content.
extractSearchText() { extractSearchText() {
return '' return ''
}, },
@@ -694,12 +691,7 @@ export const FileReadTool = buildTool({
let content: string let content: string
if (data.file.content) { if (data.file.content) {
content = content = memoryFileFreshnessPrefix(data) + formatFileLines(data.file)
memoryFileFreshnessPrefix(data) +
formatFileLines(data.file) +
(shouldIncludeFileReadMitigation()
? CYBER_RISK_MITIGATION_REMINDER
: '')
} else { } else {
// Determine the appropriate warning message // Determine the appropriate warning message
content = content =
@@ -727,17 +719,6 @@ function formatFileLines(file: { content: string; startLine: number }): string {
return addLineNumbers(file) return addLineNumbers(file)
} }
export const CYBER_RISK_MITIGATION_REMINDER =
'\n\n<system-reminder>\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n</system-reminder>\n'
// Models where cyber risk mitigation should be skipped
const MITIGATION_EXEMPT_MODELS = new Set(['claude-opus-4-6'])
function shouldIncludeFileReadMitigation(): boolean {
const shortName = getCanonicalName(getMainLoopModel())
return !MITIGATION_EXEMPT_MODELS.has(shortName)
}
/** /**
* Side-channel from call() to mapToolResultToToolResultBlockParam: mtime * Side-channel from call() to mapToolResultToToolResultBlockParam: mtime
* of auto-memory files, keyed by the `data` object identity. Avoids * of auto-memory files, keyed by the `data` object identity. Avoids

View File

@@ -0,0 +1,253 @@
import { z } from 'zod/v4'
import { buildTool, type ToolDef } from 'src/Tool.js'
import { jsonStringify } from 'src/utils/slowOperations.js'
import { lazySchema } from 'src/utils/lazySchema.js'
import {
completeGoal,
formatGoalElapsed,
formatGoalStatusLabel,
getGoal,
recordBlockedAttempt,
} from 'src/services/goal/goalState.js'
import { persistCurrentGoal } from 'src/services/goal/goalStorage.js'
import { GOAL_TOOL_NAME } from './constants.js'
import { DESCRIPTION, generatePrompt } from './prompt.js'
function toolLog(msg: string): void {
try {
const { logForDebugging } =
require('src/utils/debug.js') as typeof import('src/utils/debug.js')
logForDebugging(`[goal] tool: ${msg}`)
} catch {
/* debug not available */
}
}
const inputSchema = lazySchema(() =>
z.strictObject({
action: z
.enum(['get', 'update'])
.optional()
.describe(
'Action to perform: "get" to read status, "update" to mark complete or blocked. Defaults to "update" if status is provided, otherwise "get".',
),
status: z
.enum(['complete', 'blocked'])
.optional()
.describe(
'Required for "update". Only "complete" or "blocked" are accepted.',
),
reason: z
.string()
.optional()
.describe('Explanation for the status change. Required for "update".'),
}),
)
type InputSchema = ReturnType<typeof inputSchema>
const outputSchema = lazySchema(() =>
z.object({
success: z.boolean(),
goal: z
.object({
objective: z.string(),
status: z.string(),
tokensUsed: z.number(),
tokenBudget: z.number().nullable(),
elapsed: z.string(),
turnsExecuted: z.number(),
})
.optional(),
message: z.string().optional(),
report: z.string().optional(),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
export type Input = z.infer<InputSchema>
export type Output = z.infer<OutputSchema>
function buildGoalSnapshot() {
const goal = getGoal()
if (!goal) return undefined
return {
objective: goal.objective,
status: formatGoalStatusLabel(goal.status),
tokensUsed: goal.tokensUsed,
tokenBudget: goal.tokenBudget,
elapsed: formatGoalElapsed(goal),
turnsExecuted: goal.turnsExecuted,
}
}
function buildCompletionReport(): string {
const goal = getGoal()
if (!goal) return ''
const budget =
goal.tokenBudget !== null
? `Token usage: ${goal.tokensUsed} / ${goal.tokenBudget}`
: `Token usage: ${goal.tokensUsed}`
return [
'Goal achieved — usage report:',
` ${budget}`,
` Active time: ${formatGoalElapsed(goal)}`,
` Continuation turns: ${goal.turnsExecuted}`,
].join('\n')
}
export const GoalTool = buildTool({
name: GOAL_TOOL_NAME,
searchHint: 'get or update the active goal (complete/blocked)',
maxResultSizeChars: 10_000,
async description() {
return DESCRIPTION
},
async prompt() {
return generatePrompt()
},
get inputSchema(): InputSchema {
return inputSchema()
},
get outputSchema(): OutputSchema {
return outputSchema()
},
userFacingName() {
return 'Goal'
},
shouldDefer: true,
isConcurrencySafe() {
return true
},
isReadOnly(input: Input) {
const action = input.action ?? (input.status ? 'update' : 'get')
return action === 'get'
},
toAutoClassifierInput(input: Input) {
const action = input.action ?? (input.status ? 'update' : 'get')
if (action === 'get') return 'get goal status'
return `update goal: ${input.status}${input.reason ?? ''}`
},
async checkPermissions(input: Input) {
return { behavior: 'allow' as const, updatedInput: input }
},
renderToolUseMessage(input: Input) {
const action = input.action ?? (input.status ? 'update' : 'get')
if (action === 'get') return 'Checking goal status…'
return `Updating goal: ${input.status}${input.reason ? `${input.reason}` : ''}`
},
renderToolResultMessage(output: Output) {
if (output.error) return `Goal error: ${output.error}`
if (output.report) return output.report
if (output.goal) {
return `Goal "${output.goal.objective}" — ${output.goal.status}`
}
return output.message ?? 'Done'
},
renderToolUseRejectedMessage() {
return 'Goal operation rejected'
},
async call(input: Input): Promise<{ data: Output }> {
const action = input.action ?? (input.status ? 'update' : 'get')
toolLog(
`called: action=${action}${input.status ? ` status=${input.status}` : ''}${input.reason ? ` reason="${input.reason.slice(0, 60)}"` : ''}`,
)
if (action === 'get') {
const snapshot = buildGoalSnapshot()
if (!snapshot) {
return {
data: {
success: true,
message:
'No active goal. The user can set one with `/goal <objective>`.',
},
}
}
return { data: { success: true, goal: snapshot } }
}
// action === 'update'
if (!input.status) {
return {
data: {
success: false,
error:
'The "status" field is required for update. Use "complete" or "blocked".',
},
}
}
const goal = getGoal()
if (!goal) {
return {
data: {
success: false,
error: 'No active goal to update.',
},
}
}
if (input.status === 'complete') {
const report = buildCompletionReport()
completeGoal()
persistCurrentGoal()
return {
data: {
success: true,
goal: buildGoalSnapshot(),
report,
},
}
}
// status === 'blocked'
const reason = input.reason ?? 'unspecified blocker'
const result = recordBlockedAttempt(reason)
if (!result) {
return {
data: {
success: false,
error: 'Goal is not in a state that accepts blocked attempts.',
},
}
}
persistCurrentGoal()
if (result.status === 'blocked') {
return {
data: {
success: true,
goal: buildGoalSnapshot(),
message: `Goal marked as blocked after ${result.attempts} consecutive attempts. Reason: ${reason}`,
},
}
}
return {
data: {
success: true,
goal: buildGoalSnapshot(),
message: `Blocked attempt ${result.attempts} recorded. The goal remains active — the same condition must persist for 3 consecutive turns before it is marked blocked.`,
},
}
},
mapToolResultToToolResultBlockParam(content: Output, toolUseID: string) {
if (content.error) {
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: `Error: ${content.error}`,
is_error: true,
}
}
const parts: string[] = []
if (content.message) parts.push(content.message)
if (content.report) parts.push(content.report)
if (content.goal) parts.push(jsonStringify(content.goal))
return {
tool_use_id: toolUseID,
type: 'tool_result' as const,
content: parts.join('\n') || 'Done',
}
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1 @@
export const GOAL_TOOL_NAME = 'GoalTool'

View File

@@ -0,0 +1,38 @@
export const DESCRIPTION =
'Get or update the active goal status. The model may only mark a goal as "complete" or "blocked".'
export function generatePrompt(): string {
return `Use this tool to interact with the active thread goal.
## Actions
### get
Returns the current goal state (objective, status, token usage, elapsed time, turns executed).
No input required beyond \`action: "get"\`.
### update
Transition the goal to a terminal status. Only two values are accepted:
- **complete** — All requirements are verified (see Completion Audit below).
- **blocked** — An insurmountable obstacle has persisted for 3+ consecutive turns (see Blocked Audit below).
When marking complete, provide a brief \`reason\` summarising what was achieved.
When marking blocked, provide a \`reason\` describing the specific blocker.
## Completion Audit (required before marking complete)
1. Derive concrete requirements from the objective.
2. Preserve the original scope — do not redefine success around existing work.
3. For every requirement, identify authoritative evidence (test output, file content, command result).
4. Treat tests and manifests as evidence only after confirming they cover the requirement.
5. Treat uncertain or indirect evidence as "not achieved".
6. The audit must PROVE completion, not merely fail to find remaining work.
## Blocked Audit (required before marking blocked)
1. The same blocking condition must persist across at least 3 consecutive continuation turns.
2. "Difficult", "slow", or "partially incomplete" is NOT blocked.
3. Only genuinely insurmountable obstacles qualify (missing credentials, external service down, etc.).
## Important
- You cannot pause, resume, or clear a goal — only the user can do that via \`/goal\`.
- If no goal is active, \`get\` returns a message saying so; \`update\` returns an error.
- On completion, the tool result includes a usage report (tokens, time, turns).`
}

View File

@@ -5,6 +5,7 @@ import { formatFileSize } from 'src/utils/format.js'
import { lazySchema } from 'src/utils/lazySchema.js' import { lazySchema } from 'src/utils/lazySchema.js'
import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js' import type { PermissionDecision } from 'src/utils/permissions/PermissionResult.js'
import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js' import { getRuleByContentsForTool } from 'src/utils/permissions/permissions.js'
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import { isPreapprovedHost } from './preapproved.js' import { isPreapprovedHost } from './preapproved.js'
import { DESCRIPTION, WEB_FETCH_TOOL_NAME } from './prompt.js' import { DESCRIPTION, WEB_FETCH_TOOL_NAME } from './prompt.js'
import { import {
@@ -16,6 +17,7 @@ import {
import { import {
applyPromptToMarkdown, applyPromptToMarkdown,
type FetchedContent, type FetchedContent,
fetchContentWithTavily,
getURLMarkdownContent, getURLMarkdownContent,
isPreapprovedUrl, isPreapprovedUrl,
MAX_MARKDOWN_LENGTH, MAX_MARKDOWN_LENGTH,
@@ -211,6 +213,72 @@ ${DESCRIPTION}`
) { ) {
const start = Date.now() const start = Date.now()
// Select backend: settings.webFetchAdapter → default 'tavily'
const settings = getSettings_DEPRECATED()
const backend = settings.webFetchAdapter ?? 'tavily'
// Tavily path: /extract returns Markdown directly — skip turndown + queryHaiku
if (backend === 'tavily') {
const response = await fetchContentWithTavily(url, abortController)
if ('type' in response && response.type === 'redirect') {
const statusText = 'See Other'
const message = `REDIRECT DETECTED: The URL redirects to a different host.
Original URL: ${(response as { originalUrl: string }).originalUrl}
Redirect URL: ${(response as { redirectUrl: string }).redirectUrl}
Please use WebFetch again with the redirect URL.`
const output: Output = {
bytes: Buffer.byteLength(message),
code: 302,
codeText: statusText,
result: message,
durationMs: Date.now() - start,
url,
}
return { data: output }
}
const {
content,
bytes,
code,
codeText,
contentType,
persistedPath,
persistedSize,
} = response as FetchedContent
let result = content
if (prompt && prompt.trim()) {
// Tavily extract returns raw Markdown — if user provided a prompt,
// still run secondary model call for content processing
result = await applyPromptToMarkdown(
prompt,
content,
abortController.signal,
isNonInteractiveSession,
isPreapprovedUrl(url),
)
}
if (persistedPath) {
result += `\n\n[Binary content (${contentType}, ${formatFileSize(persistedSize ?? bytes)}) also saved to ${persistedPath}]`
}
const output: Output = {
bytes,
code,
codeText,
result,
durationMs: Date.now() - start,
url,
}
return { data: output }
}
// HTTP direct path (original behavior): fetch + turndown + queryHaiku
const response = await getURLMarkdownContent(url, abortController) const response = await getURLMarkdownContent(url, abortController)
// Check if we got a redirect to a different host // Check if we got a redirect to a different host

View File

@@ -17,23 +17,9 @@ import { asSystemPrompt } from 'src/utils/systemPromptType.js'
import { isPreapprovedHost } from './preapproved.js' import { isPreapprovedHost } from './preapproved.js'
import { makeSecondaryModelPrompt } from './prompt.js' import { makeSecondaryModelPrompt } from './prompt.js'
// Custom error classes for domain blocking const DEFAULT_TAVILY_EXTRACT_URL = 'https://tavily.claude-code-best.win/extract'
class DomainBlockedError extends Error {
constructor(domain: string) {
super(`Claude Code is unable to fetch from ${domain}`)
this.name = 'DomainBlockedError'
}
}
class DomainCheckFailedError extends Error {
constructor(domain: string) {
super(
`Unable to verify if domain ${domain} is safe to fetch. This may be due to network restrictions or enterprise security policies blocking claude.ai.`,
)
this.name = 'DomainCheckFailedError'
}
}
// Custom error class for egress proxy blocks
class EgressBlockedError extends Error { class EgressBlockedError extends Error {
constructor(public readonly domain: string) { constructor(public readonly domain: string) {
super( super(
@@ -68,18 +54,8 @@ const URL_CACHE = new LRUCache<string, CacheEntry>({
ttl: CACHE_TTL_MS, ttl: CACHE_TTL_MS,
}) })
// Separate cache for preflight domain checks. URL_CACHE is URL-keyed, so
// fetching two paths on the same domain triggers two identical preflight
// HTTP round-trips to api.anthropic.com. This hostname-keyed cache avoids
// that. Only 'allowed' is cached — blocked/failed re-check on next attempt.
const DOMAIN_CHECK_CACHE = new LRUCache<string, true>({
max: 128,
ttl: 5 * 60 * 1000, // 5 minutes — shorter than URL_CACHE TTL
})
export function clearWebFetchCache(): void { export function clearWebFetchCache(): void {
URL_CACHE.clear() URL_CACHE.clear()
DOMAIN_CHECK_CACHE.clear()
} }
function responseHeaderToString(value: unknown): string | undefined { function responseHeaderToString(value: unknown): string | undefined {
@@ -141,13 +117,19 @@ const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024
// Timeout for the main HTTP fetch request (60 seconds). // Timeout for the main HTTP fetch request (60 seconds).
// Prevents hanging indefinitely on slow/unresponsive servers. // Prevents hanging indefinitely on slow/unresponsive servers.
const FETCH_TIMEOUT_MS = 60_000 // Overridable via settings.webFetchHttpTimeoutMs (set in /web-tools panel).
const DEFAULT_FETCH_TIMEOUT_MS = 60_000
// Timeout for the domain blocklist preflight check (10 seconds). function getFetchTimeoutMs(): number {
const DOMAIN_CHECK_TIMEOUT_MS = 10_000 const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
webFetchHttpTimeoutMs?: number
}
return settings.webFetchHttpTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
}
// Cap same-host redirect hops. Without this a malicious server can return // Cap same-host redirect hops. Without this a malicious server can return
// a redirect loop (/a → /b → /a …) and the per-request FETCH_TIMEOUT_MS // a redirect loop (/a → /b → /a …) and the per-request timeout
// (controlled by settings.webFetchHttpTimeoutMs)
// resets on every hop, hanging the tool until user interrupt. 10 matches // resets on every hop, hanging the tool until user interrupt. 10 matches
// common client defaults (axios=5, follow-redirects=21, Chrome=20). // common client defaults (axios=5, follow-redirects=21, Chrome=20).
const MAX_REDIRECTS = 10 const MAX_REDIRECTS = 10
@@ -196,40 +178,6 @@ export function validateURL(url: string): boolean {
return true return true
} }
type DomainCheckResult =
| { status: 'allowed' }
| { status: 'blocked' }
| { status: 'check_failed'; error: Error }
export async function checkDomainBlocklist(
domain: string,
): Promise<DomainCheckResult> {
if (DOMAIN_CHECK_CACHE.has(domain)) {
return { status: 'allowed' }
}
try {
const response = await axios.get(
`https://api.anthropic.com/api/web/domain_info?domain=${encodeURIComponent(domain)}`,
{ timeout: DOMAIN_CHECK_TIMEOUT_MS },
)
if (response.status === 200) {
if (response.data.can_fetch === true) {
DOMAIN_CHECK_CACHE.set(domain, true)
return { status: 'allowed' }
}
return { status: 'blocked' }
}
// Non-200 status but didn't throw
return {
status: 'check_failed',
error: new Error(`Domain check returned status ${response.status}`),
}
} catch (e) {
logError(e)
return { status: 'check_failed', error: e as Error }
}
}
/** /**
* Check if a redirect is safe to follow * Check if a redirect is safe to follow
* Allows redirects that: * Allows redirects that:
@@ -299,7 +247,7 @@ export async function getWithPermittedRedirects(
try { try {
return await axios.get(url, { return await axios.get(url, {
signal, signal,
timeout: FETCH_TIMEOUT_MS, timeout: getFetchTimeoutMs(),
maxRedirects: 0, maxRedirects: 0,
responseType: 'arraybuffer', responseType: 'arraybuffer',
maxContentLength: MAX_HTTP_CONTENT_LENGTH, maxContentLength: MAX_HTTP_CONTENT_LENGTH,
@@ -412,23 +360,6 @@ export async function getURLMarkdownContent(
const hostname = parsedUrl.hostname const hostname = parsedUrl.hostname
// Check if the user has opted to skip the blocklist check
// This is for enterprise customers with restrictive security policies
// that prevent outbound connections to claude.ai
const settings = getSettings_DEPRECATED()
if (settings.skipWebFetchPreflight === false) {
const checkResult = await checkDomainBlocklist(hostname)
switch (checkResult.status) {
case 'allowed':
// Continue with the fetch
break
case 'blocked':
throw new DomainBlockedError(hostname)
case 'check_failed':
throw new DomainCheckFailedError(hostname)
}
}
if (process.env.USER_TYPE === 'ant') { if (process.env.USER_TYPE === 'ant') {
logEvent('tengu_web_fetch_host', { logEvent('tengu_web_fetch_host', {
hostname: hostname:
@@ -436,13 +367,6 @@ export async function getURLMarkdownContent(
}) })
} }
} catch (e) { } catch (e) {
if (
e instanceof DomainBlockedError ||
e instanceof DomainCheckFailedError
) {
// Expected user-facing failures - re-throw without logging as internal error
throw e
}
logError(e) logError(e)
} }
@@ -513,6 +437,109 @@ export async function getURLMarkdownContent(
return entry return entry
} }
/**
* Fetch URL content via Tavily Extract API, which directly returns Markdown.
* This skips the HTML→Markdown conversion (turndown) and the secondary
* model call (queryHaiku) — Tavily already delivers clean Markdown.
*/
export async function fetchContentWithTavily(
url: string,
abortController: AbortController,
): Promise<FetchedContent | RedirectInfo> {
if (!validateURL(url)) {
throw new Error('Invalid URL')
}
// Check cache (LRUCache handles TTL automatically)
const cachedEntry = URL_CACHE.get(url)
if (cachedEntry) {
return {
bytes: cachedEntry.bytes,
code: cachedEntry.code,
codeText: cachedEntry.codeText,
content: cachedEntry.content,
contentType: cachedEntry.contentType,
persistedPath: cachedEntry.persistedPath,
persistedSize: cachedEntry.persistedSize,
}
}
let parsedUrl: URL
try {
parsedUrl = new URL(url)
} catch {
throw new Error('Invalid URL')
}
// Upgrade http to https if needed
if (parsedUrl.protocol === 'http:') {
parsedUrl.protocol = 'https:'
url = parsedUrl.toString()
}
const abortSignal = abortController.signal
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
tavilyEndpointUrl?: string
}
const baseUrl = settings.tavilyEndpointUrl || DEFAULT_TAVILY_EXTRACT_URL
// Derive extract URL from the base Tavily endpoint
const extractUrl = baseUrl.endsWith('/search')
? baseUrl.replace(/\/search$/, '/extract')
: baseUrl.endsWith('/extract')
? baseUrl
: `${baseUrl.replace(/\/$/, '')}/extract`
const response = await axios.post<{ url: string; raw_content: string }>(
extractUrl,
{
urls: [url],
},
{
signal: abortSignal,
timeout: getFetchTimeoutMs(),
headers: { 'Content-Type': 'application/json' },
},
)
if (abortSignal.aborted) {
throw new AbortError()
}
const rawContent = response.data?.raw_content ?? ''
// If raw_content is a JSON string (extract may return {url:..., raw_content:...}
// per URL), unwrap it.
let markdownContent = rawContent
if (!markdownContent.trim()) {
// Try to extract from results array
const resp = response.data as unknown as {
results?: Array<{ raw_content?: string }>
}
const results = resp.results ?? []
if (results.length > 0 && results[0].raw_content) {
markdownContent = results[0].raw_content
}
}
if (!markdownContent.trim()) {
throw new Error(
`Tavily Extract returned empty content for ${url}. The page may require authentication or JavaScript rendering.`,
)
}
const contentBytes = Buffer.byteLength(markdownContent)
const entry: CacheEntry = {
bytes: contentBytes,
code: 200,
codeText: 'OK',
content: markdownContent,
contentType: 'text/markdown',
}
URL_CACHE.set(url, entry, { size: Math.max(1, contentBytes) })
return entry
}
export async function applyPromptToMarkdown( export async function applyPromptToMarkdown(
prompt: string, prompt: string,
markdownContent: string, markdownContent: string,

View File

@@ -1,21 +1,21 @@
import { afterEach, describe, expect, mock, test } from 'bun:test' import { afterEach, describe, expect, test } from 'bun:test'
let isFirstPartyBaseUrl = true let mockSettingsWebSearchAdapter: string | undefined
// Only mock the external dependency that controls adapter selection // Mock settings to avoid depending on the on-disk settings.json file.
mock.module('src/utils/model/providers.js', () => ({ // Other tests running in the same process may have persisted adapter choices.
isFirstPartyAnthropicBaseUrl: () => isFirstPartyBaseUrl, let { getSettings_DEPRECATED } = await import('src/utils/settings/settings.js')
getAPIProvider: () => 'firstParty', const realGetSettings = getSettings_DEPRECATED
getAPIProviderForStatsig: () => 'firstParty',
}))
const { createAdapter } = await import('../adapters/index') // We can't mock getSettings_DEPRECATED directly without mocking the whole module,
// so we test using WEB_SEARCH_ADAPTER env var which takes priority anyway.
// This test focuses on the env-driven selection which is the primary path.
let { createAdapter } = await import('../adapters/index')
const originalWebSearchAdapter = process.env.WEB_SEARCH_ADAPTER const originalWebSearchAdapter = process.env.WEB_SEARCH_ADAPTER
afterEach(() => { afterEach(() => {
isFirstPartyBaseUrl = true
if (originalWebSearchAdapter === undefined) { if (originalWebSearchAdapter === undefined) {
delete process.env.WEB_SEARCH_ADAPTER delete process.env.WEB_SEARCH_ADAPTER
} else { } else {
@@ -24,6 +24,23 @@ afterEach(() => {
}) })
describe('createAdapter', () => { describe('createAdapter', () => {
test('prioritizes WEB_SEARCH_ADAPTER env var over all other config', () => {
process.env.WEB_SEARCH_ADAPTER = 'api'
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter')
process.env.WEB_SEARCH_ADAPTER = 'bing'
expect(createAdapter().constructor.name).toBe('BingSearchAdapter')
process.env.WEB_SEARCH_ADAPTER = 'brave'
expect(createAdapter().constructor.name).toBe('BraveSearchAdapter')
process.env.WEB_SEARCH_ADAPTER = 'exa'
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter')
process.env.WEB_SEARCH_ADAPTER = 'tavily'
expect(createAdapter().constructor.name).toBe('TavilySearchAdapter')
})
test('reuses the same instance when the selected backend does not change', () => { test('reuses the same instance when the selected backend does not change', () => {
process.env.WEB_SEARCH_ADAPTER = 'brave' process.env.WEB_SEARCH_ADAPTER = 'brave'
@@ -31,7 +48,6 @@ describe('createAdapter', () => {
const secondAdapter = createAdapter() const secondAdapter = createAdapter()
expect(firstAdapter).toBe(secondAdapter) expect(firstAdapter).toBe(secondAdapter)
expect(firstAdapter.constructor.name).toBe('BraveSearchAdapter')
}) })
test('rebuilds the adapter when WEB_SEARCH_ADAPTER changes', () => { test('rebuilds the adapter when WEB_SEARCH_ADAPTER changes', () => {
@@ -42,20 +58,21 @@ describe('createAdapter', () => {
const bingAdapter = createAdapter() const bingAdapter = createAdapter()
expect(bingAdapter).not.toBe(braveAdapter) expect(bingAdapter).not.toBe(braveAdapter)
expect(bingAdapter.constructor.name).toBe('BingSearchAdapter')
}) })
test('selects the API adapter for first-party Anthropic URLs', () => { test('defaults to Tavily when no env var is set', () => {
delete process.env.WEB_SEARCH_ADAPTER delete process.env.WEB_SEARCH_ADAPTER
isFirstPartyBaseUrl = true
expect(createAdapter().constructor.name).toBe('ApiSearchAdapter') const adapter = createAdapter()
}) // The actual adapter may vary if settings.webSearchAdapter is set on disk.
// But we only assert it's one of the valid adapter types.
test('selects the Exa adapter for third-party Anthropic base URLs', () => { const validTypes = [
delete process.env.WEB_SEARCH_ADAPTER 'ApiSearchAdapter',
isFirstPartyBaseUrl = false 'BingSearchAdapter',
'BraveSearchAdapter',
expect(createAdapter().constructor.name).toBe('ExaSearchAdapter') 'ExaSearchAdapter',
'TavilySearchAdapter',
]
expect(validTypes).toContain(adapter.constructor.name)
}) })
}) })

View File

@@ -5,6 +5,7 @@
import axios from 'axios' import axios from 'axios'
import { AbortError } from 'src/utils/errors.js' import { AbortError } from 'src/utils/errors.js'
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js' import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
const FETCH_TIMEOUT_MS = 30_000 const FETCH_TIMEOUT_MS = 30_000
@@ -156,6 +157,14 @@ function normalizeSnippet(snippets: string[] | undefined): string | undefined {
} }
function getBraveApiKey(): string { function getBraveApiKey(): string {
// Priority: settings.braveApiKey (from /web-tools panel) > environment variable
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
braveApiKey?: string
}
if (settings.braveApiKey?.trim()) {
return settings.braveApiKey.trim()
}
for (const envVar of BRAVE_API_KEY_ENV_VARS) { for (const envVar of BRAVE_API_KEY_ENV_VARS) {
const value = process.env[envVar]?.trim() const value = process.env[envVar]?.trim()
if (value) { if (value) {

View File

@@ -10,9 +10,10 @@
import axios from 'axios' import axios from 'axios'
import { AbortError } from 'src/utils/errors.js' import { AbortError } from 'src/utils/errors.js'
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js' import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
const EXA_MCP_URL = 'https://mcp.exa.ai/mcp' const DEFAULT_EXA_MCP_URL = 'https://mcp.exa.ai/mcp'
const FETCH_TIMEOUT_MS = 25_000 const FETCH_TIMEOUT_MS = 25_000
export class ExaSearchAdapter implements WebSearchAdapter { export class ExaSearchAdapter implements WebSearchAdapter {
@@ -38,10 +39,24 @@ export class ExaSearchAdapter implements WebSearchAdapter {
const searchType = options.searchType ?? 'auto' const searchType = options.searchType ?? 'auto'
const contextMaxCharacters = options.contextMaxCharacters ?? 10000 const contextMaxCharacters = options.contextMaxCharacters ?? 10000
// Read settings for custom endpoint / API key
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
exaEndpointUrl?: string
exaApiKey?: string
}
const exaUrl = settings.exaEndpointUrl || DEFAULT_EXA_MCP_URL
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
}
if (settings.exaApiKey) {
headers['Authorization'] = `Bearer ${settings.exaApiKey}`
}
let responseText: string let responseText: string
try { try {
const response = await axios.post( const response = await axios.post(
EXA_MCP_URL, exaUrl,
{ {
jsonrpc: '2.0', jsonrpc: '2.0',
id: 1, id: 1,
@@ -60,10 +75,7 @@ export class ExaSearchAdapter implements WebSearchAdapter {
{ {
signal: abortController.signal, signal: abortController.signal,
timeout: FETCH_TIMEOUT_MS, timeout: FETCH_TIMEOUT_MS,
headers: { headers,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
responseType: 'text', responseType: 'text',
}, },
) )

View File

@@ -1,13 +1,18 @@
/** /**
* Search adapter factory — selects the appropriate backend by checking * Search adapter factory — selects the appropriate backend.
* whether the API base URL points to Anthropic's official endpoint. *
* Priority (highest first):
* 1. WEB_SEARCH_ADAPTER environment variable (explicit override)
* 2. settings.webSearchAdapter (user-configurable via /web-tools)
* 3. Default: tavily
*/ */
import { isFirstPartyAnthropicBaseUrl } from 'src/utils/model/providers.js' import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import { ApiSearchAdapter } from './apiAdapter.js' import { ApiSearchAdapter } from './apiAdapter.js'
import { BingSearchAdapter } from './bingAdapter.js' import { BingSearchAdapter } from './bingAdapter.js'
import { BraveSearchAdapter } from './braveAdapter.js' import { BraveSearchAdapter } from './braveAdapter.js'
import { ExaSearchAdapter } from './exaAdapter.js' import { ExaSearchAdapter } from './exaAdapter.js'
import { TavilySearchAdapter } from './tavilyAdapter.js'
import type { WebSearchAdapter } from './types.js' import type { WebSearchAdapter } from './types.js'
export type { export type {
@@ -17,60 +22,53 @@ export type {
WebSearchAdapter, WebSearchAdapter,
} from './types.js' } from './types.js'
/** export type SearchAdapterKey = 'api' | 'bing' | 'brave' | 'exa' | 'tavily'
* Check if the current session uses a third-party (non-Anthropic) API provider.
* These providers don't support Anthropic's server_tools (server-side web search),
* so they must fall back to the Bing scraper adapter.
*/
function isThirdPartyProvider(): boolean {
return !!(
process.env.CLAUDE_CODE_USE_OPENAI ||
process.env.CLAUDE_CODE_USE_GEMINI ||
process.env.CLAUDE_CODE_USE_GROK
)
}
let cachedAdapter: WebSearchAdapter | null = null let cachedAdapter: WebSearchAdapter | null = null
let cachedAdapterKey: 'api' | 'bing' | 'brave' | 'exa' | null = null let cachedAdapterKey: SearchAdapterKey | null = null
export function createAdapter(): WebSearchAdapter { export function createAdapter(): WebSearchAdapter {
// 1. Explicit env override
const envAdapter = process.env.WEB_SEARCH_ADAPTER const envAdapter = process.env.WEB_SEARCH_ADAPTER
// Priority: // 2. Settings preference (set via /web-tools panel)
// 1. Explicit env override (WEB_SEARCH_ADAPTER=api|bing|brave) const settingsAdapter = getSettings_DEPRECATED().webSearchAdapter
// 2. Third-party provider (OpenAI/Gemini/Grok) → bing (no server_tools support)
// 3. First-party Anthropic API → api (server-side web search + connector_text) const adapterKey: SearchAdapterKey =
// 4. Fallback → bing
const adapterKey =
envAdapter === 'api' || envAdapter === 'api' ||
envAdapter === 'bing' || envAdapter === 'bing' ||
envAdapter === 'brave' || envAdapter === 'brave' ||
envAdapter === 'exa' envAdapter === 'exa' ||
envAdapter === 'tavily'
? envAdapter ? envAdapter
: isThirdPartyProvider() : settingsAdapter === 'api' ||
? 'bing' settingsAdapter === 'bing' ||
: isFirstPartyAnthropicBaseUrl() settingsAdapter === 'brave' ||
? 'api' settingsAdapter === 'exa' ||
: 'exa' settingsAdapter === 'tavily'
? settingsAdapter
: 'tavily' // 3. Default
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
if (adapterKey === 'api') { switch (adapterKey) {
case 'api':
cachedAdapter = new ApiSearchAdapter() cachedAdapter = new ApiSearchAdapter()
cachedAdapterKey = 'api' break
return cachedAdapter case 'bing':
} cachedAdapter = new BingSearchAdapter()
if (adapterKey === 'brave') { break
case 'brave':
cachedAdapter = new BraveSearchAdapter() cachedAdapter = new BraveSearchAdapter()
cachedAdapterKey = 'brave' break
return cachedAdapter case 'exa':
}
if (adapterKey === 'exa') {
cachedAdapter = new ExaSearchAdapter() cachedAdapter = new ExaSearchAdapter()
cachedAdapterKey = 'exa' break
return cachedAdapter case 'tavily':
default:
cachedAdapter = new TavilySearchAdapter()
break
} }
cachedAdapter = new BingSearchAdapter() cachedAdapterKey = adapterKey
cachedAdapterKey = 'bing'
return cachedAdapter return cachedAdapter
} }

View File

@@ -0,0 +1,98 @@
/**
* Tavily-based search adapter — calls the Tavily Search API
* (https://tavily.claude-code-best.win) and maps results to
* the unified SearchResult format.
*/
import axios from 'axios'
import { AbortError } from 'src/utils/errors.js'
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
import type { SearchResult, SearchOptions, WebSearchAdapter } from './types.js'
const DEFAULT_TAVILY_SEARCH_URL = 'https://tavily.claude-code-best.win/search'
const FETCH_TIMEOUT_MS = 30_000
interface TavilySearchHit {
title: string
url: string
content: string
score: number
}
interface TavilySearchResponse {
results: TavilySearchHit[]
}
export class TavilySearchAdapter implements WebSearchAdapter {
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
const { signal, onProgress, allowedDomains, blockedDomains } = options
if (signal?.aborted) {
throw new AbortError()
}
onProgress?.({ type: 'query_update', query })
const abortController = new AbortController()
if (signal) {
signal.addEventListener('abort', () => abortController.abort(), {
once: true,
})
}
const settings = getSettings_DEPRECATED() as Record<string, unknown> & {
tavilyEndpointUrl?: string
}
const baseUrl = settings.tavilyEndpointUrl || DEFAULT_TAVILY_SEARCH_URL
// Ensure the URL ends with /search (same pattern as fetchContentWithTavily for /extract)
const searchUrl = baseUrl.endsWith('/search')
? baseUrl
: `${baseUrl.replace(/\/$/, '')}/search`
try {
const response = await axios.post<{
query: string
results: TavilySearchHit[]
}>(
searchUrl,
{
query,
search_depth: 'basic',
max_results: options.numResults ?? 8,
include_domains: allowedDomains ?? [],
exclude_domains: blockedDomains ?? [],
},
{
signal: abortController.signal,
timeout: FETCH_TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
},
)
if (abortController.signal.aborted) {
throw new AbortError()
}
const results: SearchResult[] = (response.data.results ?? []).map(
(hit: TavilySearchHit) => ({
title: hit.title,
url: hit.url,
snippet: hit.content,
}),
)
onProgress?.({
type: 'search_results_received',
resultCount: results.length,
query,
})
return results
} catch (e) {
if (axios.isCancel(e) || abortController.signal.aborted) {
throw new AbortError()
}
throw e
}
}
}

View File

@@ -1,432 +0,0 @@
import { randomUUID } from 'crypto'
import { mkdir, readdir, readFile, writeFile } from 'fs/promises'
import { join, parse } from 'path'
import { z } from 'zod/v4'
import type { ToolResultBlockParam } from 'src/Tool.js'
import { buildTool } from 'src/Tool.js'
import { truncate } from 'src/utils/format.js'
import { safeParseJSON } from 'src/utils/json.js'
import {
WORKFLOW_DIR_NAME,
WORKFLOW_FILE_EXTENSIONS,
WORKFLOW_TOOL_NAME,
} from './constants.js'
const WORKFLOW_RUNS_DIR = '.claude/workflow-runs'
const inputSchema = z.object({
workflow: z.string().describe('Name of the workflow to execute'),
args: z.string().optional().describe('Arguments to pass to the workflow'),
action: z
.enum(['start', 'status', 'advance', 'cancel', 'list'])
.optional()
.describe('Workflow action. Defaults to start.'),
run_id: z
.string()
.optional()
.describe('Workflow run id for status, advance, or cancel.'),
})
type Input = typeof inputSchema
type WorkflowInput = z.infer<Input>
type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'cancelled'
type WorkflowStep = {
name: string
prompt: string
status: WorkflowStepStatus
startedAt?: number
completedAt?: number
}
type WorkflowRun = {
runId: string
workflow: string
args?: string
status: 'running' | 'completed' | 'cancelled'
createdAt: number
updatedAt: number
currentStepIndex: number
steps: WorkflowStep[]
}
type WorkflowOutput = { output: string }
async function findWorkflowFile(
workflowDir: string,
workflow: string,
): Promise<{ path: string; content: string } | null> {
for (const ext of WORKFLOW_FILE_EXTENSIONS) {
const path = join(workflowDir, `${workflow}${ext}`)
try {
return { path, content: await readFile(path, 'utf-8') }
} catch {
// try next
}
}
return null
}
async function listAvailableWorkflows(workflowDir: string): Promise<string[]> {
try {
const files = await readdir(workflowDir)
return files
.filter(f =>
WORKFLOW_FILE_EXTENSIONS.includes(parse(f).ext.toLowerCase()),
)
.map(f => parse(f).name)
.sort()
} catch {
return []
}
}
function workflowRunPath(cwd: string, runId: string): string {
return join(cwd, WORKFLOW_RUNS_DIR, `${runId}.json`)
}
async function readWorkflowRun(
cwd: string,
runId: string,
): Promise<WorkflowRun | null> {
try {
const parsed = safeParseJSON(
await readFile(workflowRunPath(cwd, runId), 'utf-8'),
false,
) as Partial<WorkflowRun> | null
if (
!parsed ||
typeof parsed.runId !== 'string' ||
typeof parsed.workflow !== 'string' ||
!Array.isArray(parsed.steps)
) {
return null
}
return parsed as WorkflowRun
} catch {
return null
}
}
async function writeWorkflowRun(cwd: string, run: WorkflowRun): Promise<void> {
await mkdir(join(cwd, WORKFLOW_RUNS_DIR), { recursive: true })
await writeFile(
workflowRunPath(cwd, run.runId),
JSON.stringify(run, null, 2) + '\n',
'utf-8',
)
}
async function listWorkflowRuns(cwd: string): Promise<WorkflowRun[]> {
let files: string[]
try {
files = await readdir(join(cwd, WORKFLOW_RUNS_DIR))
} catch {
return []
}
const runs = await Promise.all(
files
.filter(f => f.endsWith('.json'))
.map(f => readWorkflowRun(cwd, f.slice(0, -'.json'.length))),
)
return runs
.filter((run): run is WorkflowRun => run !== null)
.sort((a, b) => b.updatedAt - a.updatedAt)
}
function parseMarkdownSteps(content: string): WorkflowStep[] {
const steps: WorkflowStep[] = []
for (const rawLine of content.split('\n')) {
const line = rawLine.trim()
const taskMatch = line.match(/^[-*]\s+\[[ xX]\]\s+(.+)$/)
const bulletMatch = line.match(/^[-*]\s+(.+)$/)
const numberedMatch = line.match(/^\d+[.)]\s+(.+)$/)
const text = taskMatch?.[1] ?? bulletMatch?.[1] ?? numberedMatch?.[1]
if (!text) continue
steps.push({ name: text.slice(0, 80), prompt: text, status: 'pending' })
}
return steps
}
function parseYamlSteps(content: string): WorkflowStep[] {
const steps: WorkflowStep[] = []
let current: Partial<WorkflowStep> | null = null
const flush = () => {
if (!current) return
const prompt = current.prompt ?? current.name
if (current.name && prompt) {
steps.push({
name: current.name,
prompt,
status: 'pending',
})
}
current = null
}
for (const rawLine of content.split('\n')) {
const line = rawLine.trim()
const stepText = line.match(/^-\s+(.+)$/)?.[1]
if (stepText) {
flush()
const inlineName = stepText.match(/^name:\s*(.+)$/)?.[1]
current = {
name: inlineName ?? stepText,
prompt: inlineName ? undefined : stepText,
}
continue
}
const name = line.match(/^name:\s*(.+)$/)?.[1]
if (name) {
if (!current) current = {}
current.name = name
continue
}
const prompt = line.match(/^(prompt|run|command):\s*(.+)$/)?.[2]
if (prompt) {
if (!current) current = {}
current.prompt = prompt
}
}
flush()
return steps
}
function parseWorkflowSteps(filePath: string, content: string): WorkflowStep[] {
const ext = parse(filePath).ext.toLowerCase()
const steps =
ext === '.md' ? parseMarkdownSteps(content) : parseYamlSteps(content)
if (steps.length > 0) {
return steps
}
return [
{
name: 'Execute workflow',
prompt: content.trim(),
status: 'pending',
},
]
}
function formatStep(step: WorkflowStep, index: number): string {
return `Step ${index + 1}: ${step.name}\n${step.prompt}`
}
function formatRunStatus(run: WorkflowRun): string {
const lines = [
`Workflow run: ${run.runId}`,
`Workflow: ${run.workflow}`,
`Status: ${run.status}`,
`Current step: ${run.steps[run.currentStepIndex]?.name ?? 'none'}`,
`Steps: ${run.steps.length}`,
]
for (let i = 0; i < run.steps.length; i += 1) {
const step = run.steps[i]!
lines.push(` ${i + 1}. [${step.status}] ${step.name}`)
}
return lines.join('\n')
}
async function startWorkflow(
input: WorkflowInput,
cwd: string,
): Promise<WorkflowOutput> {
const workflowDir = join(cwd, WORKFLOW_DIR_NAME)
const found = await findWorkflowFile(workflowDir, input.workflow)
if (!found) {
const available = await listAvailableWorkflows(workflowDir)
const hint =
available.length > 0
? `\nAvailable workflows: ${available.join(', ')}`
: `\nNo workflows found in ${WORKFLOW_DIR_NAME}/. Create .md or .yaml files there.`
return { output: `Error: Workflow "${input.workflow}" not found.${hint}` }
}
const steps = parseWorkflowSteps(found.path, found.content)
const now = Date.now()
steps[0] = { ...steps[0]!, status: 'running', startedAt: now }
const run: WorkflowRun = {
runId: randomUUID(),
workflow: input.workflow,
...(input.args ? { args: input.args } : {}),
status: 'running',
createdAt: now,
updatedAt: now,
currentStepIndex: 0,
steps,
}
await writeWorkflowRun(cwd, run)
const argsSection = input.args ? `\n\nArguments:\n${input.args}` : ''
return {
output: [
`Workflow run started`,
`run_id: ${run.runId}`,
`workflow: ${run.workflow}`,
'',
formatStep(steps[0]!, 0),
argsSection,
'',
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
].join('\n'),
}
}
async function getRunOrError(
cwd: string,
runId: string | undefined,
): Promise<{ run?: WorkflowRun; output?: string }> {
if (!runId) return { output: 'Error: run_id is required for this action.' }
const run = await readWorkflowRun(cwd, runId)
if (!run) return { output: `Error: Workflow run "${runId}" not found.` }
return { run }
}
async function advanceWorkflow(
cwd: string,
runId: string | undefined,
): Promise<WorkflowOutput> {
const found = await getRunOrError(cwd, runId)
if (!found.run) return { output: found.output! }
const run = found.run
const now = Date.now()
const current = run.steps[run.currentStepIndex]
if (current && current.status === 'running') {
current.status = 'completed'
current.completedAt = now
}
const nextIndex = run.currentStepIndex + 1
if (nextIndex >= run.steps.length) {
run.status = 'completed'
run.updatedAt = now
await writeWorkflowRun(cwd, run)
return { output: `Workflow completed\nrun_id: ${run.runId}` }
}
run.currentStepIndex = nextIndex
run.steps[nextIndex] = {
...run.steps[nextIndex]!,
status: 'running',
startedAt: now,
}
run.updatedAt = now
await writeWorkflowRun(cwd, run)
return {
output: [
`Next workflow step`,
`run_id: ${run.runId}`,
'',
formatStep(run.steps[nextIndex]!, nextIndex),
'',
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
].join('\n'),
}
}
async function cancelWorkflow(
cwd: string,
runId: string | undefined,
): Promise<WorkflowOutput> {
const found = await getRunOrError(cwd, runId)
if (!found.run) return { output: found.output! }
const run = found.run
const now = Date.now()
run.status = 'cancelled'
run.updatedAt = now
for (const step of run.steps) {
if (step.status === 'pending' || step.status === 'running') {
step.status = 'cancelled'
}
}
await writeWorkflowRun(cwd, run)
return { output: `Workflow cancelled\nrun_id: ${run.runId}` }
}
async function listWorkflowRunsForOutput(cwd: string): Promise<WorkflowOutput> {
const runs = await listWorkflowRuns(cwd)
if (runs.length === 0) return { output: 'No workflow runs recorded.' }
return {
output: runs
.slice(0, 20)
.map(
run =>
`${run.runId} | ${run.workflow} | ${run.status} | step=${run.steps[run.currentStepIndex]?.name ?? 'none'} | updated=${new Date(run.updatedAt).toLocaleString()}`,
)
.join('\n'),
}
}
export const WorkflowTool = buildTool({
name: WORKFLOW_TOOL_NAME,
searchHint: 'execute user-defined workflow scripts',
maxResultSizeChars: 50_000,
strict: true,
inputSchema,
async description() {
return 'Execute and track a user-defined workflow from .claude/workflows/'
},
async prompt() {
return `Use the Workflow tool to run user-defined workflows located in .claude/workflows/. Workflows may be Markdown checklists/lists or YAML files with steps.
Actions:
- start (default): create a persisted workflow run and return the first step to execute
- advance: mark the current step complete and return the next step
- status: inspect a workflow run by run_id
- cancel: cancel a workflow run
- list: list recent workflow runs
Workflow run state is persisted in .claude/workflow-runs/.`
},
userFacingName() {
return 'Workflow'
},
isReadOnly(input) {
return input.action === 'status' || input.action === 'list'
},
isEnabled() {
return true
},
renderToolUseMessage(input: Partial<WorkflowInput>) {
const name = input.workflow ?? 'unknown'
const action = input.action ?? 'start'
return input.args
? `Workflow: ${action} ${name} ${input.args}`
: `Workflow: ${action} ${name}`
},
mapToolResultToToolResultBlockParam(
content: WorkflowOutput,
toolUseID: string,
): ToolResultBlockParam {
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: truncate(content.output, 50_000),
}
},
async call(input: WorkflowInput) {
const cwd = process.cwd()
const action = input.action ?? 'start'
switch (action) {
case 'start':
return { data: await startWorkflow(input, cwd) }
case 'status': {
const found = await getRunOrError(cwd, input.run_id)
return {
data: {
output: found.run ? formatRunStatus(found.run) : found.output!,
},
}
}
case 'advance':
return { data: await advanceWorkflow(cwd, input.run_id) }
case 'cancel':
return { data: await cancelWorkflow(cwd, input.run_id) }
case 'list':
return { data: await listWorkflowRunsForOutput(cwd) }
}
},
})

View File

@@ -1,104 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { WorkflowTool } from '../WorkflowTool'
let cwd: string
let previousCwd: string
beforeEach(async () => {
previousCwd = process.cwd()
cwd = join(
tmpdir(),
`workflow-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`,
)
await mkdir(join(cwd, '.claude', 'workflows'), { recursive: true })
process.chdir(cwd)
})
afterEach(async () => {
process.chdir(previousCwd)
await rm(cwd, { recursive: true, force: true })
})
describe('WorkflowTool', () => {
test('starts a workflow run and persists step state', async () => {
await writeFile(
join(cwd, '.claude', 'workflows', 'release.md'),
['# Release', '', '- [ ] Run tests', '- [ ] Build package'].join('\n'),
)
const result = await WorkflowTool.call({ workflow: 'release' })
expect(result.data.output).toContain('Workflow run started')
expect(result.data.output).toContain('Run tests')
const match = result.data.output.match(/run_id: ([a-f0-9-]+)/)
expect(match?.[1]).toBeString()
const raw = await readFile(
join(cwd, '.claude', 'workflow-runs', `${match![1]}.json`),
'utf-8',
)
const run = JSON.parse(raw)
expect(run.workflow).toBe('release')
expect(run.status).toBe('running')
expect(run.steps).toHaveLength(2)
expect(run.steps[0].status).toBe('running')
expect(run.steps[1].status).toBe('pending')
})
test('advances a workflow run through completion', async () => {
await writeFile(
join(cwd, '.claude', 'workflows', 'audit.yaml'),
[
'steps:',
' - name: Inspect',
' prompt: Inspect the code',
' - name: Verify',
' prompt: Run focused tests',
].join('\n'),
)
const started = await WorkflowTool.call({ workflow: 'audit' })
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
const next = await WorkflowTool.call({
workflow: 'audit',
action: 'advance',
run_id: runId,
})
expect(next.data.output).toContain('Next workflow step')
expect(next.data.output).toContain('Run focused tests')
const done = await WorkflowTool.call({
workflow: 'audit',
action: 'advance',
run_id: runId,
})
expect(done.data.output).toContain('Workflow completed')
})
test('lists and cancels workflow runs', async () => {
await writeFile(
join(cwd, '.claude', 'workflows', 'cleanup.md'),
'- Remove stale files',
)
const started = await WorkflowTool.call({ workflow: 'cleanup' })
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
const listed = await WorkflowTool.call({
workflow: 'cleanup',
action: 'list',
})
expect(listed.data.output).toContain(runId)
const cancelled = await WorkflowTool.call({
workflow: 'cleanup',
action: 'cancel',
run_id: runId,
})
expect(cancelled.data.output).toContain('Workflow cancelled')
})
})

View File

@@ -1,15 +0,0 @@
// Bundled workflow initialization.
// Called by tools.ts when WORKFLOW_SCRIPTS feature flag is enabled.
// Sets up any pre-bundled workflow scripts that ship with the CLI.
/**
* Initialize bundled workflows. Called once at startup when the
* WORKFLOW_SCRIPTS feature flag is active. This is the hook point
* for registering any workflow scripts that are compiled into the
* binary (as opposed to user-authored ones in .claude/workflows/).
*/
export function initBundledWorkflows(): void {
// Bundled workflows are registered here at startup.
// Currently a no-op — all workflows are user-authored in .claude/workflows/.
// This function exists as the extension point for future built-in workflows.
}

View File

@@ -1,3 +0,0 @@
export const WORKFLOW_TOOL_NAME = 'workflow'
export const WORKFLOW_DIR_NAME = '.claude/workflows'
export const WORKFLOW_FILE_EXTENSIONS = ['.yml', '.yaml', '.md']

View File

@@ -1,46 +0,0 @@
import { readdir } from 'fs/promises'
import { join, parse } from 'path'
import type { Command } from 'src/types/command.js'
import { WORKFLOW_DIR_NAME, WORKFLOW_FILE_EXTENSIONS } from './constants.js'
/**
* Scans .claude/workflows/ directory and creates Command objects for each workflow file.
* Each workflow file becomes a slash command (e.g. /workflow-name).
*/
export async function getWorkflowCommands(cwd: string): Promise<Command[]> {
const workflowDir = join(cwd, WORKFLOW_DIR_NAME)
let files: string[]
try {
files = await readdir(workflowDir)
} catch {
return []
}
const workflowFiles = files.filter(f => {
const ext = parse(f).ext.toLowerCase()
return WORKFLOW_FILE_EXTENSIONS.includes(ext)
})
return workflowFiles.map(file => {
const name = parse(file).name
return {
type: 'prompt' as const,
name,
description: `Run workflow: ${name}`,
kind: 'workflow' as const,
source: 'builtin' as const,
progressMessage: `Running workflow ${name}...`,
contentLength: 0,
async getPromptForCommand(args, _context) {
const { readFile } = await import('fs/promises')
const content = await readFile(join(workflowDir, file), 'utf-8')
return [
{
type: 'text' as const,
text: `Execute this workflow:\n\n${content}${args ? `\n\nArguments: ${args}` : ''}`,
},
]
},
} satisfies Command
})
}

View File

@@ -0,0 +1,124 @@
/**
* registry 多后端路由演示mock adapter无需 API key
*
* 两个 adapterstrong被 researcher 路由命中)+ fast默认
* 脚本里 agent({agentType:'researcher'}) → strong其余 → fast。
* 证明 agent 后端可通过 AgentAdapterRegistry 插拔 + 路由,引擎不关心实现。
*
* 用法bun run packages/workflow-engine/examples/registry-demo.ts
*/
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
AgentAdapterRegistry,
createFileJournalStore,
createHostHandle,
runWorkflow,
type AgentAdapter,
type AgentRunParams,
type AgentRunResult,
type WorkflowPorts,
} from '@claude-code-best/workflow-engine'
const strongAdapter: AgentAdapter = {
id: 'strong',
capabilities: { structuredOutput: true, tools: true },
async run(p: AgentRunParams): Promise<AgentRunResult> {
return {
kind: 'ok',
output: `[strong] ← ${p.prompt}`,
usage: { outputTokens: 1 },
}
},
}
const fastAdapter: AgentAdapter = {
id: 'fast',
capabilities: { structuredOutput: false },
async run(p: AgentRunParams): Promise<AgentRunResult> {
return {
kind: 'ok',
output: `[fast] ← ${p.prompt}`,
usage: { outputTokens: 1 },
}
},
}
const registry = new AgentAdapterRegistry()
.register(strongAdapter)
.register(fastAdapter)
.route({ kind: 'agentType', agentType: 'researcher', adapter: 'strong' })
.default('fast')
const SCRIPT = `
export const meta = { name: 'registry-demo', description: 'multi-adapter routing' }
phase('Route')
const research = await agent('深度调研任务', { agentType: 'researcher', label: 'research' })
const quick = await agent('快速小任务', { label: 'quick' })
return { research, quick }
`
function makePorts(runsDir: string): WorkflowPorts {
return {
// registry 优先agentRunner 仅作形状占位(不会被调到)
agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) },
agentAdapterRegistry: registry,
progressEmitter: {
emit: e => {
if (e.type === 'phase_started') console.log(`\n━ phase: ${e.phase}`)
else if (e.type === 'agent_done') {
const out =
e.result.kind === 'ok'
? String(e.result.output)
: `[${e.result.kind}]`
console.log(`${e.label}${out}`)
}
},
},
taskRegistrar: {
register: () => ({
runId: 'demo',
signal: new AbortController().signal,
}),
complete() {},
fail() {},
kill() {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(runsDir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: process.cwd(),
budgetTotal: null,
}),
}
}
if (import.meta.main) {
await registry.initializeAll()
try {
const result = await runWorkflow({
script: SCRIPT,
runId: `demo-${Date.now()}`,
ports: makePorts(join(tmpdir(), 'wf-registry-demo')),
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: process.cwd(),
budgetTotal: null,
})
console.log(`\n■ ${result.status}`)
if (result.status === 'completed') {
const ret = result.returnValue as { research: string; quick: string }
console.log(
`research(agentType:researcher) → ${ret.research.startsWith('[strong]') ? 'strong adapter ✓' : '??'}`,
)
console.log(
`quick(默认) → ${ret.quick.startsWith('[fast]') ? 'fast adapter ✓' : '??'}`,
)
}
} finally {
await registry.disposeAll()
}
}

View File

@@ -0,0 +1,74 @@
# research-report —— 库优先运行示例
`@claude-code-best/workflow-engine` **直接**运行一个 workflow绕开 Workflow 工具与核心 `runAgent`
## 状态
- **引擎层**:完整且测试覆盖 **99.65% 行 / 99.20% 函数**workflow-engine 包 112 个 mock 测试全绿)。
- **本 example**:编排逻辑(`parallel` / `pipeline` / `schema` / `args`)经 mock 端到端验证;**真实 LLM 已跑通**(直连 Anthropic SDK
- **定位**:库 API 与引擎逻辑的**参考实现 + 冒烟示范**,不是生产服务——见下方「生产就绪」。
## 它演示了什么
- **库可独立使用**`run.ts``import { runWorkflow, ... } from '@claude-code-best/workflow-engine'`,自己组装 7 个端口,不依赖 `src/` 任何核心模块。
- **agent 后端直连 Anthropic SDK**`agentRunner``client.messages.create`,子 agent = 一次模型调用(不经核心 `runAgent`、不经 Workflow 工具)。
- **真实 LLM + 结构化输出**`agent(schema)` → prompt 追加 JSON 指令 → 提取 JSON → `validateAgainstSchema`Ajv校验失败回退 `dead`
- **引擎能力全覆盖**`parallel`(屏障,多角度 fan-out`pipeline`(无屏障,逐条深挖)→ `phase` / `log` / `args`
## 运行
```bash
ANTHROPIC_API_KEY=sk-... \
bun run packages/workflow-engine/examples/research-report/run.ts "Edge Computing"
```
环境变量:
- `ANTHROPIC_API_KEY`(必填)
- `ANTHROPIC_MODEL`:默认 `claude-sonnet-4-5`
- `WORKFLOW_API_CONCURRENCY`API 并发上限,默认 `3`(见下)。低 tier 可设 `1` 串行
- `RESEARCH_RUNS_DIR`journal 目录,默认 `~/.claude/workflow-runs`resume 时复用)
## 健壮性与排错
runner 内置了几项让真实 API 跑得稳的处理:
- **API 并发限制**`llmAgent` 经独立信号量限并发(默认 3**独立于引擎的 CPU 级 semaphore**——LLM API 对并发远比 CPU 敏感,按 cores可能 14放并发会触发 429。用 `WORKFLOW_API_CONCURRENCY` 调。
- **429/5xx 重试**指数退避500ms → 1s → 2s → 4s最多 4 次);连接/超时错误也重试。
- **SDK 日志关闭**`new Anthropic({ logLevel: 'off' })`options 优先级最高,压过 `ANTHROPIC_LOG` env。否则 SDK 会打 `[log_xxxxx] sending request {…}` 这种完整请求 JSON。
- **错误摘要精简**:失败只打 `HTTP 429 rate_limit_error` 这种短行,不打印含 request body 的整段 message。
- **synthesize 防 JSON**prompt 明确禁止把输入的 `deepFindings` JSON 原样粘进报告。
排错速查:
| 现象 | 原因 / 处理 |
|------|------|
| `HTTP 429 ...` 频繁 | 降 `WORKFLOW_API_CONCURRENCY=1`(或 2 |
| agent `✗ [dead]` 多 | 模型未按 schema 返回 JSON换更强模型或放宽 schema |
| `[log_xxx] sending request` 刷屏 | 不应再出现(已 `logLevel:'off'`);若仍出现检查 env 是否覆盖 |
| 报告被截断 | synthesize 已 `maxTokens:8192`;仍不够可改 workflow 脚本 |
## 文件
| 文件 | 作用 |
|------|------|
| `research-report.workflow.mjs` | workflow 脚本(编排逻辑,纯 JS引擎沙箱执行 |
| `run.ts` | runner组装端口 + 直连 SDK + 运行 + 终端进度 |
| (同级 `../smoke.ts` | 最小冒烟入口3 次调用,秒级验证通路) |
## 扩展点
- **联网调研**:给 `llmAgent``messages.create``tools: [{ type: 'web_search_20250305' }]`Anthropic server-side web searchresearch 角度即可联网。
- **命名命令复用**:把 `research-report.workflow.mjs` 复制到项目 `.claude/workflows/research-report.mjs`,即可通过 `/research-report` 或 Workflow 工具运行(同一脚本,两种入口)。
- **token 预算**`runWorkflow({ budgetTotal: 200000 })` 设上限;脚本内用 `budget.remaining()` 自适应规模。
- **resume**:同 `runId` + `resume: true` 重放 journal已完成的 agent 不重跑。
## 生产就绪(诚实)
本 example 验证的是**库的 API 与引擎编排逻辑**,不是生产服务。要上生产还差:
- **真实 LLM 压测**:长 workflow、大量并发、中断/resume 的真实场景验证mock 覆盖不到模型行为)。
- **核心 adapter 的 v1 延期项**`budgetTotal` 注入、skip/retry UI、worktree 隔离、StructuredOutput 完整接入(本 example 用 prompt+JSON 解析,比核心真实路径弱)。
- **错误恢复**journal resume 只在 mock 验证过;真实中途崩溃的重放正确性未压测。
引擎核心逻辑(并发 / 预算 / journal / schema有 99.65% 覆盖的 mock 测试兜底,可作为基础继续建。

View File

@@ -0,0 +1,124 @@
// research-report.workflow.mjs
// 技术研究报告 workflow。
// 由 run.ts 通过 @claude-code-best/workflow-engine 的 runWorkflow() 直接执行——
// 不经 Workflow 工具、不经核心 runAgent。脚本内的 agent / parallel / pipeline /
// phase / log / args 均为引擎运行时注入的全局(见 src/engine/script.ts 的沙箱)。
//
// 编排多角度并行调研parallel 屏障)→ 逐条深挖pipeline 无屏障)→ 综合成报告。
export const meta = {
name: 'research-report',
description:
'Multi-angle tech research → deep-read → synthesize into a Markdown report',
whenToUse: '调研一个技术主题:从多个角度并行研究、逐条深挖、综合成结构化报告',
phases: [
{ title: 'Research', detail: '多角度并行调研parallel 屏障)' },
{ title: 'DeepRead', detail: '逐条深挖pipeline 无屏障)' },
{ title: 'Synthesize', detail: '综合成 Markdown 报告' },
],
}
// agent(schema) 让子 agent 返回「校验对象」而非纯文本。
const ANGLE_SCHEMA = {
type: 'object',
required: ['angle', 'findings'],
properties: {
angle: { type: 'string', description: '本次调研的角度名' },
findings: {
type: 'array',
items: {
type: 'object',
required: ['claim', 'evidence'],
properties: {
claim: { type: 'string', description: '一句话结论' },
evidence: { type: 'string', description: '依据/来源/理由' },
},
},
},
},
}
const DEEP_SCHEMA = {
type: 'object',
required: ['claim', 'analysis', 'confidence'],
properties: {
claim: { type: 'string' },
analysis: { type: 'string', description: '机理/前提/边界/反例' },
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
},
}
// ---- 输入(由 run.ts 通过 args 透传)----
const topic = args.topic
if (typeof topic !== 'string' || topic.length === 0) {
throw new Error('research-report 需要 args.topic研究主题字符串')
}
const angles =
Array.isArray(args.angles) && args.angles.length > 0
? args.angles
: ['核心概念与原理', '主流方案与对比', '工程实践与权衡', '生态与趋势']
// ---- Phase 1多角度并行调研。parallel = 屏障,等所有角度完成后才继续。----
phase('Research')
log(`主题「${topic}」:${angles.length} 个角度并行调研中`)
const researched = await parallel(
angles.map(
a => () =>
agent(
`你是资深技术研究分析师。针对技术主题「${topic}」,从「${a}」角度调研,给出该角度下 2-4 条最关键的技术发现,每条须附依据。`,
{ label: `research:${a}`, phase: 'Research', schema: ANGLE_SCHEMA },
),
),
)
// parallel 返回 (object|null)[]skipped/dead 的角度为 null过滤后展平
const allFindings = researched
.filter(Boolean)
.flatMap(r => r.findings.map(f => ({ ...f, angle: r.angle })))
log(`收集到 ${allFindings.length} 条发现,进入深挖`)
if (allFindings.length === 0) {
return {
topic,
report: '(所有角度调研均失败,无可用发现)',
anglesCovered: 0,
findingsDeepened: 0,
}
}
// ---- Phase 2逐条深挖。pipeline = 无屏障,每条发现独立跑完所有 stage互不等待。----
phase('DeepRead')
const deepened = await pipeline(
allFindings,
f =>
agent(
`针对以下技术发现,深入分析其机理、成立前提、适用边界与可能的反例:\n结论:${f.claim}\n依据:${f.evidence}\n角度:${f.angle}`,
{ label: `deep:${f.angle}`, phase: 'DeepRead', schema: DEEP_SCHEMA },
),
// 第二个 stage按置信度标注交叉价值演示多 stage pipeline 链式传递)。
// stage-1 若 dead 返回 null这里显式守卫——避免对 null 取属性(否则被 pipeline
// 的 per-item catch 吞掉、整条静默丢失)。
d =>
d
? {
...d,
crossCutting:
d.confidence === 'high' ? '可作为报告主干' : '需谨慎引用或佐证',
}
: null,
)
const deepFindings = deepened.filter(Boolean)
log(`深挖完成 ${deepFindings.length}/${allFindings.length}`)
// ---- Phase 3综合成 Markdown 报告(无 schema → 返回纯文本)----
phase('Synthesize')
const report = await agent(
`你是首席技术分析师。基于以下经深挖的技术发现,综合一份结构化研究报告(纯 Markdown 叙述)。\n要求:含摘要、分角度分析、关键结论、落地建议与风险;用自然语言陈述每条发现并标注 confidence。\n禁止:在报告中粘贴 JSON 代码块或原样引用下方输入数据。\n\n主题:${topic}\n\n深度发现JSON仅供你理解不要原样输出\n${JSON.stringify(deepFindings)}`,
{ label: 'synthesize', phase: 'Synthesize', maxTokens: 8192 },
)
return {
topic,
report,
anglesCovered: angles.length,
findingsDeepened: deepFindings.length,
}

View File

@@ -0,0 +1,313 @@
/**
* research-report runner —— 直接用 @claude-code-best/workflow-engine 运行 workflow
* 完全绕开 Workflow 工具与核心 runAgent。agent() 后端直连 Anthropic SDK
* @anthropic-ai/sdk子 agent = 一次 messages.create。
*
* 用法:
* ANTHROPIC_API_KEY=sk-... \
* bun run packages/workflow-engine/examples/research-report/run.ts "Edge Computing"
*
* 可选环境变量:
* ANTHROPIC_MODEL 模型名,默认 claude-sonnet-4-5
* RESEARCH_RUNS_DIR journal 目录,默认 ~/.claude/workflow-runsresume 复用)
*/
import Anthropic from '@anthropic-ai/sdk'
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
createFileJournalStore,
createHostHandle,
runWorkflow,
Semaphore,
validateAgainstSchema,
type AgentRunParams,
type AgentRunResult,
type ProgressEvent,
type WorkflowPorts,
} from '@claude-code-best/workflow-engine'
const SCRIPT_FILE = `${import.meta.dir}/research-report.workflow.mjs`
const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5'
const MAX_TOKENS = 4096
// 终端着色(无第三方依赖)
const paint = {
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
}
// client 由 main() 构造llmAgent 闭包引用。null 守卫使 import 时不触发真实调用。
const clientRef: { client: Anthropic | null } = { client: null }
// API 并发上限(独立于引擎的 CPU semaphore——LLM API 对并发远比 CPU 敏感,默认 3
// 用 WORKFLOW_API_CONCURRENCY 调整。
const apiSem = new Semaphore(
Math.max(1, Number(process.env.WORKFLOW_API_CONCURRENCY) || 3),
)
/** 429/5xx/连接错误指数退避重试500ms → 1s → 2s → 4s最多 4 次。 */
async function withRetry<T>(fn: () => Promise<T>, retries = 4): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn()
} catch (e) {
if (!isRetryable(e) || attempt >= retries) throw e
const wait = Math.min(500 * 2 ** attempt, 8000)
await new Promise(r => {
setTimeout(r, wait)
})
}
}
}
function isRetryable(e: unknown): boolean {
const err = e as { status?: number; name?: string }
if (err.status === 429) return true
if (typeof err.status === 'number' && err.status >= 500) return true
if (typeof err.name === 'string' && /Connection|Timeout/i.test(err.name)) {
return true
}
return false
}
/** 精简错误摘要(避免打印整个含 request body 的 message。 */
function errSummary(e: unknown): string {
const err = e as {
status?: number
error?: { type?: string }
message?: string
}
if (err.status) return `HTTP ${err.status} ${err.error?.type ?? ''}`.trim()
return (err.message ?? 'unknown').slice(0, 120)
}
/**
* 真实 LLM agentRunner一次 messages.create经 API 并发信号量 + 重试)。
* schema 模式prompt 追加 JSON 指令 → 取文本 → 提取 JSON → Ajv 校验 → 失败返回 dead。
* 非 schema返回纯文本。
*/
async function llmAgent(params: AgentRunParams): Promise<AgentRunResult> {
const client = clientRef.client
if (client === null) return { kind: 'dead' }
const schemaInstruction = params.schema
? '\n\n你必须以一个【单独的 JSON 对象】作为整段回答(不要 Markdown 代码围栏、不要任何解释),该对象须匹配如下 JSON Schema\n' +
JSON.stringify(params.schema)
: ''
const release = await apiSem.acquire()
try {
const resp = await withRetry(() =>
client.messages.create({
model: params.model ?? DEFAULT_MODEL,
max_tokens: params.maxTokens ?? MAX_TOKENS,
messages: [
{ role: 'user', content: params.prompt + schemaInstruction },
],
}),
)
const outputTokens = resp.usage.output_tokens
const truncated = resp.stop_reason === 'max_tokens'
if (params.schema) {
// 截断的 JSON 几乎必然不完整 → 直接判 dead而非让解析模糊失败
if (truncated) return { kind: 'dead' }
const text = resp.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('')
.trim()
const parsed = extractJsonObject(text)
if (parsed === null) return { kind: 'dead' }
const { valid } = validateAgainstSchema(parsed, params.schema)
if (!valid) return { kind: 'dead' }
return { kind: 'ok', output: parsed as object, usage: { outputTokens } }
}
const text = resp.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('')
.trim()
if (truncated) {
console.error(
paint.yellow(` ⚠ 输出被 max_tokens 截断(${outputTokens} tokens`),
)
}
return { kind: 'ok', output: text, usage: { outputTokens } }
} catch (e) {
console.error(paint.red(`${errSummary(e)}`))
return { kind: 'dead' }
} finally {
release()
}
}
/**
* 容错 JSON 提取:去代码围栏 → 从首个 { 起做括号深度匹配(跳过字符串字面量与
* 转义,仿 src/engine/script.ts 的 extractMeta取配对的 {…} → JSON.parse。
* 比 lastIndexOf('}') 稳健:正确处理 JSON 后散文里含 }、第二个对象、字符串内 }。
*/
function extractJsonObject(text: string): unknown | null {
const stripped = text.replace(/```(?:json)?/gi, '').trim()
const start = stripped.indexOf('{')
if (start < 0) {
try {
return JSON.parse(stripped)
} catch {
return null
}
}
let depth = 0
let inStr: string | null = null
for (let i = start; i < stripped.length; i++) {
const ch = stripped[i]
if (inStr) {
if (ch === '\\') i++
else if (ch === inStr) inStr = null
continue
}
if (ch === '"' || ch === "'" || ch === '`') inStr = ch
else if (ch === '{') depth++
else if (ch === '}') {
depth--
if (depth === 0) {
try {
return JSON.parse(stripped.slice(start, i + 1))
} catch {
return null
}
}
}
}
return null
}
/** 内存版 taskRegistrar不经核心 LocalWorkflowTask仅维护 runId → AbortController。 */
function makeTaskRegistrar(): WorkflowPorts['taskRegistrar'] {
const controllers = new Map<string, AbortController>()
return {
register(opts) {
const ac = new AbortController()
const runId = opts.runId ?? `research-${controllers.size + 1}`
controllers.set(runId, ac)
return { runId, signal: ac.signal }
},
complete() {},
fail() {},
kill(runId) {
controllers.get(runId)?.abort()
},
pendingAction() {
return null
},
}
}
/** 进度事件 → 终端实时打印。 */
function printProgress(e: ProgressEvent): void {
switch (e.type) {
case 'run_started':
console.log(paint.bold(paint.cyan(`\n▶ ${e.workflowName}`)))
break
case 'phase_started':
console.log(paint.cyan(`\n━ phase: ${e.phase}`))
break
case 'phase_done':
break
case 'agent_started':
console.log(` ${paint.dim('→')} ${e.label ?? 'agent'}`)
break
case 'agent_done': {
const tag =
e.result.kind === 'ok'
? paint.green('✓')
: e.result.kind === 'skipped'
? paint.yellow('⊘')
: paint.red('✗')
console.log(
` ${tag} ${e.label ?? 'agent'} ${paint.dim(`[${e.result.kind}]`)}`,
)
break
}
case 'log':
console.log(` ${paint.dim('·')} ${e.message}`)
break
case 'run_done':
console.log(paint.bold(`\n■ ${e.status}`))
break
}
}
/** 组装端口agent 后端直连 SDK其余为自包含实现不触达核心层。 */
function makePorts(runsDir: string): WorkflowPorts {
return {
agentRunner: { runAgentToResult: llmAgent },
progressEmitter: { emit: printProgress },
taskRegistrar: makeTaskRegistrar(),
journalStore: createFileJournalStore(runsDir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: process.cwd(),
budgetTotal: null,
}),
}
}
async function main(): Promise<void> {
const topic = process.argv[2]
if (!topic) {
console.error(paint.red('✗ 用法run.ts <研究主题>'))
console.error(paint.dim(' 例bun run run.ts "Edge Computing"'))
process.exit(1)
}
clientRef.client = new Anthropic({ logLevel: 'off' })
const runsDir =
process.env.RESEARCH_RUNS_DIR ?? join(homedir(), '.claude', 'workflow-runs')
const script = await readFile(SCRIPT_FILE, 'utf-8')
const result = await runWorkflow({
script,
args: { topic },
runId: `research-${Date.now()}`,
ports: makePorts(runsDir),
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: process.cwd(),
budgetTotal: null,
})
if (result.status !== 'completed') {
console.error(
paint.red(`✗ workflow ${result.status}${result.error ?? ''}`),
)
process.exit(1)
}
const ret = result.returnValue as {
report?: string
topic?: string
anglesCovered?: number
findingsDeepened?: number
}
console.log(
paint.bold(
paint.green(`\n════════ 技术研究报告:${ret.topic ?? topic} ════════`),
),
)
console.log(
paint.dim(
`角度数=${ret.anglesCovered ?? '?'} 深挖=${ret.findingsDeepened ?? '?'}`,
),
)
console.log(ret.report ?? '(无报告输出)')
}
// 仅作为脚本直接运行时启动import 不触发,便于冒烟/复用端口工厂)
if (import.meta.main) {
await main()
}

View File

@@ -0,0 +1,251 @@
/**
* 冒烟端到端入口 —— 真实 SDK + 引擎,最小验证端到端通路。
* 3 次模型调用2 角度并行 schema + 1 综合),秒级完成、低成本。
* 覆盖runWorkflow、parallel屏障、agent(schema) 结构化、agent 文本、进度事件。
*
* 用法:
* ANTHROPIC_API_KEY=sk-... \
* bun run packages/workflow-engine/examples/smoke.ts
*
* 可选ANTHROPIC_MODEL默认 claude-sonnet-4-5
*/
import Anthropic from '@anthropic-ai/sdk'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
createFileJournalStore,
createHostHandle,
runWorkflow,
Semaphore,
validateAgainstSchema,
type AgentRunParams,
type AgentRunResult,
type ProgressEvent,
type WorkflowPorts,
} from '@claude-code-best/workflow-engine'
const DEFAULT_MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-4-5'
const clientRef: { client: Anthropic | null } = { client: null }
const POINT_SCHEMA = {
type: 'object',
required: ['point'],
properties: { point: { type: 'string' } },
}
// 最小 workflow2 角度并行schema 结构化)→ 综合(文本)。脚本内用 + 拼接避免 ${}。
const SMOKE_SCRIPT =
`
export const meta = { name: 'smoke', description: 'minimal end-to-end smoke' }
phase('Smoke')
const angles = ['一句话定义', '一个最核心价值']
const points = await parallel(
angles.map(a => () =>
agent('用简短一句话30 字内)说明 workflow 编排的「' + a + '」。', {
label: 'p:' + a,
schema: ` +
JSON.stringify(POINT_SCHEMA) +
`,
}),
),
)
const clean = points.filter(Boolean)
const joined = clean.map(p => p.point).join('')
const summary = await agent('把以下要点综合成一句中文结论。要点:' + joined, {
label: 'summary',
})
return { points: clean, summary }
`
// API 并发上限(独立于引擎的 CPU semaphore——LLM API 对并发远比 CPU 敏感,默认 3
const apiSem = new Semaphore(
Math.max(1, Number(process.env.WORKFLOW_API_CONCURRENCY) || 3),
)
/** 429/5xx/连接错误指数退避重试,最多 4 次。 */
async function withRetry<T>(fn: () => Promise<T>, retries = 4): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn()
} catch (e) {
if (!isRetryable(e) || attempt >= retries) throw e
const wait = Math.min(500 * 2 ** attempt, 8000)
await new Promise(r => {
setTimeout(r, wait)
})
}
}
}
function isRetryable(e: unknown): boolean {
const err = e as { status?: number; name?: string }
if (err.status === 429) return true
if (typeof err.status === 'number' && err.status >= 500) return true
if (typeof err.name === 'string' && /Connection|Timeout/i.test(err.name)) {
return true
}
return false
}
function errSummary(e: unknown): string {
const err = e as {
status?: number
error?: { type?: string }
message?: string
}
if (err.status) return `HTTP ${err.status} ${err.error?.type ?? ''}`.trim()
return (err.message ?? 'unknown').slice(0, 120)
}
async function llmAgent(params: AgentRunParams): Promise<AgentRunResult> {
const client = clientRef.client
if (client === null) return { kind: 'dead' }
const schemaInstruction = params.schema
? '\n\n以单独 JSON 对象回答(无围栏无解释),匹配 schema\n' +
JSON.stringify(params.schema)
: ''
const release = await apiSem.acquire()
try {
const resp = await withRetry(() =>
client.messages.create({
model: params.model ?? DEFAULT_MODEL,
max_tokens: params.maxTokens ?? 1024,
messages: [
{ role: 'user', content: params.prompt + schemaInstruction },
],
}),
)
const outputTokens = resp.usage.output_tokens
if (resp.stop_reason === 'max_tokens') return { kind: 'dead' }
const text = resp.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('')
.trim()
if (params.schema) {
const parsed = extractJsonObject(text)
if (parsed === null) return { kind: 'dead' }
if (!validateAgainstSchema(parsed, params.schema).valid) {
return { kind: 'dead' }
}
return { kind: 'ok', output: parsed as object, usage: { outputTokens } }
}
return { kind: 'ok', output: text, usage: { outputTokens } }
} catch (e) {
console.error(`${errSummary(e)}`)
return { kind: 'dead' }
} finally {
release()
}
}
function extractJsonObject(text: string): unknown | null {
const stripped = text.replace(/```(?:json)?/gi, '').trim()
const start = stripped.indexOf('{')
if (start < 0) {
try {
return JSON.parse(stripped)
} catch {
return null
}
}
let depth = 0
let inStr: string | null = null
for (let i = start; i < stripped.length; i++) {
const ch = stripped[i]
if (inStr) {
if (ch === '\\') i++
else if (ch === inStr) inStr = null
continue
}
if (ch === '"' || ch === "'" || ch === '`') inStr = ch
else if (ch === '{') depth++
else if (ch === '}') {
depth--
if (depth === 0) {
try {
return JSON.parse(stripped.slice(start, i + 1))
} catch {
return null
}
}
}
}
return null
}
function makePorts(runsDir: string): WorkflowPorts {
return {
agentRunner: { runAgentToResult: llmAgent },
progressEmitter: {
emit: (e: ProgressEvent) => {
if (e.type === 'phase_started') console.log(`\n━ phase: ${e.phase}`)
else if (e.type === 'agent_started')
console.log(`${e.label ?? 'agent'}`)
else if (e.type === 'agent_done')
console.log(
` ${e.result.kind === 'ok' ? '✓' : '✗'} ${e.label ?? ''} [${e.result.kind}]`,
)
else if (e.type === 'log') console.log(` · ${e.message}`)
},
},
taskRegistrar: {
register: () => ({
runId: 'smoke',
signal: new AbortController().signal,
}),
complete() {},
fail() {},
kill() {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(runsDir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: process.cwd(),
budgetTotal: null,
}),
}
}
async function main(): Promise<void> {
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) {
console.error('✗ 缺少 ANTHROPIC_API_KEY 环境变量')
process.exit(1)
}
clientRef.client = new Anthropic({ apiKey, logLevel: 'off' })
const runsDir =
process.env.RESEARCH_RUNS_DIR ?? join(homedir(), '.claude', 'workflow-runs')
const result = await runWorkflow({
script: SMOKE_SCRIPT,
args: {},
runId: `smoke-${Date.now()}`,
ports: makePorts(runsDir),
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: process.cwd(),
budgetTotal: null,
})
if (result.status !== 'completed') {
console.error(`\n✗ FAIL${result.status} ${result.error ?? ''}`)
process.exit(1)
}
const ret = result.returnValue as {
points: Array<{ point: string }>
summary: string
}
console.log('\n━━━━━━━━ 冒烟结果 ━━━━━━━━')
for (const p of ret.points) console.log(`${p.point}`)
console.log(`\n综合${ret.summary}`)
console.log(
`\n✓ PASS端到端通路正常${ret.points.length} 要点 + 综合3 次模型调用)`,
)
}
if (import.meta.main) {
await main()
}

View File

@@ -0,0 +1,19 @@
{
"name": "@claude-code-best/workflow-engine",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"dependencies": {
"ajv": "^8.18.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@anthropic-ai/sdk": "^0.81.0"
}
}

View File

@@ -0,0 +1,527 @@
import { expect, test } from 'bun:test'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createWorkflowTool } from '../tool/WorkflowTool.js'
import { createHostHandle, type WorkflowPorts } from '../ports.js'
import type { AgentRunParams, AgentRunResult, ProgressEvent } from '../types.js'
function mockPorts(
runsDir: string,
results: Map<string, AgentRunResult>,
): {
ports: WorkflowPorts
events: ProgressEvent[]
runStatus: Map<string, string>
} {
const events: ProgressEvent[] = []
const runStatus = new Map<string, string>()
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async (p: AgentRunParams) =>
results.get(p.prompt) ?? { kind: 'dead' },
},
progressEmitter: { emit: e => void events.push(e) },
taskRegistrar: {
register: () => ({
runId: 'run-x',
signal: new AbortController().signal,
}),
complete: id => void runStatus.set(id, 'completed'),
fail: id => void runStatus.set(id, 'failed'),
kill: id => void runStatus.set(id, 'killed'),
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: runsDir,
budgetTotal: null,
}),
}
return { ports, events, runStatus }
}
test('call returns launch message and completes in background', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
const { ports, runStatus } = mockPorts(
dir,
new Map([
['compute', { kind: 'ok', output: '42', usage: { outputTokens: 1 } }],
]),
)
const tool = createWorkflowTool(ports)
const res = await tool.call(
{ script: `return agent('compute')` },
undefined,
undefined,
undefined,
)
expect(res.data.output).toContain('run_id: run-x')
await new Promise(r => {
setTimeout(r, 50)
})
expect(runStatus.get('run-x')).toBe('completed')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('inline script persists to run directory, returns real scriptPath', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
const { ports } = mockPorts(
dir,
new Map([['x', { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }]]),
)
const tool = createWorkflowTool(ports)
const res = await tool.call(
{ script: `return agent('x')` },
undefined,
undefined,
undefined,
)
const expectedPath = join(
dir,
'.claude',
'workflow-runs',
'run-x',
'script.js',
)
expect(res.data.output).toContain(expectedPath)
expect(await readFile(expectedPath, 'utf-8')).toBe(`return agent('x')`)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('missing script/name/scriptPath → returns error (does not enter background)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
const { ports, runStatus } = mockPorts(dir, new Map())
const tool = createWorkflowTool(ports)
const res = await tool.call({}, undefined, undefined, undefined)
expect(res.data.output).toMatch(/^Error:/)
expect(runStatus.size).toBe(0)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('script syntax error → returns validation error (does not enter background)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
const { ports, runStatus } = mockPorts(dir, new Map())
const tool = createWorkflowTool(ports)
const res = await tool.call(
{ script: `return ((` },
undefined,
undefined,
undefined,
)
expect(res.data.output).toMatch(/validation failed|Error/i)
expect(runStatus.size).toBe(0)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('name resolves to .claude/workflows/<name>.ts', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
await writeFile(
join(dir, '.claude', 'workflows', 'release.ts'),
`return agent('compute')`,
)
const { ports, runStatus } = mockPorts(
dir,
new Map([
['compute', { kind: 'ok', output: 'done', usage: { outputTokens: 1 } }],
]),
)
const tool = createWorkflowTool(ports)
const res = await tool.call(
{ name: 'release' },
undefined,
undefined,
undefined,
)
expect(res.data.output).toContain('run_id')
await new Promise(r => {
setTimeout(r, 50)
})
expect(runStatus.get('run-x')).toBe('completed')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('renderToolUseMessage / mapToolResultToToolResultBlockParam', () => {
const dir = '/tmp'
const { ports } = mockPorts(dir, new Map())
const tool = createWorkflowTool(ports)
expect(tool.renderToolUseMessage({ name: 'release' })).toBe(
'Workflow: release',
)
const block = tool.mapToolResultToToolResultBlockParam(
{ output: 'hi' },
'tu-1',
)
expect(block.tool_use_id).toBe('tu-1')
expect(block.type).toBe('tool_result')
expect(block.content[0]!.text).toBe('hi')
})
test('scriptPath resolves to file content and runs in background', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
const scriptFile = join(dir, 'external.ts')
await writeFile(scriptFile, `return agent('compute')`)
const { ports, runStatus } = mockPorts(
dir,
new Map([
['compute', { kind: 'ok', output: 'done', usage: { outputTokens: 1 } }],
]),
)
const tool = createWorkflowTool(ports)
const res = await tool.call(
{ scriptPath: scriptFile },
undefined,
undefined,
undefined,
)
expect(res.data.output).toContain('run_id')
expect(res.data.output).toContain('external.ts')
await new Promise(r => {
setTimeout(r, 50)
})
expect(runStatus.get('run-x')).toBe('completed')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('script runtime failure → onFinish routes to fail', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
const { ports, runStatus } = mockPorts(dir, new Map())
const tool = createWorkflowTool(ports)
await tool.call(
{ script: `throw new Error('boom')` },
undefined,
undefined,
undefined,
)
await new Promise(r => {
setTimeout(r, 50)
})
expect(runStatus.get('run-x')).toBe('failed')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('metadata methods: description/prompt/renderToolUseMessage', async () => {
const { ports } = mockPorts('/tmp', new Map())
const tool = createWorkflowTool(ports)
expect(tool.isEnabled()).toBe(true)
expect(tool.isReadOnly({})).toBe(false)
expect(await tool.description()).toBeTruthy()
expect(await tool.prompt()).toContain('Workflow')
expect(tool.renderToolUseMessage({})).toBe('Workflow: unknown')
expect(tool.renderToolUseMessage({ resumeFromRunId: 'r1' })).toBe(
'Workflow resume: r1',
)
})
test('prompt includes default concurrency 3 + AskUserQuestion guidance', async () => {
const { ports } = mockPorts('/tmp', new Map())
const tool = createWorkflowTool(ports)
const p = await tool.prompt()
expect(p).toMatch(/default is 3/i)
expect(p).toMatch(/maxConcurrency/i)
expect(p).toMatch(/AskUserQuestion/i)
})
test('name does not exist → returns error (does not enter background)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
const { ports, runStatus } = mockPorts(dir, new Map())
const tool = createWorkflowTool(ports)
const res = await tool.call(
{ name: 'nope' },
undefined,
undefined,
undefined,
)
expect(res.data.output).toMatch(/^Error:/)
expect(runStatus.size).toBe(0)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('workflow aborted → onFinish routes to kill', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
const runStatus = new Map<string, string>()
const ac = new AbortController()
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async () => ({
kind: 'ok',
output: 'x',
usage: { outputTokens: 1 },
}),
},
progressEmitter: { emit: () => {} },
taskRegistrar: {
register: () => ({ runId: 'run-x', signal: ac.signal }),
complete: id => void runStatus.set(id, 'completed'),
fail: id => void runStatus.set(id, 'failed'),
kill: id => void runStatus.set(id, 'killed'),
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: dir,
budgetTotal: null,
}),
}
ac.abort()
const tool = createWorkflowTool(ports)
await tool.call(
{ script: `return agent('x')` },
undefined,
undefined,
undefined,
)
await new Promise(r => {
setTimeout(r, 50)
})
expect(runStatus.get('run-x')).toBe('killed')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('args defensively parses when a JSON-stringified object (backward compatible with old z.string() contract)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
const capturedPrompts: unknown[] = []
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async (p: AgentRunParams) => {
capturedPrompts.push(p.prompt)
return { kind: 'ok', output: 'done', usage: { outputTokens: 1 } }
},
},
progressEmitter: { emit: () => {} },
taskRegistrar: {
register: () => ({
runId: 'run-x',
signal: new AbortController().signal,
}),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: dir,
budgetTotal: null,
}),
}
const tool = createWorkflowTool(ports)
await tool.call(
{
script: `return agent(args.commit)`,
// simulate stringified JSON sent by model under old contract
args: '{"commit":"abc123"}',
},
undefined,
undefined,
undefined,
)
await new Promise(r => {
setTimeout(r, 50)
})
// if args not normalized: args.commit === undefined (string has no commit property)
// if args normalized: args.commit === 'abc123'
expect(capturedPrompts).toContain('abc123')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('args keeps original value for non-legal JSON string without throwing', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
const capturedPrompts: unknown[] = []
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async (p: AgentRunParams) => {
capturedPrompts.push(p.prompt)
return { kind: 'ok', output: 'ok', usage: { outputTokens: 1 } }
},
},
progressEmitter: { emit: () => {} },
taskRegistrar: {
register: () => ({
runId: 'run-x',
signal: new AbortController().signal,
}),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: dir,
budgetTotal: null,
}),
}
const tool = createWorkflowTool(ports)
await tool.call(
{
// script uses args as a string: agent(args) → agent('hello')
script: `return agent(args)`,
args: 'hello',
},
undefined,
undefined,
undefined,
)
await new Promise(r => {
setTimeout(r, 50)
})
// 'hello' is not valid JSON, should be kept as a string
expect(capturedPrompts).toContain('hello')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('scriptPath out of bounds (resolved outside cwd) → rejected with error (prevents arbitrary file read)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
const subDir = join(dir, 'sub')
await mkdir(subDir, { recursive: true })
// place a script outside subDir (inside dir)
const outsideScript = join(dir, 'outside.ts')
await writeFile(outsideScript, `return agent('x')`)
// host.cwd = subDir, scriptPath is an absolute path outside subDir
const { ports, runStatus } = mockPorts(subDir, new Map())
const tool = createWorkflowTool(ports)
const res = await tool.call(
{ scriptPath: outsideScript },
undefined,
undefined,
undefined,
)
expect(res.data.output).toMatch(/^Error:/)
expect(res.data.output).toMatch(/out of bounds|outside|not within/i)
expect(runStatus.size).toBe(0)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('name contains ".." path segment → rejected (prevents path traversal escaping workflowDir)', async () => {
const outer = await mkdtemp(join(tmpdir(), 'wf-outer-'))
try {
// place evil.ts at outer root (outside .claude/workflows)
await writeFile(join(outer, 'evil.ts'), `return agent('x')`)
await mkdir(join(outer, '.claude', 'workflows'), { recursive: true })
const { ports, runStatus } = mockPorts(outer, new Map())
const tool = createWorkflowTool(ports)
// name = '../../evil' → after join escapes the workflows directory to outer/evil.ts
const res = await tool.call(
{ name: '../../evil' },
undefined,
undefined,
undefined,
)
expect(res.data.output).toMatch(/^Error:/)
expect(runStatus.size).toBe(0)
} finally {
await rm(outer, { recursive: true, force: true })
}
})
test('name contains path separators or is absolute → rejected', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
const { ports } = mockPorts(dir, new Map())
const tool = createWorkflowTool(ports)
for (const badName of ['foo/bar', '/etc/passwd', '..', '.']) {
const res = await tool.call(
{ name: badName },
undefined,
undefined,
undefined,
)
expect(res.data.output).toMatch(/^Error:/)
}
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('returnValue is an object → complete (formatValue takes JSON branch)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-tool-'))
try {
const { ports, runStatus } = mockPorts(
dir,
new Map([['x', { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }]]),
)
const tool = createWorkflowTool(ports)
await tool.call(
{
script: `await agent('x')\nreturn { ok: true, n: 1 }`,
},
undefined,
undefined,
undefined,
)
await new Promise(r => {
setTimeout(r, 50)
})
expect(runStatus.get('run-x')).toBe('completed')
} finally {
await rm(dir, { recursive: true, force: true })
}
})

View File

@@ -0,0 +1,156 @@
import { expect, test } from 'bun:test'
import {
AgentAdapterRegistry,
AdapterNotFoundError,
type AgentAdapter,
} from '../agentAdapter.js'
import { createHostHandle } from '../ports.js'
import type { AgentRunParams, AgentRunResult } from '../types.js'
function makeAdapter(
id: string,
result: AgentRunResult = {
kind: 'ok',
output: `out-${id}`,
usage: { outputTokens: 1 },
},
): AgentAdapter {
return {
id,
capabilities: { structuredOutput: true },
async run() {
return result
},
}
}
const P = (over: Partial<AgentRunParams> = {}): AgentRunParams => ({
prompt: 'p',
...over,
})
const CTX = {
host: createHostHandle(null),
signal: new AbortController().signal,
runId: 'r',
agentId: 1,
}
test('resolve goes to default adapter, run returns result', async () => {
const reg = new AgentAdapterRegistry()
.register(makeAdapter('a'))
.register(makeAdapter('b'))
.default('a')
expect(reg.resolve(P()).id).toBe('a')
const r = await reg.resolve(P()).run(P(), CTX)
expect(r.kind).toBe('ok')
})
test('route agentType hit takes priority over default', () => {
const reg = new AgentAdapterRegistry()
.register(makeAdapter('default'))
.register(makeAdapter('research'))
.route({ kind: 'agentType', agentType: 'researcher', adapter: 'research' })
.default('default')
expect(reg.resolve(P({ agentType: 'researcher' })).id).toBe('research')
expect(reg.resolve(P({ agentType: 'other' })).id).toBe('default')
})
test('route model prefix match', () => {
const reg = new AgentAdapterRegistry()
.register(makeAdapter('cheap'))
.register(makeAdapter('strong'))
.route({ kind: 'model', pattern: 'claude-opus', adapter: 'strong' })
.default('cheap')
expect(reg.resolve(P({ model: 'claude-opus-4' })).id).toBe('strong')
expect(reg.resolve(P({ model: 'claude-sonnet-4' })).id).toBe('cheap')
expect(reg.resolve(P()).id).toBe('cheap') // no model → default
})
test('route custom predicate', () => {
const reg = new AgentAdapterRegistry()
.register(makeAdapter('main'))
.register(makeAdapter('special'))
.route({
kind: 'custom',
match: p => p.prompt.includes('VIP'),
adapter: 'special',
})
.default('main')
expect(reg.resolve(P({ prompt: 'handle VIP case' })).id).toBe('special')
expect(reg.resolve(P({ prompt: 'normal' })).id).toBe('main')
})
test('rules match in order (first hit wins)', () => {
const reg = new AgentAdapterRegistry()
.register(makeAdapter('a'))
.register(makeAdapter('b'))
.route({ kind: 'agentType', agentType: 'x', adapter: 'a' })
.route({ kind: 'agentType', agentType: 'x', adapter: 'b' })
expect(reg.resolve(P({ agentType: 'x' })).id).toBe('a')
})
test('rule-matched adapter not registered → skip that rule and continue matching', () => {
const reg = new AgentAdapterRegistry()
.register(makeAdapter('real'))
.route({ kind: 'agentType', agentType: 'x', adapter: 'ghost' })
.route({ kind: 'agentType', agentType: 'x', adapter: 'real' })
expect(reg.resolve(P({ agentType: 'x' })).id).toBe('real')
})
test('no match and no default → AdapterNotFoundError', () => {
const reg = new AgentAdapterRegistry().register(makeAdapter('a'))
expect(() => reg.resolve(P())).toThrow(AdapterNotFoundError)
})
test('default points to an unregistered adapter → still throws (no silent fallback)', () => {
const reg = new AgentAdapterRegistry()
.register(makeAdapter('a'))
.default('missing')
expect(() => reg.resolve(P())).toThrow(AdapterNotFoundError)
})
test('has / get', () => {
const reg = new AgentAdapterRegistry().register(makeAdapter('a'))
expect(reg.has('a')).toBe(true)
expect(reg.has('b')).toBe(false)
expect(reg.get('a')?.id).toBe('a')
expect(reg.get('b')).toBeUndefined()
})
test('initializeAll / disposeAll triggers lifecycle (skips unimplemented)', async () => {
const events: string[] = []
const withLifecycle: AgentAdapter = {
id: 'a',
capabilities: { structuredOutput: false },
async run() {
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
},
async initialize() {
events.push('init-a')
},
async dispose() {
events.push('dispose-a')
},
}
const noLifecycle = makeAdapter('b') // no initialize/dispose
const reg = new AgentAdapterRegistry()
.register(withLifecycle)
.register(noLifecycle)
await reg.initializeAll()
await reg.disposeAll()
expect(events).toEqual(['init-a', 'dispose-a'])
})
test('capabilities declaration is readable', () => {
const adapter: AgentAdapter = {
id: 'a',
capabilities: { structuredOutput: true, tools: true, stream: false },
async run() {
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
},
}
expect(adapter.capabilities.structuredOutput).toBe(true)
expect(adapter.capabilities.tools).toBe(true)
expect(adapter.capabilities.stream).toBe(false)
})

View File

@@ -0,0 +1,94 @@
import { expect, test } from 'bun:test'
import { createEngineContext } from '../engine/context.js'
import { makeHooks } from '../engine/hooks.js'
import { createBufferingEmitter } from '../progress/events.js'
import { createHostHandle, type WorkflowPorts } from '../ports.js'
import type { AgentRunParams, AgentRunResult } from '../types.js'
function build(results: Map<string, AgentRunResult>) {
const { emitter, events } = createBufferingEmitter()
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async (p: AgentRunParams) =>
results.get(p.prompt) ?? { kind: 'dead' },
},
progressEmitter: emitter,
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
signal: new AbortController().signal,
cwd: '/tmp',
budgetTotal: null,
}),
}
const ctx = createEngineContext({
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
runId: 'r',
workflowName: 'w',
cwd: '/tmp',
budgetTotal: null,
})
return { ctx, events, hooks: makeHooks(ctx, async () => null) }
}
test('concurrent agents each get a unique agentId, started/done are paired', async () => {
const ok = (out: string): AgentRunResult => ({
kind: 'ok',
output: out,
usage: { outputTokens: 1 },
})
const { ctx, events, hooks } = build(
new Map([
['a', ok('1')],
['b', ok('2')],
]),
)
await hooks.parallel([() => hooks.agent('a'), () => hooks.agent('b')])
const started = events.filter(e => e.type === 'agent_started')
const done = events.filter(e => e.type === 'agent_done')
expect(started).toHaveLength(2)
expect(done).toHaveLength(2)
const ids = started.map(e => (e as { agentId: number }).agentId)
expect(new Set(ids).size).toBe(2)
for (const d of done as Array<{ agentId: number }>) {
expect(ids).toContain(d.agentId)
}
expect(ctx.resources.agentIdSeq.value).toBe(2)
})
test('agentId increases monotonically', async () => {
const ok = (out: string): AgentRunResult => ({
kind: 'ok',
output: out,
usage: { outputTokens: 1 },
})
const { events, hooks } = build(
new Map([
['a', ok('1')],
['b', ok('2')],
['c', ok('3')],
]),
)
await hooks.agent('a')
await hooks.agent('b')
await hooks.agent('c')
const ids = events
.filter(e => e.type === 'agent_started')
.map(e => (e as { agentId: number }).agentId)
expect(ids).toEqual([0, 1, 2])
})

View File

@@ -0,0 +1,29 @@
import { expect, test } from 'bun:test'
import { Budget, BudgetExhaustedError } from '../engine/budget.js'
test('total=null means unlimited', () => {
const b = new Budget(null)
expect(b.total).toBeNull()
expect(b.remaining()).toBe(Infinity)
b.addOutputTokens(999999)
expect(b.spent()).toBe(999999)
expect(() => b.assertCanSpend()).not.toThrow()
})
test('accumulates and throws when cap exceeded', () => {
const b = new Budget(100)
expect(b.remaining()).toBe(100)
b.addOutputTokens(40)
expect(b.spent()).toBe(40)
expect(b.remaining()).toBe(60)
expect(() => b.assertCanSpend()).not.toThrow()
b.addOutputTokens(60)
expect(b.spent()).toBe(100)
expect(() => b.assertCanSpend()).toThrow(BudgetExhaustedError)
})
test('addOutputTokens ignores negative values', () => {
const b = new Budget(100)
b.addOutputTokens(-50)
expect(b.spent()).toBe(0)
})

View File

@@ -0,0 +1,119 @@
import { expect, test } from 'bun:test'
import {
clampMaxConcurrency,
Semaphore,
maxConcurrency,
} from '../engine/concurrency.js'
import { DEFAULT_MAX_CONCURRENCY, MAX_CONCURRENCY_CAP } from '../constants.js'
test('Semaphore limits concurrency, permit transfer does not leak', async () => {
const sem = new Semaphore(2)
let active = 0
let peak = 0
const task = async (): Promise<void> => {
const release = await sem.acquire()
active++
peak = Math.max(peak, active)
await new Promise(r => {
setTimeout(r, 10)
})
active--
release()
}
await Promise.all(Array.from({ length: 6 }, () => task()))
expect(peak).toBe(2) // never exceeds permits
})
test('maxConcurrency returns DEFAULT_MAX_CONCURRENCY (=3)', () => {
expect(maxConcurrency()).toBe(DEFAULT_MAX_CONCURRENCY)
expect(maxConcurrency()).toBe(3)
})
test('clampMaxConcurrency: undefined/NaN→DEFAULT; <1→1; >CAP→CAP; normal value kept', () => {
expect(clampMaxConcurrency(undefined)).toBe(DEFAULT_MAX_CONCURRENCY)
expect(clampMaxConcurrency(Number.NaN)).toBe(DEFAULT_MAX_CONCURRENCY)
expect(clampMaxConcurrency(0)).toBe(1)
expect(clampMaxConcurrency(-3)).toBe(1)
expect(clampMaxConcurrency(MAX_CONCURRENCY_CAP + 100)).toBe(
MAX_CONCURRENCY_CAP,
)
expect(clampMaxConcurrency(5)).toBe(5)
expect(clampMaxConcurrency(1)).toBe(1)
expect(clampMaxConcurrency(MAX_CONCURRENCY_CAP)).toBe(MAX_CONCURRENCY_CAP)
// decimal truncation (Semaphore already does Math.max(1, Math.floor); clampMaxConcurrency explicitly truncs)
expect(clampMaxConcurrency(2.9)).toBe(2)
})
test('Semaphore(0) has at least 1 permit, acquire does not block', async () => {
const sem = new Semaphore(0)
const release = await sem.acquire()
expect(release).toBeTypeOf('function')
release()
})
test('Semaphore wakes up in FIFO order', async () => {
const sem = new Semaphore(1)
const order: string[] = []
const first = await sem.acquire()
const p1 = sem.acquire().then(r => {
order.push('p1')
return r
})
const p2 = sem.acquire().then(r => {
order.push('p2')
return r
})
await new Promise(r => {
setTimeout(r, 5)
})
expect(order).toEqual([])
first()
await new Promise(r => {
setTimeout(r, 5)
})
expect(order).toEqual(['p1'])
;(await p1)()
await new Promise(r => {
setTimeout(r, 5)
})
expect(order).toEqual(['p1', 'p2'])
;(await p2)()
})
test('Semaphore.acquire with an aborted signal → immediately rejects, no permit consumed', async () => {
// Fix L: a queued waiter on abort must reject immediately instead of waiting for a permit.
// Otherwise a cancelled agent blocks on acquire(), the permit is consumed (transferred to a dead waiter),
// reducing actual concurrency capacity; in the worst case all waiters are cancelled while the semaphore still queues for dead waiters.
const sem = new Semaphore(1)
const ac = new AbortController()
// occupy the only permit
const first = await sem.acquire()
// queued waiter
const queued = sem.acquire(ac.signal)
await new Promise(r => {
setTimeout(r, 5)
})
// abort → waiter should reject immediately
ac.abort()
await expect(queued).rejects.toThrow()
// no permit leak: after releasing first, a new acquire should get it immediately (no stale waiter preemption)
first()
const third = await sem.acquire()
expect(third).toBeTypeOf('function')
third()
})
test('Semaphore.acquire with an already aborted signal → synchronous reject', async () => {
const sem = new Semaphore(1)
const ac = new AbortController()
ac.abort()
// signal already aborted, should not acquire even if a permit is available (semantics: caller already cancelled)
// Note: current implementation checks available first and may return directly. This test locks "check abort first".
// If the implementation chose "prefer granting when permit available", this test would change to: acquire succeeds, caller checks abort later.
// Current implementation chose the former: aborted signal throws immediately, preventing dead agents from grabbing permits.
await expect(sem.acquire(ac.signal)).rejects.toThrow()
})

View File

@@ -0,0 +1,139 @@
import { expect, test } from 'bun:test'
import { createBufferingEmitter } from '../progress/events.js'
import {
createEngineContext,
createSharedResources,
} from '../engine/context.js'
import { WorkflowError } from '../engine/errors.js'
import { createHostHandle, type WorkflowPorts } from '../ports.js'
function mockPorts(): WorkflowPorts {
return {
agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) },
progressEmitter: { emit: () => {} },
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: '/tmp',
budgetTotal: null,
}),
}
}
test('createSharedResources initializes budget and counts', () => {
const r = createSharedResources(100)
expect(r.budget.total).toBe(100)
expect(r.agentCountBox.value).toBe(0)
expect(r.depth).toBe(0)
})
test('createSharedResources: maxConcurrency controls semaphore permits', async () => {
// default permits = DEFAULT_MAX_CONCURRENCY = 3: after 4 acquires the 4th is pending
const r1 = createSharedResources(null)
const releases1: Array<() => void> = []
for (let i = 0; i < 3; i++) releases1.push(await r1.semaphore.acquire())
let fourthResolved = false
const pending = r1.semaphore.acquire().then(r => {
fourthResolved = true
return r
})
await new Promise(res => {
setTimeout(res, 5)
})
expect(fourthResolved).toBe(false)
releases1[0]!() // release one, the fourth should be woken up
releases1.push(await pending)
for (const rel of releases1) rel()
// explicit maxConcurrency=2: the 3rd acquire is pending
const r2 = createSharedResources(null, 2)
const releases2: Array<() => void> = []
releases2.push(await r2.semaphore.acquire())
releases2.push(await r2.semaphore.acquire())
let thirdResolved = false
const pending2 = r2.semaphore.acquire().then(r => {
thirdResolved = true
return r
})
await new Promise(res => {
setTimeout(res, 5)
})
expect(thirdResolved).toBe(false)
releases2[0]!()
releases2.push(await pending2)
for (const rel of releases2) rel()
})
test('createEngineContext passes maxConcurrency through to resources.semaphore', async () => {
const ctx = createEngineContext({
ports: mockPorts(),
host: createHostHandle(null),
signal: new AbortController().signal,
runId: 'r-mc',
workflowName: 'w',
cwd: '/tmp',
budgetTotal: null,
maxConcurrency: 1,
})
// maxConcurrency=1: the second acquire should be pending
const first = await ctx.resources.semaphore.acquire()
let secondResolved = false
const pending = ctx.resources.semaphore.acquire().then(r => {
secondResolved = true
return r
})
await new Promise(res => {
setTimeout(res, 5)
})
expect(secondResolved).toBe(false)
first()
await pending
})
test('createEngineContext copies journal and resets cursor', () => {
const journal = [
{
key: 'k',
seq: 0,
result: { kind: 'ok' as const, output: 'x', usage: { outputTokens: 1 } },
},
]
const ctx = createEngineContext({
ports: mockPorts(),
host: createHostHandle(null),
signal: new AbortController().signal,
runId: 'r1',
workflowName: 'w',
cwd: '/tmp',
budgetTotal: null,
journal,
})
expect(ctx.journal).toHaveLength(1)
expect(ctx.journalIndex).toBe(0)
expect(ctx.journalInvalidated).toBe(false)
})
test('createBufferingEmitter collects events', () => {
const { emitter, events } = createBufferingEmitter()
emitter.emit({ type: 'log', runId: 'r', message: 'hi' })
expect(events).toHaveLength(1)
})
test('WorkflowError is recognizable', () => {
const e = new WorkflowError('boom')
expect(e).toBeInstanceOf(Error)
expect(e.message).toBe('boom')
})

View File

@@ -0,0 +1,39 @@
import { expect, test } from 'bun:test'
import { WorkflowError, WorkflowAbortedError } from '../engine/errors.js'
test('WorkflowError carries message and name', () => {
const e = new WorkflowError('script error')
expect(e).toBeInstanceOf(Error)
expect(e.message).toBe('script error')
expect(e.name).toBe('WorkflowError')
})
test('WorkflowAbortedError is a recognizable cancellation error', () => {
const e = new WorkflowAbortedError()
expect(e).toBeInstanceOf(Error)
expect(e.name).toBe('WorkflowAbortedError')
expect(e.message).toBeTruthy()
})
test('the two error types can be distinguished by instanceof (not confused)', () => {
const a = new WorkflowError('x')
const b = new WorkflowAbortedError()
expect(a).toBeInstanceOf(WorkflowError)
expect(a).not.toBeInstanceOf(WorkflowAbortedError)
expect(b).toBeInstanceOf(WorkflowAbortedError)
expect(b).not.toBeInstanceOf(WorkflowError)
})
test('can be caught as a plain Error in a catch block', () => {
const throwIt = (): never => {
throw new WorkflowAbortedError()
}
let caught: unknown = null
try {
throwIt()
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(Error)
expect(caught).toBeInstanceOf(WorkflowAbortedError)
})

View File

@@ -0,0 +1,51 @@
import { expect, test } from 'bun:test'
import {
createBufferingEmitter,
createProgressEmitter,
} from '../progress/events.js'
import type { ProgressEvent } from '../types.js'
const log = (message: string): ProgressEvent =>
({ type: 'log', runId: 'r', message }) as ProgressEvent
const phase = (p: string): ProgressEvent =>
({ type: 'phase_started', runId: 'r', phase: p }) as ProgressEvent
test('createBufferingEmitter collects all events in order', () => {
const { emitter, events } = createBufferingEmitter()
emitter.emit(log('a'))
emitter.emit(phase('P'))
expect(events).toHaveLength(2)
expect(events[0]).toEqual(log('a'))
expect(events[1]).toEqual(phase('P'))
})
test('createBufferingEmitter emit returns void (no return value)', () => {
const { emitter } = createBufferingEmitter()
expect(emitter.emit(log('x'))).toBeUndefined()
})
test('createBufferingEmitter instances are independent (no shared buffer)', () => {
const a = createBufferingEmitter()
const b = createBufferingEmitter()
a.emitter.emit(log('1'))
expect(a.events).toHaveLength(1)
expect(b.events).toHaveLength(0)
})
test('createProgressEmitter forwards events to callback (in order, no buffering)', () => {
const received: ProgressEvent[] = []
const emitter = createProgressEmitter(e => void received.push(e))
emitter.emit(log('a'))
emitter.emit(log('b'))
expect(received).toEqual([log('a'), log('b')])
})
test('createProgressEmitter triggers callback synchronously', () => {
let seen = ''
const emitter = createProgressEmitter(e => {
seen = (e as { message: string }).message
})
emitter.emit(log('sync'))
// callback already executed before emit returns
expect(seen).toBe('sync')
})

View File

@@ -0,0 +1,614 @@
import { expect, test } from 'bun:test'
import { AgentAdapterRegistry } from '../agentAdapter.js'
import { createEngineContext } from '../engine/context.js'
import { maxConcurrency, Semaphore } from '../engine/concurrency.js'
import { agentCallKey } from '../engine/journal.js'
import { makeHooks, type SubWorkflowRunner } from '../engine/hooks.js'
import { WorkflowError, WorkflowAbortedError } from '../engine/errors.js'
import { createBufferingEmitter } from '../progress/events.js'
import { createHostHandle, type WorkflowPorts } from '../ports.js'
import type {
AgentRunParams,
AgentRunResult,
JournalEntry,
ProgressEvent,
} from '../types.js'
type CtxOverrides = Partial<{
agentResults: Map<string, AgentRunResult>
runner: (params: AgentRunParams) => Promise<AgentRunResult>
pending: { kind: 'skip' | 'retry' } | null
journal: JournalEntry[]
budgetTotal: number | null
signal: AbortSignal
truncated: string[]
agentAdapterRegistry: AgentAdapterRegistry
loggerWarn: (msg: string) => void
// taskRegistrar agent-level abort binding (agent kill bridge).
// When provided, buildCtx injects it into ports.taskRegistrar; hooks.agent pushes the closure into adapterCtx.
registerAgentAbort: (
runId: string,
agentId: number,
ac: AbortController,
) => void
unregisterAgentAbort: (runId: string, agentId: number) => void
}>
function buildCtx(overrides: CtxOverrides = {}): {
ctx: ReturnType<typeof createEngineContext>
events: ProgressEvent[]
hooks: ReturnType<typeof makeHooks>
} {
const { emitter, events } = createBufferingEmitter()
const results = overrides.agentResults ?? new Map<string, AgentRunResult>()
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: overrides.runner
? overrides.runner
: async (params: AgentRunParams) =>
results.get(params.prompt) ?? { kind: 'dead' },
},
...(overrides.agentAdapterRegistry
? { agentAdapterRegistry: overrides.agentAdapterRegistry }
: {}),
progressEmitter: emitter,
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => overrides.pending ?? null,
...(overrides.registerAgentAbort
? { registerAgentAbort: overrides.registerAgentAbort }
: {}),
...(overrides.unregisterAgentAbort
? { unregisterAgentAbort: overrides.unregisterAgentAbort }
: {}),
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async (id: string) => {
overrides.truncated?.push(id)
},
},
permissionGate: { isAborted: () => false },
logger: {
debug: () => {},
event: () => {},
...(overrides.loggerWarn ? { warn: overrides.loggerWarn } : {}),
},
hostFactory: () => ({
handle: createHostHandle(null),
cwd: '/tmp',
budgetTotal: null,
}),
}
const ctx = createEngineContext({
ports,
host: createHostHandle(null),
signal: overrides.signal ?? new AbortController().signal,
runId: 'r1',
workflowName: 'w',
cwd: '/tmp',
budgetTotal: overrides.budgetTotal ?? null,
journal: overrides.journal,
})
const noopSub: SubWorkflowRunner = async () => null
return { ctx, events, hooks: makeHooks(ctx, noopSub) }
}
test('agent returns text result and counts', async () => {
const { ctx, hooks } = buildCtx({
agentResults: new Map([
['hi', { kind: 'ok', output: 'hello', usage: { outputTokens: 5 } }],
]),
})
const out = await hooks.agent('hi')
expect(out).toBe('hello')
expect(ctx.resources.agentCountBox.value).toBe(1)
})
test('agent skipped → null and not counted', async () => {
const { hooks } = buildCtx({
agentResults: new Map([['hi', { kind: 'skipped' }]]),
})
expect(await hooks.agent('hi')).toBeNull()
})
test('agent dead → null', async () => {
const { hooks } = buildCtx({
agentResults: new Map([['hi', { kind: 'dead' }]]),
})
expect(await hooks.agent('hi')).toBeNull()
})
// Retry: dead or non-abort throw both get one retry chance; WorkflowAbortedError (kill) is not retried.
// Retry still fails: dead stays dead; throw degrades to dead (does not break the workflow, hooks.agent returns null).
test('agent dead → retry once succeeds → ok', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
return calls === 1
? { kind: 'dead' as const }
: {
kind: 'ok' as const,
output: 'recovered',
usage: { outputTokens: 5 },
}
},
})
expect(await hooks.agent('p')).toBe('recovered')
expect(calls).toBe(2)
})
test('agent dead → retry still dead → final null (dead stays dead)', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
return { kind: 'dead' as const }
},
loggerWarn: () => {},
})
expect(await hooks.agent('p')).toBeNull()
expect(calls).toBe(2)
})
test('agent non-abort throw → retry once succeeds → ok', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
if (calls === 1) throw new Error('transient network')
return {
kind: 'ok' as const,
output: 'recovered',
usage: { outputTokens: 3 },
}
},
loggerWarn: () => {},
})
expect(await hooks.agent('p')).toBe('recovered')
expect(calls).toBe(2)
})
test('agent non-abort throw → retry still throws → degrade to dead (returns null, does not break workflow)', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
throw new Error('persistent')
},
loggerWarn: () => {},
})
expect(await hooks.agent('p')).toBeNull()
expect(calls).toBe(2)
})
test('agent throw WorkflowAbortedError → no retry, rethrow directly (kill does not allow retry)', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
throw new WorkflowAbortedError()
},
})
await expect(hooks.agent('p')).rejects.toBeInstanceOf(WorkflowAbortedError)
expect(calls).toBe(1)
})
test('agent ok → no retry (calls=1, saves a backend round-trip)', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
return {
kind: 'ok' as const,
output: 'first-try',
usage: { outputTokens: 1 },
}
},
})
expect(await hooks.agent('p')).toBe('first-try')
expect(calls).toBe(1)
})
test('agent skipped → no retry (user actively skips, no retry)', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
return { kind: 'skipped' as const }
},
})
expect(await hooks.agent('p')).toBeNull()
expect(calls).toBe(1)
})
test('agent journal hit does not call runner', async () => {
let called = 0
const { emitter } = createBufferingEmitter()
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async () => {
called++
return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } }
},
},
progressEmitter: emitter,
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: '/tmp',
budgetTotal: null,
}),
}
const key = agentCallKey('hi', { prompt: 'hi' })
const ctx = createEngineContext({
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
runId: 'r1',
workflowName: 'w',
cwd: '/tmp',
budgetTotal: null,
journal: [
{
key,
seq: 0,
result: { kind: 'ok', output: 'cached', usage: { outputTokens: 1 } },
},
],
})
const hooks = makeHooks(ctx, async () => null)
expect(await hooks.agent('hi')).toBe('cached')
expect(called).toBe(0)
})
test('agent exceeding total cap throws', async () => {
const { hooks, ctx } = buildCtx()
ctx.resources.agentCountBox.value = 1000
await expect(hooks.agent('hi')).rejects.toThrow(WorkflowError)
})
test('parallel single item throws → null, others kept', async () => {
const { hooks } = buildCtx()
const out = await hooks.parallel([
async () => 'a',
async () => {
throw new Error('x')
},
async () => 'c',
])
expect(out).toEqual(['a', null, 'c'])
})
test('parallel single item throws → logger.warn records the failure reason', async () => {
const warns: string[] = []
const { hooks } = buildCtx({ loggerWarn: msg => warns.push(msg) })
await hooks.parallel([
async () => 'a',
async () => {
throw new Error('boom-x')
},
async () => 'c',
])
expect(warns.length).toBe(1)
expect(warns[0]).toMatch(/boom-x/)
})
test('pipeline chains stage by stage, stage throws → null', async () => {
const { hooks } = buildCtx()
const out = await hooks.pipeline(
[1, 2],
n => Promise.resolve((n as number) + 1),
m => Promise.resolve((m as number) * 10),
)
expect(out).toEqual([20, 30])
const out2 = await hooks.pipeline(
[1],
() => Promise.reject(new Error('boom')),
m => Promise.resolve(m),
)
expect(out2).toEqual([null])
})
test('pipeline stage throws → logger.warn records the failure reason', async () => {
const warns: string[] = []
const { hooks } = buildCtx({ loggerWarn: msg => warns.push(msg) })
await hooks.pipeline(
[1],
() => Promise.reject(new Error('stage-boom')),
m => Promise.resolve(m),
)
expect(warns.length).toBe(1)
expect(warns[0]).toMatch(/stage-boom/)
})
test('pipeline over 4096 throws', async () => {
const { hooks } = buildCtx()
await expect(
hooks.pipeline(Array(4097), () => Promise.resolve(1)),
).rejects.toThrow(WorkflowError)
})
test('phase switch emits phase_started/done; log emits log', () => {
const { hooks, events } = buildCtx()
hooks.phase('A')
hooks.log('hello')
hooks.phase('B')
expect(events.some(e => e.type === 'phase_started' && e.phase === 'A')).toBe(
true,
)
expect(events.some(e => e.type === 'phase_done' && e.phase === 'A')).toBe(
true,
)
expect(events.some(e => e.type === 'log' && e.message === 'hello')).toBe(true)
expect(events.some(e => e.type === 'phase_started' && e.phase === 'B')).toBe(
true,
)
})
// ---- boundary and error paths ----
test('agent dead also counts in agentCountBox', async () => {
const { hooks, ctx } = buildCtx({
agentResults: new Map([['x', { kind: 'dead' }]]),
})
await hooks.agent('x')
expect(ctx.resources.agentCountBox.value).toBe(1)
})
test('agent pendingAction=skip → null, does not call runner, not counted', async () => {
let called = 0
const { hooks, ctx } = buildCtx({
pending: { kind: 'skip' },
runner: async () => {
called++
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
},
})
expect(await hooks.agent('x')).toBeNull()
expect(called).toBe(0)
expect(ctx.resources.agentCountBox.value).toBe(0)
})
test('agent journal key diverges → invalidate and truncate', async () => {
const truncated: string[] = []
const { hooks, ctx } = buildCtx({
runner: async () => ({
kind: 'ok',
output: 'live',
usage: { outputTokens: 1 },
}),
journal: [
{
key: 'stale-key',
seq: 0,
result: { kind: 'ok', output: 'old', usage: { outputTokens: 1 } },
},
],
truncated,
})
const out = await hooks.agent('different-prompt')
expect(out).toBe('live')
expect(truncated).toContain('r1')
expect(ctx.journalInvalidated).toBe(true)
})
test('agent throws when budget exhausted', async () => {
const { hooks, ctx } = buildCtx({
budgetTotal: 10,
runner: async () => ({
kind: 'ok',
output: 'x',
usage: { outputTokens: 1 },
}),
})
ctx.resources.budget.addOutputTokens(10)
await expect(hooks.agent('x')).rejects.toThrow()
})
test('agent budget check inside semaphore critical section (queued waiter sees latest spent)', async () => {
// When semaphore capacity < parallel agent count, some agents will queue.
// Old bug: assertCanSpend was before acquire, all waiters entered the queue with spent=0 and passed the check;
// after permits released waiters ran the runner and deducted the budget without re-checking → all over-spent.
// Fix: assertCanSpend moved into the critical section; waiters check spent after being woken before deciding to run.
// Force capacity=1 (serializing semaphore) to ensure N>1 agents must queue.
const { hooks, ctx } = buildCtx({
budgetTotal: 10,
runner: async () => {
// make the runner a bit slow to ensure waiters truly queue
await new Promise(r => {
setTimeout(r, 5)
})
return {
kind: 'ok',
output: 'x',
usage: { outputTokens: 6 }, // 6 tokens each, 2 runs exceed 10
}
},
})
// replace the default semaphore with a single-permit one, forcing serialization
ctx.resources.semaphore = new Semaphore(1)
const results = await hooks.parallel([
() => hooks.agent('a'),
() => hooks.agent('b'),
() => hooks.agent('c'),
() => hooks.agent('d'),
])
// at least 1 agent is caught as null by parallel (assertCanSpend throws)
expect(results.some(r => r === null)).toBe(true)
// not all 4 should run and spend 24; the cap is at-most-one-over (first two spend 12, last two blocked)
expect(ctx.resources.budget.spent()).toBeLessThanOrEqual(12)
})
test('agent signal aborted → WorkflowAbortedError', async () => {
const ac = new AbortController()
ac.abort()
const { hooks } = buildCtx({
signal: ac.signal,
runner: async () => ({
kind: 'ok',
output: 'x',
usage: { outputTokens: 1 },
}),
})
await expect(hooks.agent('x')).rejects.toThrow(WorkflowAbortedError)
})
test('parallel over 4096 items throws', async () => {
const { hooks } = buildCtx()
await expect(
hooks.parallel(Array.from({ length: 4097 }, () => async () => 1)),
).rejects.toThrow(WorkflowError)
})
test('workflow() nesting beyond one level throws', async () => {
const { hooks, ctx } = buildCtx()
ctx.resources.depth = 1
await expect(hooks.workflow('child')).rejects.toThrow(WorkflowError)
})
test('agent concurrency bounded by semaphore (does not exceed maxConcurrency)', async () => {
let active = 0
let peak = 0
const { hooks } = buildCtx({
runner: async () => {
active++
peak = Math.max(peak, active)
await new Promise(r => {
setTimeout(r, 5)
})
active--
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
},
})
await hooks.parallel(Array.from({ length: 32 }, () => () => hooks.agent('p')))
expect(peak).toBeLessThanOrEqual(maxConcurrency())
})
test('agentAdapterRegistry takes priority over agentRunner (dispatched to adapter by route)', async () => {
const called: string[] = []
const registry = new AgentAdapterRegistry()
.register({
id: 'ad',
capabilities: { structuredOutput: true },
async run() {
called.push('adapter')
return {
kind: 'ok',
output: 'from-adapter',
usage: { outputTokens: 1 },
}
},
})
.default('ad')
const { hooks } = buildCtx({
agentAdapterRegistry: registry,
runner: async () => {
called.push('runner')
return { kind: 'ok', output: 'from-runner', usage: { outputTokens: 1 } }
},
})
expect(await hooks.agent('x')).toBe('from-adapter')
expect(called).toEqual(['adapter'])
})
test('agentAdapterRegistry resolve throws → agent rethrows (workflow failed)', async () => {
const registry = new AgentAdapterRegistry().default('missing') // not registered
const { hooks } = buildCtx({
agentAdapterRegistry: registry,
runner: async () => ({
kind: 'ok',
output: 'x',
usage: { outputTokens: 1 },
}),
})
await expect(hooks.agent('x')).rejects.toThrow()
})
// service.kill(runId, agentId) bridge: hooks.agent must inject taskRegistrar's
// registerAgentAbort/unregisterAgentAbort into adapterCtx (bound to the current runId).
// The backend puts the agentAbort controller into a Map based on this; service.kill aborts precisely by agentId.
test('agentAdapter ctx injects registerAgentAbort/unregisterAgentAbort (bound to runId, forwards to taskRegistrar)', async () => {
const registered: Array<{
runId: string
agentId: number
controller: AbortController
}> = []
const unregistered: Array<{ runId: string; agentId: number }> = []
// capture the ctx hooks pass to the adapter (verify register/unregister are injected and bound to runId)
let capturedCtx: {
registerAgentAbort?: (id: number, ac: AbortController) => void
unregisterAgentAbort?: (id: number) => void
agentId: number
runId: string
} | null = null
const registry = new AgentAdapterRegistry()
.register({
id: 'ad',
capabilities: { structuredOutput: true },
async run(_params, ctx) {
capturedCtx = ctx
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
},
})
.default('ad')
const { hooks } = buildCtx({
agentAdapterRegistry: registry,
registerAgentAbort: (runId, agentId, controller) =>
registered.push({ runId, agentId, controller }),
unregisterAgentAbort: (runId, agentId) =>
unregistered.push({ runId, agentId }),
})
await hooks.agent('x')
// ctx contains register/unregister (closure bound to runId='r1')
expect(capturedCtx).not.toBeNull()
expect(typeof capturedCtx!.registerAgentAbort).toBe('function')
expect(typeof capturedCtx!.unregisterAgentAbort).toBe('function')
// simulate backend call: the injected closure forwards (agentId, controller) to taskRegistrar,
// and auto-fills runId='r1' (backend does not need to know runId)
const ac = new AbortController()
capturedCtx!.registerAgentAbort!(7, ac)
capturedCtx!.unregisterAgentAbort!(7)
expect(registered).toEqual([{ runId: 'r1', agentId: 7, controller: ac }])
expect(unregistered).toEqual([{ runId: 'r1', agentId: 7 }])
})
test('taskRegistrar does not provide registerAgentAbort → adapterCtx also lacks it (hooks do not error)', async () => {
// without registerAgentAbort/unregisterAgentAbort overrides → buildCtx does not inject taskRegistrar either
// hooks skip via optional chaining; adapterCtx lacks these two fields
let capturedCtx: object | null = null
const registry = new AgentAdapterRegistry()
.register({
id: 'ad',
capabilities: { structuredOutput: true },
async run(_params, ctx) {
capturedCtx = ctx
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
},
})
.default('ad')
const { hooks } = buildCtx({ agentAdapterRegistry: registry })
await hooks.agent('x')
expect(capturedCtx).not.toBeNull()
expect(
(capturedCtx! as Record<string, unknown>).registerAgentAbort,
).toBeUndefined()
})

View File

@@ -0,0 +1,89 @@
import { expect, test } from 'bun:test'
import * as wf from '../index.js'
test('engine core API fully exported', () => {
expect(typeof wf.runWorkflow).toBe('function')
expect(typeof wf.parseScript).toBe('function')
expect(typeof wf.extractMeta).toBe('function')
expect(typeof wf.makeHooks).toBe('function')
expect(typeof wf.createEngineContext).toBe('function')
expect(typeof wf.createSharedResources).toBe('function')
})
test('ports / host API fully exported', () => {
expect(typeof wf.createHostHandle).toBe('function')
expect(typeof wf.isHostHandle).toBe('function')
expect(typeof wf.unwrapHostHandle).toBe('function')
})
test('persistence / structured output / named workflow / progress API fully exported', () => {
expect(typeof wf.createFileJournalStore).toBe('function')
expect(typeof wf.agentCallKey).toBe('function')
expect(typeof wf.validateAgainstSchema).toBe('function')
expect(typeof wf.resolveNamedWorkflow).toBe('function')
expect(typeof wf.listNamedWorkflows).toBe('function')
expect(typeof wf.createBufferingEmitter).toBe('function')
expect(typeof wf.createProgressEmitter).toBe('function')
})
test('concurrency / budget / error classes fully exported', () => {
expect(typeof wf.Semaphore).toBe('function')
expect(typeof wf.maxConcurrency).toBe('function')
expect(typeof wf.clampMaxConcurrency).toBe('function')
expect(typeof wf.Budget).toBe('function')
expect(typeof wf.BudgetExhaustedError).toBe('function')
expect(typeof wf.WorkflowError).toBe('function')
expect(typeof wf.WorkflowAbortedError).toBe('function')
expect(typeof wf.ScriptError).toBe('function')
})
test('tool descriptor and input schema exported', () => {
expect(typeof wf.createWorkflowTool).toBe('function')
expect(typeof wf.workflowInputSchema).toBe('object')
expect(wf.WORKFLOW_TOOL_NAME).toBe('Workflow')
})
test('engine constant values are stable', () => {
expect(wf.WORKFLOW_DIR_NAME).toBe('.claude/workflows')
expect(wf.WORKFLOW_RUNS_DIR).toBe('.claude/workflow-runs')
expect(wf.WORKFLOW_TOOL_NAME).toBe('Workflow')
expect(wf.MAX_TOTAL_AGENTS).toBe(1000)
expect(wf.MAX_ITEMS_PER_CALL).toBe(4096)
expect(wf.MAX_CONCURRENCY_CAP).toBe(16)
expect(wf.DEFAULT_MAX_CONCURRENCY).toBe(3)
expect(wf.WORKFLOW_SCRIPT_EXTENSIONS).toEqual(['.ts', '.js', '.mjs'])
})
test('createWorkflowTool returns complete descriptor shape', () => {
const tool = wf.createWorkflowTool({
agentRunner: { runAgentToResult: async () => ({ kind: 'dead' }) },
progressEmitter: { emit: () => {} },
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete() {},
fail() {},
kill() {},
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: wf.createHostHandle(null),
cwd: '/tmp',
budgetTotal: null,
}),
})
expect(tool.name).toBe('Workflow')
expect(tool.isEnabled()).toBe(true)
expect(tool.isReadOnly({})).toBe(false)
expect(typeof tool.call).toBe('function')
expect(typeof tool.description).toBe('function')
expect(typeof tool.prompt).toBe('function')
expect(typeof tool.renderToolUseMessage).toBe('function')
expect(typeof tool.mapToolResultToToolResultBlockParam).toBe('function')
})

View File

@@ -0,0 +1,282 @@
/**
* Integration test: runs the canonical workflow script (canonical pattern from the Workflow tool definition:
* pipeline without barrier + parallel barrier + agent(schema) + phase) with a faithful mock adapter.
* Verifies the engine is semantically compatible with real workflow scripts.
*/
import { expect, test } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runWorkflow } from '../engine/runWorkflow.js'
import { createFileJournalStore } from '../engine/journal.js'
import { createHostHandle, type WorkflowPorts } from '../ports.js'
import { createBufferingEmitter } from '../progress/events.js'
import type { AgentRunParams, AgentRunResult, ProgressEvent } from '../types.js'
function canonicalPorts(runsDir: string): {
ports: WorkflowPorts
events: ProgressEvent[]
agentCalls: AgentRunParams[]
} {
const { emitter, events } = createBufferingEmitter()
const agentCalls: AgentRunParams[] = []
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async (
params: AgentRunParams,
): Promise<AgentRunResult> => {
agentCalls.push(params)
const p = params.prompt
if (p.startsWith('review-')) {
return {
kind: 'ok',
output: { findings: [{ title: `${p}-finding`, file: 'a.ts' }] },
usage: { outputTokens: 5 },
}
}
if (p.startsWith('verify')) {
return {
kind: 'ok',
output: { isReal: true },
usage: { outputTokens: 2 },
}
}
return { kind: 'dead' }
},
},
progressEmitter: emitter,
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(runsDir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: runsDir,
budgetTotal: null,
}),
}
return { ports, events, agentCalls }
}
// canonical review pattern (pipeline→parallel→verify→synthesize), verbatim from the Workflow tool definition.
const CANONICAL_REVIEW_SCRIPT = `
export const meta = {
name: 'review-changes',
description: 'Review changed files across dimensions, verify each finding',
phases: [{ title: 'Review' }, { title: 'Verify' }],
}
const DIMENSIONS = [
{ key: 'bugs', prompt: 'review-bugs' },
{ key: 'perf', prompt: 'review-perf' },
]
const FINDINGS_SCHEMA = { type: 'object' }
const VERDICT_SCHEMA = { type: 'object' }
phase('Review')
const results = await pipeline(
DIMENSIONS,
d => agent(d.prompt, { label: 'review:' + d.key, phase: 'Review', schema: FINDINGS_SCHEMA }),
review => parallel(
review.findings.map(f => () =>
agent('verify: ' + f.title, { label: 'verify:' + f.file, phase: 'Verify', schema: VERDICT_SCHEMA })
.then(v => ({ ...f, verdict: v }))
)
)
)
const all = results.flat().filter(Boolean)
const confirmed = all.filter(f => f.verdict && f.verdict.isReal)
return { confirmed, total: all.length }
`
test('canonical review script end-to-end compatibility', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-int-'))
try {
const { ports, events, agentCalls } = canonicalPorts(dir)
const result = await runWorkflow({
script: CANONICAL_REVIEW_SCRIPT,
runId: 'int-1',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
expect(result.status).toBe('completed')
const ret = result.returnValue as { confirmed: unknown[]; total: number }
// 2 dimensions × 1 finding, all isReal=true → confirmed=2, total=2
expect(ret.total).toBe(2)
expect(ret.confirmed).toHaveLength(2)
// 2 review agents + 2 verify agents = 4
expect(agentCalls).toHaveLength(4)
expect(agentCalls.filter(c => c.prompt.startsWith('review-'))).toHaveLength(
2,
)
expect(agentCalls.filter(c => c.prompt.startsWith('verify'))).toHaveLength(
2,
)
// progress events: run_started/done + phase Review/Verify + agent started/done
expect(
events.some(
e => e.type === 'run_started' && e.workflowName === 'review-changes',
),
).toBe(true)
expect(
events.some(e => e.type === 'run_done' && e.status === 'completed'),
).toBe(true)
// script explicitly calls phase('Review') once; the verify agent's phase:'Verify' is a display label, does not emit phase_started
expect(
events.filter(e => e.type === 'phase_started' && e.phase === 'Review'),
).toHaveLength(1)
expect(events.filter(e => e.type === 'agent_started')).toHaveLength(4)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('loop-until-dry pattern: two consecutive rounds with no new findings converges', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-int-'))
try {
let round = 0
const { emitter, events } = createBufferingEmitter()
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async (
p: AgentRunParams,
): Promise<AgentRunResult> => {
round++
// rounds 1-2 return findings, round 3+ returns empty → converges
const found = round <= 2 ? [{ b: round }] : []
return {
kind: 'ok',
output: { bugs: found },
usage: { outputTokens: 1 },
}
},
},
progressEmitter: emitter,
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(dir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: dir,
budgetTotal: null,
}),
}
const script = `
const seen = []
const confirmed = []
let dry = 0
while (dry < 2) {
const found = (await agent('find bugs')).bugs
const fresh = found.filter(b => !seen.includes(b.b))
if (fresh.length === 0) { dry++; continue }
dry = 0
for (const b of fresh) seen.push(b.b)
confirmed.push(...fresh)
}
return { confirmed }
`
const result = await runWorkflow({
script,
runId: 'int-2',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
expect(result.status).toBe('completed')
const ret = result.returnValue as { confirmed: { b: number }[] }
// round1 finds {b:1}, round2 finds {b:2} (fresh, since seen=[1]), round3 found{b:3}?
// mock counts by round: round1→{b:1}, round2→{b:2}, round3→[] (found empty)
// but round2 found=[{b:2}], seen=[1], fresh=[{b:2}] → confirmed=[{b:1},{b:2}], dry=0
// round3 found=[] → fresh=[] → dry=1; round4 found=[] → dry=2 → exits
expect(ret.confirmed).toHaveLength(2)
expect(
events.some(e => e.type === 'run_done' && e.status === 'completed'),
).toBe(true)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('resume compatibility: second run hits journal, agents do not re-run', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-int-'))
try {
let calls = 0
const makePorts = (): WorkflowPorts => ({
agentRunner: {
runAgentToResult: async () => {
calls++
return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } }
},
},
progressEmitter: { emit: () => {} },
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(dir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: dir,
budgetTotal: null,
}),
})
const script = `
phase('A')
const a = await agent('do-a')
const b = await agent('do-b')
return { a, b }
`
// first run: 2 agents run live
const first = await runWorkflow({
script,
runId: 'int-3',
ports: makePorts(),
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
expect(first.status).toBe('completed')
expect(calls).toBe(2)
// resume same runId: journal hit, no re-run
calls = 0
const resumed = await runWorkflow({
script,
runId: 'int-3',
ports: makePorts(),
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
resume: true,
})
expect(resumed.status).toBe('completed')
expect(calls).toBe(0)
} finally {
await rm(dir, { recursive: true, force: true })
}
})

View File

@@ -0,0 +1,113 @@
import { expect, test } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { agentCallKey, createFileJournalStore } from '../engine/journal.js'
import type { AgentRunParams } from '../types.js'
const base: AgentRunParams = { prompt: 'do something' }
test('agentCallKey stable for same prompt+params', () => {
expect(agentCallKey('p', base)).toBe(agentCallKey('p', base))
})
test('agentCallKey varies with prompt', () => {
expect(agentCallKey('p1', base)).not.toBe(agentCallKey('p2', base))
})
test('agentCallKey ignores display-only fields label/phase', () => {
const a = agentCallKey('p', { ...base, label: 'A', phase: 'ph1' })
const b = agentCallKey('p', { ...base, label: 'B', phase: 'ph2' })
expect(a).toBe(b)
})
test('FileJournalStore append → read preserves order, truncate clears', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-journal-'))
try {
const store = createFileJournalStore(dir)
const e1 = {
key: 'k1',
seq: 0,
result: { kind: 'ok' as const, output: 'x', usage: { outputTokens: 1 } },
}
const e2 = { key: 'k2', seq: 1, result: { kind: 'dead' as const } }
await store.append('run-1', e1)
await store.append('run-1', e2)
const got = await store.read('run-1')
expect(got).toHaveLength(2)
expect(got[0]!.key).toBe('k1')
expect(got[1]!.result.kind).toBe('dead')
await store.truncate('run-1')
expect(await store.read('run-1')).toEqual([])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('FileJournalStore read sorts by seq — resume stable when parallel completion order ≠ call order', async () => {
// Concurrent completion order is non-deterministic: append-to-disk = completion order; on resume, key matching uses call order.
// Without seq sorting → different runs have different key orders → nearly all keys mismatch →
// everything re-runs, journal becomes useless. Fix: read() re-orders by ascending seq before returning.
const dir = await mkdtemp(join(tmpdir(), 'wf-journal-sort-'))
try {
const store = createFileJournalStore(dir)
await store.append('r1', {
key: 'late',
seq: 2,
result: { kind: 'ok', output: 'late', usage: { outputTokens: 1 } },
})
await store.append('r1', {
key: 'first',
seq: 0,
result: { kind: 'ok', output: 'first', usage: { outputTokens: 1 } },
})
await store.append('r1', {
key: 'mid',
seq: 1,
result: { kind: 'ok', output: 'mid', usage: { outputTokens: 1 } },
})
const got = await store.read('r1')
expect(got.map(e => e.key)).toEqual(['first', 'mid', 'late'])
expect(got.map(e => e.seq)).toEqual([0, 1, 2])
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('agentCallKey varies with schema', () => {
const k0 = agentCallKey('p', { prompt: 'p' })
const k1 = agentCallKey('p', { prompt: 'p', schema: { type: 'object' } })
const k2 = agentCallKey('p', { prompt: 'p', schema: { type: 'array' } })
expect(k1).not.toBe(k0)
expect(k1).not.toBe(k2)
})
test('agentCallKey varies with model', () => {
expect(agentCallKey('p', { prompt: 'p', model: 'sonnet' })).not.toBe(
agentCallKey('p', { prompt: 'p', model: 'opus' }),
)
})
test('agentCallKey stable across params field order (canonical sort)', () => {
const a = agentCallKey('p', {
prompt: 'p',
model: 'm',
schema: { type: 'object' },
})
const b = agentCallKey('p', {
schema: { type: 'object' },
prompt: 'p',
model: 'm',
})
expect(a).toBe(b)
})
test('FileJournalStore read for non-existent run → []', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-journal-'))
try {
const store = createFileJournalStore(dir)
expect(await store.read('never-existed')).toEqual([])
} finally {
await rm(dir, { recursive: true, force: true })
}
})

View File

@@ -0,0 +1,68 @@
import { expect, test } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
listNamedWorkflows,
resolveNamedWorkflow,
} from '../engine/namedWorkflows.js'
test('resolves named workflow by extension priority', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-named-'))
try {
await writeFile(
join(dir, 'a.ts'),
'export const meta = { name: "a", description: "d" }\nreturn 1',
)
await writeFile(join(dir, 'b.js'), 'return 2')
await writeFile(join(dir, 'c.mjs'), 'return 3')
await writeFile(join(dir, 'ignore.md'), '# not a workflow')
const a = await resolveNamedWorkflow(dir, 'a')
expect(a?.path.endsWith('a.ts')).toBe(true)
expect(a?.content).toContain('meta')
expect(await resolveNamedWorkflow(dir, 'missing')).toBeNull()
const names = await listNamedWorkflows(dir)
expect(names).toEqual(['a', 'b', 'c']) // excludes .md
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('listNamedWorkflows returns empty array for non-existent directory', async () => {
expect(
await listNamedWorkflows(join(tmpdir(), 'wf-nope-' + Date.now())),
).toEqual([])
})
test('resolveNamedWorkflow falls back to .js/.mjs when .ts is missing', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-named-'))
try {
await writeFile(join(dir, 'onlyjs.js'), 'return 1')
await writeFile(join(dir, 'onlymjs.mjs'), 'return 2')
expect(
(await resolveNamedWorkflow(dir, 'onlyjs'))?.path.endsWith('onlyjs.js'),
).toBe(true)
expect(
(await resolveNamedWorkflow(dir, 'onlymjs'))?.path.endsWith(
'onlymjs.mjs',
),
).toBe(true)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('listNamedWorkflows returns sorted names', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-named-'))
try {
await writeFile(join(dir, 'zeta.ts'), 'return 1')
await writeFile(join(dir, 'alpha.js'), 'return 2')
await writeFile(join(dir, 'mid.mjs'), 'return 3')
expect(await listNamedWorkflows(dir)).toEqual(['alpha', 'mid', 'zeta'])
} finally {
await rm(dir, { recursive: true, force: true })
}
})

View File

@@ -0,0 +1,56 @@
import { expect, test } from 'bun:test'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { containsPath, sanitizeWorkflowName } from '../engine/paths.js'
test('containsPath: target equals base → true', () => {
const base = join(tmpdir(), 'a')
expect(containsPath(base, base)).toBe(true)
})
test('containsPath: target inside base → true', () => {
const base = join(tmpdir(), 'a')
const target = join(base, 'b', 'c.ts')
expect(containsPath(base, target)).toBe(true)
})
test('containsPath: target outside base (prefix false positive) → false', () => {
// /tmp/foobar should not be considered a subpath of /tmp/foo
const base = join(tmpdir(), 'foo')
const target = join(tmpdir(), 'foobar', 'x.ts')
expect(containsPath(base, target)).toBe(false)
})
test('containsPath: target using .. out of bounds → false', () => {
const base = join(tmpdir(), 'a', 'b')
const target = join(base, '..', 'outside.ts')
expect(containsPath(base, target)).toBe(false)
})
test('containsPath: relative target resolved against base', () => {
const base = join(tmpdir(), 'a')
expect(containsPath(base, 'sub/file.ts')).toBe(true)
expect(containsPath(base, '../b/file.ts')).toBe(false)
})
test('sanitizeWorkflowName: valid identifier → original value', () => {
expect(sanitizeWorkflowName('release')).toBe('release')
expect(sanitizeWorkflowName('my-workflow')).toBe('my-workflow')
expect(sanitizeWorkflowName('my_workflow_2')).toBe('my_workflow_2')
})
test('sanitizeWorkflowName: contains path separators → null', () => {
expect(sanitizeWorkflowName('foo/bar')).toBeNull()
expect(sanitizeWorkflowName('foo\\bar')).toBeNull()
expect(sanitizeWorkflowName('/abs/path')).toBeNull()
})
test('sanitizeWorkflowName: . / .. / empty → null', () => {
expect(sanitizeWorkflowName('.')).toBeNull()
expect(sanitizeWorkflowName('..')).toBeNull()
expect(sanitizeWorkflowName('')).toBeNull()
})
test('sanitizeWorkflowName: contains null byte → null', () => {
expect(sanitizeWorkflowName('evil\0.ts')).toBeNull()
})

View File

@@ -0,0 +1,41 @@
import { expect, test } from 'bun:test'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { persistInlineScript } from '../tool/persistInline.js'
test('persists to <cwd>/.claude/workflow-runs/<runId>/script.js and returns path', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-pi-'))
try {
const path = await persistInlineScript('return 1', 'r1', dir)
expect(path).toBe(join(dir, '.claude', 'workflow-runs', 'r1', 'script.js'))
expect(await readFile(path, 'utf-8')).toBe('return 1')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('same runId repeated writes overwrite (mkdir idempotent, no error)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-pi-'))
try {
await persistInlineScript('first', 'r2', dir)
const path = await persistInlineScript('second', 'r2', dir)
expect(await readFile(path, 'utf-8')).toBe('second')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('different runId do not interfere (independent subdirectories)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-pi-'))
try {
const p1 = await persistInlineScript('a', 'run-a', dir)
const p2 = await persistInlineScript('b', 'run-b', dir)
expect(p1).not.toBe(p2)
expect(await readFile(p1, 'utf-8')).toBe('a')
expect(await readFile(p2, 'utf-8')).toBe('b')
} finally {
await rm(dir, { recursive: true, force: true })
}
})

View File

@@ -0,0 +1,61 @@
import { expect, test } from 'bun:test'
import { createHostHandle, isHostHandle, unwrapHostHandle } from '../ports.js'
test('createHostHandle wraps any bundle and is opaque externally', () => {
const bundle = { secret: 'ctx', nested: { a: 1 } }
const handle = createHostHandle(bundle)
expect(isHostHandle(handle)).toBe(true)
// bundle is not exposed externally — handle only has a symbol marker
expect(Object.keys(handle)).toHaveLength(0)
})
test('plain object is not a HostHandle', () => {
expect(isHostHandle({} as unknown)).toBe(false)
expect(isHostHandle(null)).toBe(false)
})
test('ports object satisfies the minimal shape', () => {
// compile-time shape validation: the assignment below passing means the ports contract is self-consistent
const noop = (): void => {}
const ports = {
agentRunner: { runAgentToResult: noop },
progressEmitter: { emit: noop },
taskRegistrar: {
register: () => ({
runId: 'run-1',
signal: new AbortController().signal,
}),
complete: noop,
fail: noop,
kill: noop,
pendingAction: () => null,
},
journalStore: {
read: async () => [],
append: async () => {},
truncate: async () => {},
},
permissionGate: { isAborted: () => false },
logger: { debug: noop, event: noop },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: '/tmp',
budgetTotal: null,
toolUseId: 'tu-1',
}),
}
expect(ports.taskRegistrar.register().runId).toBe('run-1')
expect(ports.hostFactory().toolUseId).toBe('tu-1')
})
test('unwrapHostHandle retrieves the original bundle (same reference)', () => {
const bundle = { secret: 'ctx', nested: { a: 1 } }
const handle = createHostHandle(bundle)
expect(unwrapHostHandle(handle)).toBe(bundle)
})
test('createHostHandle(null) is opaque and unwraps to null', () => {
const handle = createHostHandle(null)
expect(isHostHandle(handle)).toBe(true)
expect(unwrapHostHandle(handle)).toBeNull()
})

View File

@@ -0,0 +1,568 @@
import { expect, test } from 'bun:test'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runWorkflow } from '../engine/runWorkflow.js'
import { agentCallKey, createFileJournalStore } from '../engine/journal.js'
import { createHostHandle, type WorkflowPorts } from '../ports.js'
import type { AgentRunParams, AgentRunResult, ProgressEvent } from '../types.js'
function portsWith(
runsDir: string,
results: Map<string, AgentRunResult>,
): WorkflowPorts {
return {
agentRunner: {
runAgentToResult: async (p: AgentRunParams) =>
results.get(p.prompt) ?? { kind: 'dead' },
},
progressEmitter: { emit: () => {} },
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(runsDir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: runsDir,
budgetTotal: null,
}),
}
}
function portsWithEvents(
runsDir: string,
results: Map<string, AgentRunResult>,
): { ports: WorkflowPorts; events: ProgressEvent[] } {
const events: ProgressEvent[] = []
return {
events,
ports: {
agentRunner: {
runAgentToResult: async (p: AgentRunParams) =>
results.get(p.prompt) ?? { kind: 'dead' },
},
progressEmitter: { emit: e => void events.push(e) },
taskRegistrar: {
register: () => ({
runId: 'r',
signal: new AbortController().signal,
}),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(runsDir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: runsDir,
budgetTotal: null,
}),
},
}
}
test('end-to-end: script returns agent result, status completed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
const ports = portsWith(
dir,
new Map([
['compute', { kind: 'ok', output: '42', usage: { outputTokens: 3 } }],
]),
)
const result = await runWorkflow({
script: `export const meta = { name: 't', description: 'd' }\nreturn agent('compute')`,
runId: 'run-1',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
expect(result.status).toBe('completed')
expect(result.returnValue).toBe('42')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('script syntax error → failed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
const ports = portsWith(dir, new Map())
const result = await runWorkflow({
script: `export const meta = { name: 't', description: 'd' }\nreturn ((`,
runId: 'run-2',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
expect(result.status).toBe('failed')
expect(result.error).toBeTruthy()
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('resume: journal hit skips runner call', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
let called = 0
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async () => {
called++
return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } }
},
},
progressEmitter: { emit: () => {} },
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(dir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: dir,
budgetTotal: null,
}),
}
const key = agentCallKey('compute', { prompt: 'compute' })
await ports.journalStore.append('run-3', {
key,
seq: 0,
result: { kind: 'ok', output: 'cached', usage: { outputTokens: 1 } },
})
const result = await runWorkflow({
script: `return agent('compute')`,
runId: 'run-3',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
resume: true,
})
expect(result.status).toBe('completed')
expect(result.returnValue).toBe('cached')
expect(called).toBe(0)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('abort → killed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
const ports = portsWith(
dir,
new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]),
)
const ac = new AbortController()
ac.abort()
const result = await runWorkflow({
script: `return agent('x')`,
runId: 'run-4',
ports,
host: createHostHandle(null),
signal: ac.signal,
cwd: dir,
budgetTotal: null,
})
expect(result.status).toBe('killed')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('workflow() nesting (one level) shares counts', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
await writeFile(
join(dir, '.claude', 'workflows', 'child.ts'),
`return agent('child')\n// child workflow`,
)
const ports = portsWith(
dir,
new Map([
[
'child',
{ kind: 'ok', output: 'child-out', usage: { outputTokens: 1 } },
],
]),
)
const result = await runWorkflow({
script: `return workflow('child')`,
runId: 'run-5',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
expect(result.status).toBe('completed')
expect(result.returnValue).toBe('child-out')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
// ---- boundary and events ----
test('scriptChanged=true → truncate journal and run all live', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
let called = 0
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async () => {
called++
return { kind: 'ok', output: 'live', usage: { outputTokens: 1 } }
},
},
progressEmitter: { emit: () => {} },
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(dir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: dir,
budgetTotal: null,
}),
}
const key = agentCallKey('compute', { prompt: 'compute' })
await ports.journalStore.append('run-chg', {
key,
seq: 0,
result: { kind: 'ok', output: 'cached', usage: { outputTokens: 1 } },
})
const result = await runWorkflow({
script: `return agent('compute')`,
runId: 'run-chg',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
resume: true,
scriptChanged: true,
})
expect(result.status).toBe('completed')
expect(result.returnValue).toBe('live')
expect(called).toBe(1)
// truncate cleared the old cached journal, live agent appends a new entry
const final = await ports.journalStore.read('run-chg')
expect(final).toHaveLength(1)
expect((final[0]!.result as { output: string }).output).toBe('live')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('script runtime throw (non-syntax error) → failed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
const ports = portsWith(dir, new Map())
const result = await runWorkflow({
script: `throw new Error('boom at runtime')`,
runId: 'run-throw',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
expect(result.status).toBe('failed')
expect(result.error).toMatch(/boom/)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('emits run_started (with workflowName) and run_done events', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
const { ports, events } = portsWithEvents(
dir,
new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]),
)
await runWorkflow({
script: `return agent('x')`,
runId: 'run-ev',
workflowName: 'my-wf',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
expect(
events.some(e => e.type === 'run_started' && e.workflowName === 'my-wf'),
).toBe(true)
expect(
events.some(e => e.type === 'run_done' && e.status === 'completed'),
).toBe(true)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
// Emit phase_done for currentPhase before terminal state: hook.phase only emits the previous phase's done on switch,
// the last phase has no subsequent switch → the UI left panel would show running forever. Verify all three paths re-emit.
test('re-emit phase_done for currentPhase before terminal state (completed path)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
const { ports, events } = portsWithEvents(
dir,
new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]),
)
await runWorkflow({
script: `phase('Review')\nreturn agent('x')`,
runId: 'run-phase-done',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
// Both phase_started and phase_done for Review should be present (done from re-emit before terminal)
expect(
events.some(e => e.type === 'phase_started' && e.phase === 'Review'),
).toBe(true)
expect(
events.some(e => e.type === 'phase_done' && e.phase === 'Review'),
).toBe(true)
// Order: phase_done must precede run_done (reducer is order-independent, but the event stream is clearer this way)
const lastPhaseDone = Math.max(
0,
...events.map((e, i) => (e.type === 'phase_done' ? i : -1)),
)
const runDoneIdx = events.findIndex(e => e.type === 'run_done')
expect(runDoneIdx).toBeGreaterThan(0)
expect(lastPhaseDone).toBeLessThan(runDoneIdx)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('re-emit phase_done for currentPhase before terminal state (killed path)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
const { ports, events } = portsWithEvents(
dir,
new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]),
)
const ac = new AbortController()
ac.abort()
await runWorkflow({
script: `phase('Run')\nreturn agent('x')`,
runId: 'run-kill-phase',
ports,
host: createHostHandle(null),
signal: ac.signal,
cwd: dir,
budgetTotal: null,
})
expect(events.some(e => e.type === 'phase_done' && e.phase === 'Run')).toBe(
true,
)
expect(
events.some(e => e.type === 'run_done' && e.status === 'killed'),
).toBe(true)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('no phase() call → terminal does not re-emit phase_done (currentPhase is null)', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
const { ports, events } = portsWithEvents(
dir,
new Map([['x', { kind: 'ok', output: '1', usage: { outputTokens: 1 } }]]),
)
await runWorkflow({
script: `return agent('x')`,
runId: 'run-no-phase',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
// No phase() → currentPhase is null → terminal does not re-emit phase_done
expect(events.some(e => e.type === 'phase_done')).toBe(false)
expect(events.some(e => e.type === 'phase_started')).toBe(false)
expect(
events.some(e => e.type === 'run_done' && e.status === 'completed'),
).toBe(true)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('derives workflowName from meta.name when not passed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
const { ports, events } = portsWithEvents(dir, new Map())
await runWorkflow({
script: `export const meta = { name: 'from-meta', description: 'd' }\nreturn 1`,
runId: 'run-meta',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
expect(
events.some(
e => e.type === 'run_started' && e.workflowName === 'from-meta',
),
).toBe(true)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('budgetTotal exhausted → failed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
const ports = portsWith(
dir,
new Map([
['a', { kind: 'ok', output: '1', usage: { outputTokens: 5 } }],
['b', { kind: 'ok', output: '2', usage: { outputTokens: 5 } }],
]),
)
const result = await runWorkflow({
script: `await agent('a')\nreturn agent('b')`,
runId: 'run-budget',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: 5,
})
expect(result.status).toBe('failed')
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('maxConcurrency passthrough: parallel agents bounded by run-level concurrency slots', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
let active = 0
let peak = 0
const ports: WorkflowPorts = {
agentRunner: {
runAgentToResult: async () => {
active++
peak = Math.max(peak, active)
await new Promise(r => {
setTimeout(r, 8)
})
active--
return { kind: 'ok', output: 'x', usage: { outputTokens: 1 } }
},
},
progressEmitter: { emit: () => {} },
taskRegistrar: {
register: () => ({ runId: 'r', signal: new AbortController().signal }),
complete: () => {},
fail: () => {},
kill: () => {},
pendingAction: () => null,
},
journalStore: createFileJournalStore(dir),
permissionGate: { isAborted: () => false },
logger: { debug: () => {}, event: () => {} },
hostFactory: () => ({
handle: createHostHandle(null),
cwd: dir,
budgetTotal: null,
}),
}
const result = await runWorkflow({
script: `return parallel(Array.from({length: 8}, () => () => agent('p')))`,
runId: 'run-mc',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
maxConcurrency: 2,
})
expect(result.status).toBe('completed')
expect(peak).toBeLessThanOrEqual(2)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('workflow() references a syntactically broken sub-script → failed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
await mkdir(join(dir, '.claude', 'workflows'), { recursive: true })
await writeFile(join(dir, '.claude', 'workflows', 'broken.ts'), `return ((`)
const ports = portsWith(dir, new Map())
const result = await runWorkflow({
script: `return workflow('broken')`,
runId: 'run-sub-err',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
expect(result.status).toBe('failed')
expect(result.error).toMatch(/Sub-workflow|script error/i)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
test('workflow() references a non-existent name → failed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'wf-run-'))
try {
const ports = portsWith(dir, new Map())
const result = await runWorkflow({
script: `return workflow('ghost')`,
runId: 'run-sub-missing',
ports,
host: createHostHandle(null),
signal: new AbortController().signal,
cwd: dir,
budgetTotal: null,
})
expect(result.status).toBe('failed')
expect(result.error).toMatch(/Sub-workflow|not found/i)
} finally {
await rm(dir, { recursive: true, force: true })
}
})

View File

@@ -0,0 +1,62 @@
import { expect, test } from 'bun:test'
import { workflowInputSchema } from '../tool/schema.js'
test('empty object passes (all fields optional)', () => {
expect(workflowInputSchema.safeParse({}).success).toBe(true)
})
test('all known fields can be filled', () => {
const r = workflowInputSchema.safeParse({
script: 'return 1',
name: 'release',
scriptPath: '/abs/x.ts',
args: { n: 1 },
resumeFromRunId: 'run-1',
description: 'do thing',
title: 'T',
maxConcurrency: 3,
})
expect(r.success).toBe(true)
})
test('args accepts any JSON value (object/array/string/number/boolean/null)', () => {
for (const args of [{ a: 1 }, [1, 2], 's', 42, true, null]) {
expect(workflowInputSchema.safeParse({ args }).success).toBe(true)
}
})
test('type errors rejected (script/name/scriptPath not strings)', () => {
expect(workflowInputSchema.safeParse({ script: 123 }).success).toBe(false)
expect(workflowInputSchema.safeParse({ name: 42 }).success).toBe(false)
expect(workflowInputSchema.safeParse({ scriptPath: {} }).success).toBe(false)
})
test('resumeFromRunId/description/title must be strings', () => {
expect(workflowInputSchema.safeParse({ resumeFromRunId: 1 }).success).toBe(
false,
)
expect(workflowInputSchema.safeParse({ description: 1 }).success).toBe(false)
expect(workflowInputSchema.safeParse({ title: 1 }).success).toBe(false)
})
test('unknown fields are stripped (zod default non-strict, safeParse succeeds)', () => {
const r = workflowInputSchema.safeParse({ script: 'x', extra: 1 })
expect(r.success).toBe(true)
})
test('maxConcurrency: integers 1-16 valid; 0/17/decimal/non-number rejected', () => {
for (const n of [1, 3, 5, 16]) {
expect(workflowInputSchema.safeParse({ maxConcurrency: n }).success).toBe(
true,
)
}
for (const bad of [0, -1, 17, 100, 1.5, '3', NaN]) {
expect(workflowInputSchema.safeParse({ maxConcurrency: bad }).success).toBe(
false,
)
}
})
test('maxConcurrency optional (safeParse succeeds when omitted)', () => {
expect(workflowInputSchema.safeParse({ script: 'x' }).success).toBe(true)
})

View File

@@ -0,0 +1,168 @@
import { expect, test } from 'bun:test'
import {
ScriptError,
extractMeta,
parseScript,
type WorkflowHooks,
} from '../engine/script.js'
const stubHooks: WorkflowHooks = {
agent: async () => 'agent-result',
parallel: async thunks =>
Promise.all(
thunks.map(async t => {
try {
return await t()
} catch {
return null
}
}),
),
pipeline: async () => [],
phase: () => {},
log: () => {},
workflow: async () => null,
}
test('extractMeta extracts plain literals and strips the statement', () => {
const src = `export const meta = { name: 'x', description: 'y' }\nreturn 1`
const { meta, body } = extractMeta(src)
expect(meta?.name).toBe('x')
expect(meta?.description).toBe('y')
expect(body).not.toContain('export const meta')
expect(body).toContain('return 1')
})
test('extractMeta returns null when no meta and body unchanged', () => {
const src = `return 42`
const { meta, body } = extractMeta(src)
expect(meta).toBeNull()
expect(body).toBe(src)
})
test('extractMeta rejects non-plain literals (variable references)', () => {
const src = `const x = 1\nexport const meta = { name: 'x', description: y }\nreturn 1`
expect(() => extractMeta(src)).toThrow(ScriptError)
})
test('parseScript executes top-level return of body', async () => {
const { execute } = parseScript(`return args.n + 1`)
const out = await execute(stubHooks, { n: 41 }, { total: null })
expect(out).toBe(42)
})
test('Date.now() in script throws non-determinism error', async () => {
const { execute } = parseScript(`return Date.now()`)
await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow(
/Date\.now/,
)
})
test('Math.random() in script throws non-determinism error', async () => {
const { execute } = parseScript(`return Math.random()`)
await expect(execute(stubHooks, {}, { total: null })).rejects.toThrow(
/Math\.random/,
)
})
test('no-arg new Date() throws, but new Date(arg) is allowed', async () => {
const bad = parseScript(`return new Date()`)
await expect(bad.execute(stubHooks, {}, { total: null })).rejects.toThrow(
/new Date/,
)
const good = parseScript(
`return new Date('2020-06-12T00:00:00Z').getUTCFullYear()`,
)
await expect(good.execute(stubHooks, {}, { total: null })).resolves.toBe(2020)
})
// ---- meta validation error branches and nesting ----
test('extractMeta meta is array → ScriptError', () => {
expect(() => extractMeta('export const meta = [1, 2]\nreturn 1')).toThrow(
ScriptError,
)
})
test('extractMeta meta missing name → ScriptError', () => {
expect(() =>
extractMeta('export const meta = { description: "d" }\nreturn 1'),
).toThrow(ScriptError)
})
test('extractMeta meta missing description → ScriptError', () => {
expect(() =>
extractMeta('export const meta = { name: "n" }\nreturn 1'),
).toThrow(ScriptError)
})
test('extractMeta meta unclosed braces → ScriptError', () => {
expect(() =>
extractMeta('export const meta = { name: "n", description: "d"\nreturn 1'),
).toThrow(ScriptError)
})
test('extractMeta supports nested objects (phases array)', () => {
const src = `export const meta = { name: 'x', description: 'y', phases: [{ title: 'A' }, { title: 'B' }] }\nreturn 1`
const { meta } = extractMeta(src)
expect(meta?.name).toBe('x')
expect(meta?.phases).toHaveLength(2)
expect(meta?.phases?.[0]?.title).toBe('A')
expect(meta?.phases?.[1]?.title).toBe('B')
})
test('parseScript syntax error → ScriptError', () => {
expect(() => parseScript('return ((')).toThrow(ScriptError)
})
test('parseScript detects import → guided ScriptError (not a generic syntax error)', () => {
expect(() =>
parseScript(
`import { foo } from 'bar'\nexport const meta = { name: 'n', description: 'd' }\nreturn foo()`,
),
).toThrow(ScriptError)
expect(() =>
parseScript(
`import { foo } from 'bar'\nexport const meta = { name: 'n', description: 'd' }\nreturn foo()`,
),
).toThrow(/import is not supported/)
})
test('parseScript detects extra export beyond meta → guided ScriptError', () => {
expect(() =>
parseScript(
`export const meta = { name: 'n', description: 'd' }\nexport const X = 1\nreturn X`,
),
).toThrow(ScriptError)
expect(() =>
parseScript(
`export const meta = { name: 'n', description: 'd' }\nexport const X = 1\nreturn X`,
),
).toThrow(/allow only one export const meta/)
})
test('parseScript does not misfire on normal plain JS scripts (no import / no extra export)', () => {
const { execute } = parseScript(
`export const meta = { name: 'n', description: 'd' }\nconst r = await agent('hi')\nreturn r`,
)
expect(typeof execute).toBe('function')
})
test('parseScript detects dynamic import(...) → guided ScriptError (sandbox anti-escape)', () => {
expect(() =>
parseScript(
`const cp = await import('node:child_process')\nreturn cp.execSync('id').toString()`,
),
).toThrow(ScriptError)
expect(() =>
parseScript(`const cp = await import('node:child_process')\nreturn cp`),
).toThrow(/import/)
})
test('parseScript does not misfire when a line contains the import string literal (e.g. prompt contains "import")', () => {
// import inside a string should not be caught by the static regex — prompt may contain the word "import"
const { execute } = parseScript(
`export const meta = { name: 'n', description: 'd' }\nconst r = await agent('please import this module')\nreturn r`,
)
expect(typeof execute).toBe('function')
})

View File

@@ -0,0 +1,40 @@
import { expect, test } from 'bun:test'
import { validateAgainstSchema } from '../engine/structuredOutput.js'
const schema = {
type: 'object',
required: ['name', 'count'],
properties: {
name: { type: 'string' },
count: { type: 'number' },
},
additionalProperties: false,
}
test('valid object passes', () => {
const { valid, errors } = validateAgainstSchema(
{ name: 'a', count: 1 },
schema,
)
expect(valid).toBe(true)
expect(errors).toEqual([])
})
test('missing field fails', () => {
const { valid, errors } = validateAgainstSchema({ name: 'a' }, schema)
expect(valid).toBe(false)
expect(errors.length).toBeGreaterThan(0)
})
test('type error fails', () => {
const { valid } = validateAgainstSchema({ name: 'a', count: 'x' }, schema)
expect(valid).toBe(false)
})
test('same schema reuses cache', () => {
validateAgainstSchema({ name: 'a', count: 1 }, schema)
// second use of the same schema object should hit cache (not throwing is enough)
expect(validateAgainstSchema({ name: 'b', count: 2 }, schema).valid).toBe(
true,
)
})

View File

@@ -0,0 +1,52 @@
import { expect, test } from 'bun:test'
// Directly construct type shapes to verify JSON round-trip (core requirement for resume persistence).
test('AgentRunResult ok branch can JSON round-trip', () => {
const result = {
kind: 'ok' as const,
output: { confirmed: true },
usage: { outputTokens: 42 },
}
const round = JSON.parse(JSON.stringify(result))
expect(round).toEqual(result)
expect(round.kind).toBe('ok')
})
test('AgentRunResult skipped/dead branch can JSON round-trip', () => {
for (const kind of ['skipped', 'dead'] as const) {
const round = JSON.parse(JSON.stringify({ kind }))
expect(round.kind).toBe(kind)
}
})
// dead carries optional reason/detail: journal persistence preserves cause of death for post-hoc audit / panel display.
test('AgentRunResult dead with reason/detail can JSON round-trip', () => {
const dead = {
kind: 'dead' as const,
reason: 'no-structured-output' as const,
detail: 'finalize content has no StructuredOutput tool_use or JSON text',
}
const round = JSON.parse(JSON.stringify(dead))
expect(round).toEqual(dead)
expect(round.kind).toBe('dead')
expect(round.reason).toBe('no-structured-output')
})
// Backward compatible with old journals: reason/detail both optional, missing is still valid dead.
test('AgentRunResult dead without reason is still valid (backward compatible with old journal)', () => {
const legacy = { kind: 'dead' as const }
const round = JSON.parse(JSON.stringify(legacy))
expect(round.kind).toBe('dead')
expect(round.reason).toBeUndefined()
expect(round.detail).toBeUndefined()
})
test('JournalEntry shape is stable', () => {
const entry = {
key: 'abc123',
result: { kind: 'ok', output: 'text', usage: { outputTokens: 1 } },
}
const round = JSON.parse(JSON.stringify(entry))
expect(round.key).toBe('abc123')
expect(round.result.kind).toBe('ok')
})

View File

@@ -0,0 +1,165 @@
// Agent backend adapter abstraction. The engine takes an adapter from the registry via resolve then calls run; it does not care about the concrete implementation
// (Anthropic SDK / core runAgent / OpenAI / local model / mock are all adapter implementations).
import type {
AgentProgressUpdate,
AgentRunParams,
AgentRunResult,
} from './types.js'
import type { HostHandle } from './ports.js'
/** Adapter capability declaration. The engine/script degrades based on this (e.g. if the backend does not support schema, switch to text + parse). */
export type AgentAdapterCapabilities = {
/** Supports schema structured output (agent(schema) returns an object directly). */
structuredOutput: boolean
/** Supports tool calling (only the core agent backend has this). */
tools?: boolean
/** Supports streaming (the v1 engine does not consume it; reserved). */
stream?: boolean
}
/** Context for adapter.run. */
export type AgentAdapterContext = {
/** Opaque host handle passed through (used by the core adapter; ignored by standalone backends). */
host: HostHandle
/** Cancellation signal (same as the workflow signal). */
signal: AbortSignal
/** Current workflow runId (for logging/tracing). */
runId: string
/**
* Engine-layer agent sequence number (incremented by hooks.agentIdSeq; same source as panel RunProgress.agents[].id).
* Note: this is a different concept from the core AgentId (a string, used for sub-agent tracking) created internally by the backend;
* do not mix them. This field is the key for registerAgentAbort/unregisterAgentAbort, so that service
* .kill(runId, agentId) can precisely route to the AbortController created by the backend.
*/
agentId: number
/**
* In-progress reporting (called by the backend loop as it accumulates tokens/tools). Optional: standalone backends may not implement it;
* the engine emits the agent_progress event based on this (closure carries agentId/runId for correlation), and the panel refreshes in real time.
*/
onProgress?: (update: AgentProgressUpdate) => void
/**
* Register an agent-level AbortController (optional). The backend calls this after creating the controller to inject it into a Map,
* so that service.kill(runId, agentId) can precisely abort a single agent without affecting others.
* Injected by hooks.agent before backend.run is called.
*/
registerAgentAbort?: (agentId: number, ac: AbortController) => void
/**
* Unregister an agent-level AbortController (called when the agent completes or fails; idempotent).
* Paired with registerAgentAbort.
*/
unregisterAgentAbort?: (agentId: number) => void
}
/**
* Agent backend adapter. The engine only depends on this interface; concrete backends implement it and register into the registry.
* initialize/dispose are optional lifecycle hooks (connection pool / resource management), triggered by the caller via
* registry.initializeAll/disposeAll.
*/
export interface AgentAdapter {
/** Unique identifier (registry routing / logging). */
readonly id: string
/** Capability declaration. */
readonly capabilities: AgentAdapterCapabilities
/** Execute one agent call. */
run(params: AgentRunParams, ctx: AgentAdapterContext): Promise<AgentRunResult>
/** Initialize (triggered by registry.initializeAll). */
initialize?(): Promise<void>
/** Dispose (triggered by registry.disposeAll). */
dispose?(): Promise<void>
}
/** Routing rule: decides which params go to which adapter. Matched in insertion order; first hit wins. */
export type AdapterRouteRule =
| { kind: 'agentType'; agentType: string; adapter: string }
| { kind: 'model'; pattern: string; adapter: string }
| {
kind: 'custom'
match: (params: AgentRunParams) => boolean
adapter: string
}
/** Thrown when the registry cannot find a matching adapter. */
export class AdapterNotFoundError extends Error {
constructor(message: string) {
super(message)
this.name = 'AdapterNotFoundError'
}
}
/**
* Multi-backend registry. register registers an adapter, route/default configure routing, and resolve picks an adapter by
* matching rules in order. The adapter lifecycle (initialize/dispose) is triggered uniformly via
* initializeAll/disposeAll (called by the caller before/after the run).
*/
export class AgentAdapterRegistry {
private readonly adapters = new Map<string, AgentAdapter>()
private readonly rules: AdapterRouteRule[] = []
private defaultId: string | null = null
/** Register an adapter (duplicate id overwrites). Chainable. */
register(adapter: AgentAdapter): this {
this.adapters.set(adapter.id, adapter)
return this
}
/** Set the default adapter (used when no rule matches). Chainable. */
default(adapterId: string): this {
this.defaultId = adapterId
return this
}
/** Add a routing rule (matched in insertion order). Chainable. */
route(rule: AdapterRouteRule): this {
this.rules.push(rule)
return this
}
has(id: string): boolean {
return this.adapters.has(id)
}
get(id: string): AgentAdapter | undefined {
return this.adapters.get(id)
}
/** Match by rules; return the first hit; if no hit, go to default; if neither, throw AdapterNotFoundError. */
resolve(params: AgentRunParams): AgentAdapter {
for (const rule of this.rules) {
if (matchRule(rule, params)) {
const hit = this.adapters.get(rule.adapter)
if (hit) return hit
}
}
if (this.defaultId) {
const fallback = this.adapters.get(this.defaultId)
if (fallback) return fallback
}
throw new AdapterNotFoundError(
`No adapter matched (rules=${this.rules.length}, default=${this.defaultId ?? 'none'})`,
)
}
/** Trigger initialize on all adapters (skips unimplemented ones). */
async initializeAll(): Promise<void> {
for (const a of this.adapters.values()) {
await a.initialize?.()
}
}
/** Trigger dispose on all adapters (skips unimplemented ones). */
async disposeAll(): Promise<void> {
for (const a of this.adapters.values()) {
await a.dispose?.()
}
}
}
function matchRule(rule: AdapterRouteRule, params: AgentRunParams): boolean {
if (rule.kind === 'agentType') return params.agentType === rule.agentType
if (rule.kind === 'model') {
return (
typeof params.model === 'string' && params.model.startsWith(rule.pattern)
)
}
return rule.match(params) // custom rule
}

View File

@@ -0,0 +1,32 @@
// Engine-level constants. No runtime dependencies.
/**
* Workflow tool name. PascalCase matches the system's other tools (Agent/Bash/CronCreate…),
* otherwise the case-sensitive toolMatchesName would fail on the model's natural select:Workflow.
*/
export const WORKFLOW_TOOL_NAME = 'Workflow'
/** Directory for user-named workflow files (relative to project root). */
export const WORKFLOW_DIR_NAME = '.claude/workflows'
/** Persistence directory for workflow runs (journal + run records). */
export const WORKFLOW_RUNS_DIR = '.claude/workflow-runs'
/** Supported script extensions for named workflows (in priority order). */
export const WORKFLOW_SCRIPT_EXTENSIONS = ['.ts', '.js', '.mjs'] as const
/**
* Concurrency: default semaphore permits per workflow run.
* History: previously used min(CAP, cpuCores - 2); changed to a fixed default of 3 — to avoid fanning out a dozen agents at once on multi-core machines.
* A single run can override this via the Workflow tool's maxConcurrency input (still clamped by CAP).
*/
export const DEFAULT_MAX_CONCURRENCY = 3
/** Absolute cap on user-supplied maxConcurrency (anti-abuse). */
export const MAX_CONCURRENCY_CAP = 16
/** Total cap on agent() calls within a single workflow lifecycle. */
export const MAX_TOTAL_AGENTS = 1000
/** Items cap per single parallel()/pipeline() call. */
export const MAX_ITEMS_PER_CALL = 4096

View File

@@ -0,0 +1,36 @@
export class BudgetExhaustedError extends Error {
constructor() {
super('workflow token budget exhausted (budget.total reached the cap)')
this.name = 'BudgetExhaustedError'
}
}
/**
* Token budget accumulator. The script reads via `budget.total / budget.spent() / budget.remaining()`;
* assertCanSpend() enforces a hard cap before each agent() call.
*/
export class Budget {
private spentTokens = 0
constructor(readonly total: number | null) {}
spent(): number {
return this.spentTokens
}
remaining(): number {
return this.total == null
? Infinity
: Math.max(0, this.total - this.spentTokens)
}
addOutputTokens(n: number): void {
if (n > 0) this.spentTokens += n
}
assertCanSpend(): void {
if (this.total != null && this.spentTokens >= this.total) {
throw new BudgetExhaustedError()
}
}
}

View File

@@ -0,0 +1,73 @@
import { DEFAULT_MAX_CONCURRENCY, MAX_CONCURRENCY_CAP } from '../constants.js'
/**
* Async semaphore. acquire() returns a release function; on release the permit is transferred
* directly to the next waiter (available stays unchanged), and only returned when there is no waiter. The total number of permits is conserved.
*
* acquire(signal?) supports cancellation: when the signal is already aborted or aborts while waiting, it rejects immediately,
* the waiter is removed from the queue, and no permit is consumed (to avoid a canceled agent holding a concurrency slot).
*/
export class Semaphore {
private available: number
private readonly waiters: Array<{
wake: () => void
cleanup: () => void
}> = []
constructor(permits: number) {
this.available = Math.max(1, Math.floor(permits))
}
async acquire(signal?: AbortSignal): Promise<() => void> {
if (signal?.aborted) {
throw new Error('Semaphore.acquire aborted (signal already aborted)')
}
if (this.available > 0) {
this.available -= 1
return () => this.release()
}
return new Promise<() => void>((resolve, reject) => {
const onAbort = () => {
const idx = this.waiters.indexOf(entry)
if (idx >= 0) this.waiters.splice(idx, 1)
reject(new Error('Semaphore.acquire aborted'))
}
const wake = () => {
signal?.removeEventListener('abort', onAbort)
resolve(() => this.release())
}
const entry = {
wake,
cleanup: () => signal?.removeEventListener('abort', onAbort),
}
signal?.addEventListener('abort', onAbort, { once: true })
this.waiters.push(entry)
})
}
private release(): void {
const next = this.waiters.shift()
if (next) {
next.wake() // transfer the permit directly
} else {
this.available += 1
}
}
}
/** Default concurrency for the current process (backward-compatible entry; for a specific run, use clampMaxConcurrency to handle user input). */
export function maxConcurrency(): number {
return DEFAULT_MAX_CONCURRENCY
}
/**
* Normalize the "user-supplied maxConcurrency" to legal permits.
* - undefined / NaN → DEFAULT_MAX_CONCURRENCY
* - <1 → 1 (at least one concurrency slot, otherwise the workflow cannot progress)
* - >MAX_CONCURRENCY_CAP → MAX_CONCURRENCY_CAP
* - otherwise the truncated original value
*/
export function clampMaxConcurrency(n: number | undefined): number {
if (n === undefined || Number.isNaN(n)) return DEFAULT_MAX_CONCURRENCY
return Math.max(1, Math.min(Math.trunc(n), MAX_CONCURRENCY_CAP))
}

View File

@@ -0,0 +1,73 @@
import type { HostHandle, WorkflowPorts } from '../ports.js'
import type { JournalEntry } from '../types.js'
import { Budget } from './budget.js'
import { Semaphore, clampMaxConcurrency } from './concurrency.js'
/**
* Resources that can be shared by sub-workflows. When nesting, semaphore/budget/agentCountBox are shared by reference,
* and depth is temporarily +1 while executing a sub-workflow.
*/
export type SharedResources = {
semaphore: Semaphore
budget: Budget
agentCountBox: { value: number }
/** Increasing sequence number for agent() calls; stamps agent_started/agent_done for precise progress correlation. Shared across sub-workflows. */
agentIdSeq: { value: number }
depth: number
}
/** Execution context for a single workflow run. */
export type EngineContext = {
ports: WorkflowPorts
host: HostHandle
signal: AbortSignal
runId: string
workflowName: string
cwd: string
resources: SharedResources
journal: JournalEntry[]
journalIndex: number
journalInvalidated: boolean
currentPhase: string | null
}
export function createSharedResources(
budgetTotal: number | null,
maxConcurrency?: number,
): SharedResources {
return {
semaphore: new Semaphore(clampMaxConcurrency(maxConcurrency)),
budget: new Budget(budgetTotal),
agentCountBox: { value: 0 },
agentIdSeq: { value: 0 },
depth: 0,
}
}
export function createEngineContext(opts: {
ports: WorkflowPorts
host: HostHandle
signal: AbortSignal
runId: string
workflowName: string
cwd: string
budgetTotal: number | null
/** Concurrency slots for a single run; undefined → DEFAULT_MAX_CONCURRENCY. Clamped by clampMaxConcurrency. */
maxConcurrency?: number
journal?: JournalEntry[]
}): EngineContext {
const resources = createSharedResources(opts.budgetTotal, opts.maxConcurrency)
return {
ports: opts.ports,
host: opts.host,
signal: opts.signal,
runId: opts.runId,
workflowName: opts.workflowName,
cwd: opts.cwd,
resources,
journal: opts.journal ? [...opts.journal] : [],
journalIndex: 0,
journalInvalidated: false,
currentPhase: null,
}
}

View File

@@ -0,0 +1,15 @@
/** Engine-level expected errors (script errors, caps, nesting). */
export class WorkflowError extends Error {
constructor(message: string) {
super(message)
this.name = 'WorkflowError'
}
}
/** workflow was aborted (killed). */
export class WorkflowAbortedError extends Error {
constructor() {
super('workflow has been aborted')
this.name = 'WorkflowAbortedError'
}
}

View File

@@ -0,0 +1,300 @@
import { MAX_ITEMS_PER_CALL, MAX_TOTAL_AGENTS } from '../constants.js'
import type {
AgentProgressUpdate,
AgentRunParams,
AgentRunResult,
JournalEntry,
ProgressEvent,
} from '../types.js'
import type { EngineContext } from './context.js'
import { WorkflowAbortedError, WorkflowError } from './errors.js'
import { agentCallKey } from './journal.js'
import type { WorkflowHooks } from './script.js'
/** Sub-workflow executor for the workflow() hook (injected by runWorkflow to avoid circular dependencies). */
export type SubWorkflowRunner = (opts: {
name?: string
scriptPath?: string
script?: string
args?: unknown
}) => Promise<unknown>
type HookProgressInit =
| { type: 'phase_started'; phase: string }
| { type: 'phase_done'; phase: string }
| { type: 'agent_started'; agentId: number; label?: string; phase?: string }
| {
type: 'agent_done'
agentId: number
label?: string
phase?: string
result: AgentRunResult
}
| {
type: 'agent_progress'
agentId: number
label?: string
phase?: string
tokenCount: number
toolCount: number
}
| { type: 'log'; message: string }
export function makeHooks(
ctx: EngineContext,
runSubWorkflow: SubWorkflowRunner,
): WorkflowHooks {
// All progress events auto-inject runId so the adapter can route them to the corresponding task (multiple concurrent workflows)
const emit = (init: HookProgressInit): void => {
ctx.ports.progressEmitter.emit({
runId: ctx.runId,
...init,
} as ProgressEvent)
}
const agent: WorkflowHooks['agent'] = async (prompt, opts = {}) => {
const r = ctx.resources
if (r.agentCountBox.value >= MAX_TOTAL_AGENTS) {
throw new WorkflowError(
`workflow exceeds total agent cap (${MAX_TOTAL_AGENTS})`,
)
}
// Assign a unique id to each agent() call (including journal hits); stamp started/done so the reducer can associate them precisely
const agentId = r.agentIdSeq.value++
const params: AgentRunParams = { prompt, ...opts }
const key = agentCallKey(prompt, params)
const label = opts.label as string | undefined
const phase =
(opts.phase as string | undefined) ?? ctx.currentPhase ?? undefined
// Journal hit -> return cached result directly
if (!ctx.journalInvalidated && ctx.journalIndex < ctx.journal.length) {
const entry = ctx.journal[ctx.journalIndex]!
if (entry.key === key) {
ctx.journalIndex++
emit({
type: 'agent_done',
agentId,
label,
phase,
result: entry.result,
})
return resultToOutput(entry.result)
}
// Divergence: discard subsequent journal entries; everything from here on runs live
ctx.journalInvalidated = true
ctx.journal = ctx.journal.slice(0, ctx.journalIndex)
await ctx.ports.journalStore.truncate(ctx.runId)
}
let release: () => void
try {
release = await ctx.resources.semaphore.acquire(ctx.signal)
} catch {
// Queued wait during abort: the semaphore already removed the waiter and did not consume a permit
throw new WorkflowAbortedError()
}
try {
if (ctx.signal.aborted) throw new WorkflowAbortedError()
// Budget check inside the semaphore critical section: a queued waiter sees the latest spent when woken,
// otherwise N waiters enqueued while spent=0 all pass the check and overspend on wake-up without re-check.
// Journal-hit path does not charge budget and needs no check.
r.budget.assertCanSpend()
const pending = ctx.ports.taskRegistrar.pendingAction(ctx.runId)
if (pending?.kind === 'skip') {
const result: AgentRunResult = { kind: 'skipped' }
emit({ type: 'agent_done', agentId, label, phase, result })
return null
}
ctx.resources.agentCountBox.value++
emit({ type: 'agent_started', agentId, label, phase })
const registry = ctx.ports.agentAdapterRegistry
// onProgress closure: the backend loop accumulates token/tool counts -> emits an agent_progress event (carrying agentId for association)
const onProgress = (update: AgentProgressUpdate): void => {
emit({ type: 'agent_progress', agentId, label, phase, ...update })
}
// Inject agent-level AbortController register/unregister: the backend creates the controller then calls
// registerAgentAbort to inject ports-layer bindings; service.kill(runId, agentId) uses this to
// precisely abort a single agent. When the registry is absent (agentRunner fallback path), there is no backend middle layer,
// and agentAbortControllers at the ports layer is always empty — single-agent kill degrades to a no-op on this path.
const adapterCtx = registry
? {
host: ctx.host,
signal: ctx.signal,
runId: ctx.runId,
agentId,
onProgress,
...(ctx.ports.taskRegistrar.registerAgentAbort
? {
registerAgentAbort: (
id: number,
ac: AbortController,
): void => {
ctx.ports.taskRegistrar.registerAgentAbort?.(
ctx.runId,
id,
ac,
)
},
}
: {}),
...(ctx.ports.taskRegistrar.unregisterAgentAbort
? {
unregisterAgentAbort: (id: number): void => {
ctx.ports.taskRegistrar.unregisterAgentAbort?.(
ctx.runId,
id,
)
},
}
: {}),
}
: null
// resolve is outside the try: configuration errors (e.g. AdapterNotFoundError) propagate directly without retry —
// this is a workflow configuration problem, not a transient backend failure; retrying is meaningless and would mask the bug.
const adapter = registry ? registry.resolve(params) : null
const invokeBackend = (): Promise<AgentRunResult> =>
adapter
? adapter.run(params, adapterCtx!)
: ctx.ports.agentRunner.runAgentToResult(params, ctx.host)
// Auto-retry once on failure: dead (terminal API error after retries) or a non-abort throw
// both get one retry chance; WorkflowAbortedError (kill) is not retried — it is the user's intent.
// If retry still fails: dead stays dead; a throw degrades to dead (one agent must not take down the workflow).
// budget is not double-charged: dead does not call addOutputTokens; retry-ok charges once (at the final ok).
// dead.reason is passed through to the log: no-structured-output (the agent's final text block did not produce plain-object JSON)
// is a high-frequency cause of death; logging detail lets you immediately see what the agent last said.
// detail is wrapped with String() defensively: old journals or third-party adapters may write non-strings (corrupted data),
// and calling .slice directly would throw a TypeError that pierces the logging path.
let result: AgentRunResult
try {
result = await invokeBackend()
if (result.kind === 'dead') {
const detailStr =
typeof result.detail === 'string' ? result.detail : ''
ctx.ports.logger.warn?.(
`agent "${label ?? `#${agentId}`}" returned dead` +
(result.reason ? ` (${result.reason})` : '') +
(detailStr ? `: ${detailStr.slice(0, 150)}` : '') +
'; retrying once',
)
result = await invokeBackend()
}
} catch (e) {
if (e instanceof WorkflowAbortedError) throw e
const eMsg = e instanceof Error ? e.message : String(e)
ctx.ports.logger.warn?.(
`agent "${label ?? `#${agentId}`}" threw (${eMsg}); retrying once`,
)
try {
result = await invokeBackend()
} catch (e2) {
if (e2 instanceof WorkflowAbortedError) throw e2
// Retry still threw: degrade to dead (keep the workflow going; hooks.agent returns null)
result = {
kind: 'dead',
reason: 'runagent-threw',
detail: e2 instanceof Error ? e2.message : String(e2),
}
}
}
if (result.kind === 'ok') {
ctx.resources.budget.addOutputTokens(result.usage.outputTokens)
}
emit({ type: 'agent_done', agentId, label, phase, result })
const entry: JournalEntry = { key, seq: agentId, result }
// Key point: push order = completion order (not call order); read() already re-sorts by seq,
// so during resume the call order aligns with the journal order and the key index stays stable.
ctx.journal.push(entry)
ctx.journalIndex++
await ctx.ports.journalStore.append(ctx.runId, entry)
return resultToOutput(result)
} finally {
release()
}
}
const parallel: WorkflowHooks['parallel'] = async thunks => {
if (thunks.length > MAX_ITEMS_PER_CALL) {
throw new WorkflowError(
`parallel exceeds the per-call items cap (${MAX_ITEMS_PER_CALL})`,
)
}
return Promise.all(
thunks.map(async (t, i) => {
try {
return await t()
} catch (e) {
// The "null on error" contract is unchanged, but it should log — otherwise the workflow author cannot locate why an agent failed
ctx.ports.logger.warn?.(
`parallel thunk #${i} failed: ${(e as Error).message}`,
)
return null
}
}),
)
}
const pipeline: WorkflowHooks['pipeline'] = async <T, R>(
items: readonly T[],
...stages: Array<
(prev: unknown, item: T, index: number) => Promise<unknown>
>
): Promise<Array<R | null>> => {
if (items.length > MAX_ITEMS_PER_CALL) {
throw new WorkflowError(
`pipeline exceeds the per-call items cap (${MAX_ITEMS_PER_CALL})`,
)
}
return Promise.all(
items.map(async (item, index): Promise<R | null> => {
try {
let prev: unknown = item
for (const stage of stages) {
prev = await stage(prev, item, index)
}
return prev as R
} catch (e) {
ctx.ports.logger.warn?.(
`pipeline item #${index} failed: ${(e as Error).message}`,
)
return null
}
}),
)
}
const phase: WorkflowHooks['phase'] = title => {
if (ctx.currentPhase) {
emit({ type: 'phase_done', phase: ctx.currentPhase })
}
ctx.currentPhase = title
emit({ type: 'phase_started', phase: title })
}
const log: WorkflowHooks['log'] = message => {
emit({ type: 'log', message })
}
const workflow: WorkflowHooks['workflow'] = async (nameOrRef, args) => {
if (ctx.resources.depth >= 1) {
throw new WorkflowError('workflow() nesting allows only one level')
}
const sub: Parameters<SubWorkflowRunner>[0] =
typeof nameOrRef === 'string'
? { name: nameOrRef }
: { scriptPath: nameOrRef.scriptPath }
return runSubWorkflow({ ...sub, args })
}
return { agent, parallel, pipeline, phase, log, workflow }
}
function resultToOutput(result: AgentRunResult): unknown {
return result.kind === 'ok' ? result.output : null
}

View File

@@ -0,0 +1,50 @@
import { createHash } from 'node:crypto'
import { appendFile, mkdir, readFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import type { JournalStore } from '../ports.js'
import type { AgentRunParams, JournalEntry } from '../types.js'
/** Canonical parameter string after removing display-only fields. */
function canonicalParams(params: AgentRunParams): string {
const { label: _label, phase: _phase, ...rest } = params
const keys = Object.keys(rest).sort()
const sorted: Record<string, unknown> = {}
for (const k of keys) sorted[k] = rest[k as keyof typeof rest]
return JSON.stringify(sorted)
}
/** Determinism key for an agent() call (sha256 of prompt + canonical params). */
export function agentCallKey(prompt: string, params: AgentRunParams): string {
return createHash('sha256')
.update(prompt + '\n' + canonicalParams(params))
.digest('hex')
}
/** File-based JournalStore (jsonl, one directory per run). Pure fs, no core dependencies. */
export function createFileJournalStore(runsDir: string): JournalStore {
const pathOf = (runId: string) => join(runsDir, runId, 'journal.jsonl')
return {
async read(runId): Promise<JournalEntry[]> {
try {
const raw = await readFile(pathOf(runId), 'utf-8')
const entries = raw
.split('\n')
.filter(line => line.trim().length > 0)
.map(line => JSON.parse(line) as JournalEntry)
// parallel completion order ≠ call order; re-sort by seq so the key index is stable during resume.
// Old entries missing seq are treated as 0 (forward compatibility; worst case degrades to file order).
return entries.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
} catch {
return []
}
},
async append(runId, entry) {
await mkdir(join(runsDir, runId), { recursive: true })
await appendFile(pathOf(runId), JSON.stringify(entry) + '\n', 'utf-8')
},
async truncate(runId) {
await rm(join(runsDir, runId), { recursive: true, force: true })
},
}
}

View File

@@ -0,0 +1,46 @@
import { readFile, readdir } from 'node:fs/promises'
import { join, parse, resolve } from 'node:path'
import { WORKFLOW_SCRIPT_EXTENSIONS } from '../constants.js'
import { containsPath } from './paths.js'
type Ext = (typeof WORKFLOW_SCRIPT_EXTENSIONS)[number]
function isScriptExt(ext: string): ext is Ext {
return (WORKFLOW_SCRIPT_EXTENSIONS as readonly string[]).includes(
ext.toLowerCase(),
)
}
/** Resolve a named workflow file by priority .ts → .js → .mjs. */
export async function resolveNamedWorkflow(
workflowDir: string,
name: string,
): Promise<{ path: string; content: string } | null> {
for (const ext of WORKFLOW_SCRIPT_EXTENSIONS) {
const p = resolve(workflowDir, name + ext)
// Double safeguard: prevents edge cases missed by the upper-layer sanitize from traversing paths outside workflowDir
if (!containsPath(workflowDir, p)) return null
try {
return { path: p, content: await readFile(p, 'utf-8') }
} catch {
// try the next extension
}
}
return null
}
/** List all named workflows in the directory (excluding non-script files). */
export async function listNamedWorkflows(
workflowDir: string,
): Promise<string[]> {
let files: string[]
try {
files = await readdir(workflowDir)
} catch {
return []
}
return files
.filter(f => isScriptExt(parse(f).ext))
.map(f => parse(f).name)
.sort()
}

View File

@@ -0,0 +1,26 @@
import { resolve, sep } from 'node:path'
/**
* Determine whether target, after resolution, is within base (including equal to base).
* Relative targets are resolved against base (does not depend on process.cwd).
* Uses the `sep` boundary to avoid false prefix positives (e.g. `/foo` is not the parent of `/foobar`).
*/
export function containsPath(base: string, target: string): boolean {
const resolvedBase = resolve(base)
const resolvedTarget = resolve(resolvedBase, target)
if (resolvedTarget === resolvedBase) return true
return resolvedTarget.startsWith(resolvedBase + sep)
}
/**
* Validate whether the named workflow name is a legal identifier (reject path traversal).
* Rejects: path separators, null bytes, `.` / `..`.
* Returns the sanitized name, or null for illegal.
*/
export function sanitizeWorkflowName(name: string): string | null {
if (typeof name !== 'string' || name.length === 0) return null
if (name.includes('/') || name.includes('\\')) return null
if (name.includes('\0')) return null
if (name === '.' || name === '..') return null
return name
}

View File

@@ -0,0 +1,156 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { WORKFLOW_DIR_NAME } from '../constants.js'
import type { HostHandle, WorkflowPorts } from '../ports.js'
import type { JournalEntry, WorkflowRunResult } from '../types.js'
import { createEngineContext } from './context.js'
import { WorkflowAbortedError, WorkflowError } from './errors.js'
import { makeHooks, type SubWorkflowRunner } from './hooks.js'
import { resolveNamedWorkflow } from './namedWorkflows.js'
import { parseScript, type ParsedScript } from './script.js'
export type RunWorkflowOptions = {
/** Already-resolved script source code. */
script: string
args?: unknown
runId: string
workflowName?: string
ports: WorkflowPorts
host: HostHandle
signal: AbortSignal
cwd: string
budgetTotal: number | null
/** Concurrency slots for a single run; undefined → DEFAULT_MAX_CONCURRENCY. */
maxConcurrency?: number
/** resume: when true, load the existing journal and replay. */
resume?: boolean
/** Whether the script source hash changed on resume. When true, ignore the journal and re-run everything. */
scriptChanged?: boolean
}
export async function runWorkflow(
opts: RunWorkflowOptions,
): Promise<WorkflowRunResult> {
const { ports } = opts
let parsed: ParsedScript
try {
parsed = parseScript(opts.script)
} catch (e) {
const error = (e as Error).message
ports.progressEmitter.emit({
type: 'run_done',
runId: opts.runId,
status: 'failed',
error,
})
return { status: 'failed', error }
}
const workflowName = opts.workflowName ?? parsed.meta?.name ?? 'workflow'
// Load the journal (only on resume and when the script is unchanged)
let journal: JournalEntry[] = []
let journalInvalidated = false
if (opts.resume && !opts.scriptChanged) {
journal = await ports.journalStore.read(opts.runId)
} else if (opts.scriptChanged) {
await ports.journalStore.truncate(opts.runId)
journalInvalidated = true
}
const ctx = createEngineContext({
ports,
host: opts.host,
signal: opts.signal,
runId: opts.runId,
workflowName,
cwd: opts.cwd,
budgetTotal: opts.budgetTotal,
maxConcurrency: opts.maxConcurrency,
journal,
})
if (journalInvalidated) ctx.journalInvalidated = true
ports.progressEmitter.emit({
type: 'run_started',
runId: opts.runId,
workflowName,
meta: parsed.meta,
})
// Sub-workflow executor: reuses the same ctx (sharing journal/concurrency/budget/counters), temporarily +1 depth
const runSubWorkflow: SubWorkflowRunner = async sub => {
const script = await resolveSubScript(sub, opts.cwd)
let subParsed: ParsedScript
try {
subParsed = parseScript(script)
} catch (e) {
throw new WorkflowError(
`Sub-workflow script error: ${(e as Error).message}`,
)
}
const prevDepth = ctx.resources.depth
ctx.resources.depth += 1
try {
const subHooks = makeHooks(ctx, runSubWorkflow)
return await subParsed.execute(subHooks, sub.args, ctx.resources.budget)
} finally {
ctx.resources.depth = prevDepth
}
}
const hooks = makeHooks(ctx, runSubWorkflow)
// hook.phase only emits phase_done for the previous phase when switching phases; when the script ends,
// currentPhase is the last phase, and there is no subsequent phase() to trigger its phase_done → the left pane of the UI
// would stay running forever (the agent list already shows ✓ done). Emit one before the terminal state — shared by all paths.
const emitTerminalPhaseDone = (): void => {
if (!ctx.currentPhase) return
ports.progressEmitter.emit({
type: 'phase_done',
runId: opts.runId,
phase: ctx.currentPhase,
})
}
let result: WorkflowRunResult
try {
const returnValue = await parsed.execute(
hooks,
opts.args,
ctx.resources.budget,
)
result = { status: 'completed', returnValue }
} catch (e) {
if (e instanceof WorkflowAbortedError) {
result = { status: 'killed' }
} else {
result = { status: 'failed', error: (e as Error).message }
}
}
emitTerminalPhaseDone()
ports.progressEmitter.emit({
type: 'run_done',
runId: opts.runId,
...result,
})
return result
}
async function resolveSubScript(
sub: { name?: string; scriptPath?: string; script?: string },
cwd: string,
): Promise<string> {
if (sub.script) return sub.script
if (sub.scriptPath) return await readFile(sub.scriptPath, 'utf-8')
if (sub.name) {
const found = await resolveNamedWorkflow(
join(cwd, WORKFLOW_DIR_NAME),
sub.name,
)
if (!found) throw new WorkflowError(`Sub-workflow "${sub.name}" not found`)
return found.content
}
throw new WorkflowError('workflow() requires name or scriptPath')
}

View File

@@ -0,0 +1,229 @@
import type { WorkflowMeta } from '../types.js'
export class ScriptError extends Error {
constructor(message: string) {
super(message)
this.name = 'ScriptError'
}
}
/** Shape of the hook functions the engine injects into a script. */
export type WorkflowHooks = {
agent: (prompt: string, opts?: Record<string, unknown>) => Promise<unknown>
parallel: <T>(thunks: Array<() => Promise<T>>) => Promise<Array<T | null>>
pipeline: <T, R>(
items: readonly T[],
...stages: Array<
(prev: unknown, item: T, index: number) => Promise<unknown>
>
) => Promise<Array<R | null>>
phase: (title: string) => void
log: (message: string) => void
workflow: (
nameOrRef: string | { scriptPath: string },
args?: unknown,
) => Promise<unknown>
}
const META_RE = /export\s+const\s+meta\s*=\s*/
/**
* Extract the `export const meta = { ... }` pure literal. Returns the meta object and the stripped body.
* The literal is evaluated with a parameter-less Function — any identifier reference throws ReferenceError → reported as "not a plain literal".
*/
export function extractMeta(source: string): {
meta: WorkflowMeta | null
body: string
} {
const match = META_RE.exec(source)
if (!match) return { meta: null, body: source }
let i = match.index + match[0].length
while (i < source.length && /\s/.test(source[i]!)) i++
if (source[i] !== '{') {
throw new ScriptError('meta must be an object literal `{ ... }`')
}
// Brace matching (handles strings / escapes / nesting)
let depth = 0
const start = i
let inStr: string | null = null
for (; i < source.length; i++) {
const ch = source[i]!
if (inStr) {
if (ch === '\\') {
i++
continue
}
if (ch === inStr) inStr = null
continue
}
if (ch === '"' || ch === "'" || ch === '`') {
inStr = ch
continue
}
if (ch === '{') depth++
else if (ch === '}') {
depth--
if (depth === 0) {
i++
break
}
}
}
if (depth !== 0) throw new ScriptError('meta literal braces are not closed')
const literal = source.slice(start, i)
let metaObj: unknown
try {
// Parameter-less Function: a plain literal can be evaluated; referencing any identifier → ReferenceError
metaObj = new Function(`return (${literal})`)()
} catch (e) {
throw new ScriptError(
`meta must be a plain literal (no variable/function calls/interpolation): ${(e as Error).message}`,
)
}
const meta = validateMeta(metaObj)
// Strip the meta statement (including trailing semicolon and extra blank lines)
const body =
source.slice(0, match.index) +
source.slice(i).replace(/^[ \t]*;[ \t]*\n/, '\n')
return { meta, body }
}
function validateMeta(v: unknown): WorkflowMeta {
if (typeof v !== 'object' || v === null || Array.isArray(v)) {
throw new ScriptError('meta must be an object')
}
const o = v as Record<string, unknown>
if (typeof o.name !== 'string' || typeof o.description !== 'string') {
throw new ScriptError('meta must include string name and description')
}
return o as unknown as WorkflowMeta
}
// ---- Non-determinism sandbox shim ----
class NonDeterministicError extends Error {
constructor(fn: string) {
super(
`${fn} is not available in workflow scripts (would break resume determinism). Pass timestamps/random seeds via args.`,
)
this.name = 'NonDeterministicError'
}
}
function sandboxDate(): DateConstructor {
const fn = function (...args: unknown[]): Date {
if (args.length === 0)
throw new NonDeterministicError('Date.now()/new Date()')
return new (Date as unknown as DateConstructor)(
...(args as [string | number | Date]),
)
} as unknown as DateConstructor
fn.now = () => {
throw new NonDeterministicError('Date.now()')
}
fn.parse = Date.parse
fn.UTC = Date.UTC
return fn
}
function sandboxMath(): Math {
return new Proxy(Math, {
get(target, prop, receiver) {
if (prop === 'random') {
return () => {
throw new NonDeterministicError('Math.random()')
}
}
return Reflect.get(target, prop, receiver)
},
}) as Math
}
const AsyncFunction = Object.getPrototypeOf(async function () {})
.constructor as {
new (...args: string[]): (...args: unknown[]) => Promise<unknown>
}
export type ParsedScript = {
meta: WorkflowMeta | null
execute: (
hooks: WorkflowHooks,
args: unknown,
budget: unknown,
) => Promise<unknown>
}
/** Validate + wrap the script as an executable async function (Date/Math are shimmed). */
/**
* Detect common violations in the script body (import / extra export) and produce precise errors with guidance.
* Otherwise it would fall through to AsyncFunction's generic "syntax error", making it hard for the model/user to pinpoint the root cause
* (the script is a non-ESM function body, hooks are already injected, and the engine does not transpile TS).
*/
function assertScriptBody(body: string): void {
if (/^\s*import\b/m.test(body)) {
throw new ScriptError(
'workflow scripts are the body of new AsyncFunction (not ESM modules); import is not supported. ' +
'agent / parallel / pipeline / phase / log / workflow / args / budget are injected as parameters — use them directly.',
)
}
// Dynamic import(...) calls: the sandbox only preserves resume determinism, not security, but obvious escape attempts should be blocked.
// Not anchored to the start of a line so it can catch `await import(...)`, `return import(...)`, etc.; requires `import` followed by `(` to intercept,
// avoiding false positives where the word "import" appears inside a string literal (e.g. agent('please import this module')).
if (/\bimport\s*\(/m.test(body)) {
throw new ScriptError(
'dynamic import(...) is forbidden in workflow scripts: it bypasses the Date/Math sandbox and breaks resume determinism. ' +
'The sandbox does not guarantee security (same trust level as the LLM), but explicit escapes are prohibited. Inject external dependencies via args.',
)
}
if (/^\s*export\b/m.test(body)) {
throw new ScriptError(
'workflow scripts allow only one export const meta = {...} (already extracted by the engine). ' +
'Remove other export / export default statements; use top-level return for the result.',
)
}
}
export function parseScript(source: string): ParsedScript {
const { meta, body } = extractMeta(source)
assertScriptBody(body)
let fn: (...args: unknown[]) => Promise<unknown>
try {
fn = new AsyncFunction(
'agent',
'parallel',
'pipeline',
'phase',
'log',
'workflow',
'args',
'budget',
'Date',
'Math',
body,
)
} catch (e) {
throw new ScriptError(`Script syntax error: ${(e as Error).message}`)
}
const sandboxedDate = sandboxDate()
const sandboxedMath = sandboxMath()
return {
meta,
async execute(hooks, args, budget) {
return fn(
hooks.agent,
hooks.parallel,
hooks.pipeline,
hooks.phase,
hooks.log,
hooks.workflow,
args,
budget,
sandboxedDate,
sandboxedMath,
)
},
}
}

View File

@@ -0,0 +1,26 @@
import { Ajv, type ValidateFunction } from 'ajv'
const cache = new WeakMap<object, ValidateFunction>()
/**
* Validate agent output against a JSON Schema (Ajv, compilation result cached by schema object).
* The engine performs secondary validation on the schema result returned by the adapter, and uses it for tests.
*/
export function validateAgainstSchema(
value: unknown,
schema: object,
): { valid: boolean; errors: string[] } {
let validate = cache.get(schema)
if (!validate) {
const ajv = new Ajv({ allErrors: true, strict: false })
validate = ajv.compile(schema) as ValidateFunction
cache.set(schema, validate)
}
const valid = validate(value) as boolean
return {
valid,
errors: valid
? []
: (validate.errors ?? []).map(e => e.message ?? 'validation error'),
}
}

View File

@@ -0,0 +1,25 @@
// @claude-code-best/workflow-engine
// Deterministic JS script orchestration engine. Zero core-layer runtime dependencies; talks to the world via port adapters.
export * from './types.js'
export * from './constants.js'
export * from './ports.js'
export * from './agentAdapter.js'
export * from './engine/concurrency.js'
export * from './engine/script.js'
export * from './engine/journal.js'
export * from './engine/budget.js'
export * from './engine/structuredOutput.js'
export * from './engine/namedWorkflows.js'
export * from './engine/errors.js'
export * from './engine/context.js'
export * from './engine/hooks.js'
export * from './engine/runWorkflow.js'
export * from './progress/events.js'
export {
createWorkflowTool,
type WorkflowToolDescriptor,
} from './tool/WorkflowTool.js'
export { workflowInputSchema, type WorkflowInput } from './tool/schema.js'
export { persistInlineScript } from './tool/persistInline.js'
export { WORKFLOW_TOOL_NAME } from './tool/constants.js'

View File

@@ -0,0 +1,149 @@
import type { AgentAdapterRegistry } from './agentAdapter.js'
import type {
AgentRunParams,
AgentRunResult,
JournalEntry,
ProgressEvent,
} from './types.js'
/**
* Opaque host handle. The core side constructs one per tool call, containing toolUseContext/
* canUseTool/parentMessage, etc. The package never inspects its internals; it only passes it through to the AgentRunner.
* This is the only coupling seam between the package and the core layer, and it is opaque.
*/
const HOST_HANDLE = Symbol('workflow.hostHandle')
export type HostBundle = unknown
export type HostHandle = { readonly [HOST_HANDLE]: HostBundle }
/** Used by the core-side hostFactory: wraps any bundle into an opaque handle. */
export function createHostHandle(bundle: HostBundle): HostHandle {
return { [HOST_HANDLE]: bundle } as HostHandle
}
/** Type guard. */
export function isHostHandle(value: unknown): value is HostHandle {
return (
typeof value === 'object' &&
value !== null &&
HOST_HANDLE in (value as object)
)
}
/** Used by the core-side adapter: unwraps (only the adapter should call this). */
export function unwrapHostHandle(handle: HostHandle): HostBundle {
return (handle as { [k: symbol]: HostBundle })[HOST_HANDLE]
}
/** Backend for the agent() hook. */
export type AgentRunner = {
runAgentToResult(
params: AgentRunParams,
host: HostHandle,
): Promise<AgentRunResult>
}
/** Progress event emitter. */
export type ProgressEmitter = {
emit(event: ProgressEvent): void
}
/** Background task lifecycle. */
export type TaskRegistrar = {
/**
* Register a background task. The adapter creates an AbortController and stores it in task state,
* returning runId and signal (for the engine to execute detached + kill to abort).
*/
register(
opts: {
workflowName: string
workflowFile?: string
summary?: string
toolUseId?: string
/** On resume, reuse the existing runId (read its journal). Omit to generate a new id. */
runId?: string
},
host: HostHandle,
): { runId: string; signal: AbortSignal }
complete(runId: string, summary?: string): void
fail(runId: string, error: string): void
kill(runId: string): void
/**
* Register an agent-level AbortController. Called by the backend when starting an agent, so that service
* .kill(runId, agentId) can precisely abort a single agent (without affecting other agents in the same run).
* Idempotent: re-registering with the same agentId overwrites.
*/
registerAgentAbort?(runId: string, agentId: number, ac: AbortController): void
/**
* Unregister an agent-level AbortController (called when the agent completes/fails; idempotent).
*/
unregisterAgentAbort?(runId: string, agentId: number): void
/**
* Abort a single agent. Returns whether it hit (false = agent already completed/does not exist).
* Does not affect other agents in the same run; the workflow continues (the aborted agent returns dead → null).
*/
killAgent?(runId: string, agentId: number): boolean
/** Returns the current pending skip/retry action, or null. */
pendingAction(runId: string): { kind: 'skip' | 'retry' } | null
}
/** Journal persistence. */
export type JournalStore = {
read(runId: string): Promise<JournalEntry[]>
append(runId: string, entry: JournalEntry): Promise<void>
truncate(runId: string): Promise<void>
}
/** Cancellation / permission gate. */
export type PermissionGate = {
isAborted(host: HostHandle): boolean
}
/** Logging + telemetry. */
export type Logger = {
debug(msg: string): void
event(name: string, metadata?: Record<string, unknown>): void
/**
* Warning-level log (e.g. errors swallowed when a single parallel/pipeline item fails).
* Optional: old ports implementations may omit it; hooks tolerate it with `?.()`.
*/
warn?(msg: string): void
}
/** Ready-to-use context the engine extracts from the host (handle + basic fields). */
export type WorkflowHostContext = {
/** Opaque handle passed through to the AgentRunner (contains toolUseContext/canUseTool/parentMessage). */
handle: HostHandle
cwd: string
/** Token budget cap; null means unlimited. */
budgetTotal: number | null
/** Core-side tool-use id (passed through to task registration). */
toolUseId?: string
}
/**
* Provided by the core side: constructs a WorkflowHostContext from the tool call's core context.
* The arguments are opaque to the package (unknown); the core-side hostFactory knows the real types.
*/
export type HostFactory = (args: {
context: unknown
canUseTool: unknown
parentMessage: unknown
}) => WorkflowHostContext
/** Aggregate of all ports. Injected into createWorkflowTool(ports). */
export type WorkflowPorts = {
agentRunner: AgentRunner
/**
* Multi-backend adapter registry. When provided, takes precedence over agentRunner — hooks.agent routes
* to adapter.run via the registry; when omitted, falls back to agentRunner (backward compatibility).
*/
agentAdapterRegistry?: AgentAdapterRegistry
progressEmitter: ProgressEmitter
taskRegistrar: TaskRegistrar
journalStore: JournalStore
permissionGate: PermissionGate
logger: Logger
hostFactory: HostFactory
}

View File

@@ -0,0 +1,20 @@
import type { ProgressEmitter } from '../ports.js'
import type { ProgressEvent } from '../types.js'
export type { ProgressEvent }
/** Construct a ProgressEmitter from a single callback. */
export function createProgressEmitter(
onEvent: (e: ProgressEvent) => void,
): ProgressEmitter {
return { emit: onEvent }
}
/** Collect all events into an array (for tests). */
export function createBufferingEmitter(): {
emitter: ProgressEmitter
events: ProgressEvent[]
} {
const events: ProgressEvent[] = []
return { emitter: { emit: e => void events.push(e) }, events }
}

View File

@@ -0,0 +1,261 @@
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { z } from 'zod/v4'
import { WORKFLOW_DIR_NAME, WORKFLOW_TOOL_NAME } from '../constants.js'
import { resolveNamedWorkflow } from '../engine/namedWorkflows.js'
import { runWorkflow } from '../engine/runWorkflow.js'
import { parseScript } from '../engine/script.js'
import { containsPath, sanitizeWorkflowName } from '../engine/paths.js'
import type { WorkflowPorts } from '../ports.js'
import type { WorkflowRunResult } from '../types.js'
import { workflowInputSchema, type WorkflowInput } from './schema.js'
import { persistInlineScript } from './persistInline.js'
/** Self-contained tool descriptor (core wiring wraps it with buildTool). Zero core-layer dependencies. */
export type WorkflowToolDescriptor = {
name: string
inputSchema: z.ZodType<WorkflowInput>
isEnabled: () => boolean
isReadOnly: (input: WorkflowInput) => boolean
description: () => Promise<string>
prompt: () => Promise<string>
renderToolUseMessage: (input: Partial<WorkflowInput>) => string
call: (
input: WorkflowInput,
context: unknown,
canUseTool: unknown,
parentMessage: unknown,
onProgress?: unknown,
) => Promise<{ data: { output: string } }>
mapToolResultToToolResultBlockParam: (
data: { output: string },
toolUseId: string,
) => {
tool_use_id: string
type: 'tool_result'
content: Array<{ type: 'text'; text: string }>
}
}
const WORKFLOW_TOOL_PROMPT = `Use the Workflow tool to execute a workflow script that orchestrates multiple subagents deterministically. The script runs in the background; you receive a run_id immediately and are notified on completion.
Provide the script inline via "script", or reference a named workflow via "name" (resolved from .claude/workflows/), or an existing file via "scriptPath". Pass "args" as a real JSON value (object/array/string), not a stringified string.
Use "resumeFromRunId" to resume a prior run — completed agent() calls replay from the journal instantly.
Concurrency: default is 3 (hard ceiling 16). OMIT maxConcurrency to use 3. To set maxConcurrency to ANY value other than 3, you MUST first ask the user via AskUserQuestion — propose 3 / 6 / 9 (or other tiers matching the fan-out width) with 3 marked "(Recommended)". The ONLY exception: the user has ALREADY specified a concurrency number in this session ("use 6", "maxConcurrency 9") — then honor it without re-asking. Never silently raise concurrency above 3 just because the workflow fans out; 3 is the recommended default.
Script execution model (common pitfalls — getting these wrong is the #1 cause of script errors): the script is the body of \`new AsyncFunction\` — NOT an ESM module, and TypeScript is NOT transpiled. Therefore:
- Do NOT use \`import\`\`agent\`, \`parallel\`, \`pipeline\`, \`phase\`, \`log\`, \`workflow\`, \`args\`, and \`budget\` are injected as parameters; reference them directly.
- Do NOT use TS type annotations, \`interface\`, \`enum\`, \`as\`, or generics — the engine does not transpile, so even a .ts file with type syntax fails to parse.
- Keep EXACTLY ONE \`export const meta = {...}\` (plain literal) and remove every other \`export\` / \`export default\`.
- Return the result with a top-level \`return\`.
Prefer .js / .mjs. See /ultracode for the full playbook and quality patterns.`
export function createWorkflowTool(
ports: WorkflowPorts,
): WorkflowToolDescriptor {
return {
name: WORKFLOW_TOOL_NAME,
inputSchema: workflowInputSchema,
// No per-session runtime opt-in gate here: the "ultracode is on for the
// session" signal is injected by the harness (claude.ai/client), not held
// in any repo state. This tool is compiled in/out via feature('WORKFLOW_SCRIPTS')
// in src/tools.ts; beyond that it is always enabled when present.
isEnabled: () => true,
isReadOnly: () => false,
async description() {
return 'Execute a workflow script that orchestrates multiple subagents to complete a task'
},
async prompt() {
return WORKFLOW_TOOL_PROMPT
},
renderToolUseMessage(input) {
if (input.resumeFromRunId)
return `Workflow resume: ${input.resumeFromRunId}`
const id =
input.name ?? input.scriptPath ?? (input.script ? 'inline' : 'unknown')
return `Workflow: ${id}`
},
async call(input, context, canUseTool, parentMessage) {
const host = ports.hostFactory({ context, canUseTool, parentMessage })
// Resolve the script source
let script: string
let workflowFile: string | undefined
try {
const resolved = await resolveScriptSource(input, host.cwd)
script = resolved.script
workflowFile = resolved.workflowFile
} catch (e) {
return { data: { output: `Error: ${(e as Error).message}` } }
}
// Quick validation (meta + syntax): on failure return an error to the model directly, do not enter the background
try {
parseScript(script)
} catch (e) {
return {
data: {
output: `Error: script validation failed: ${(e as Error).message}`,
},
}
}
const workflowName = input.name ?? input.title ?? 'workflow'
const { runId, signal } = ports.taskRegistrar.register(
{
workflowName,
...(workflowFile ? { workflowFile } : {}),
...(input.description ? { summary: input.description } : {}),
...(host.toolUseId ? { toolUseId: host.toolUseId } : {}),
...(input.resumeFromRunId ? { runId: input.resumeFromRunId } : {}),
},
host.handle,
)
// Inline entry: persist the script to the run directory and return a reusable path (the
// inline -> persist -> edit -> resubmit-as-scriptPath iteration loop promised by the ultracode skill).
// On write failure degrade to a placeholder + warn, do not abort the run (script is already in memory).
if (!workflowFile && input.script) {
try {
workflowFile = await persistInlineScript(
input.script,
runId,
host.cwd,
)
} catch (e) {
ports.logger.warn?.(
`inline script persist failed: ${(e as Error).message}`,
)
}
}
// Detached execution
void runWorkflow({
script,
...(input.args !== undefined
? { args: normalizeArgs(input.args) }
: {}),
runId,
workflowName,
ports,
host: host.handle,
signal,
cwd: host.cwd,
budgetTotal: host.budgetTotal,
...(input.maxConcurrency !== undefined
? { maxConcurrency: input.maxConcurrency }
: {}),
...(input.resumeFromRunId ? { resume: true } : {}),
})
.then(result => onFinish(ports, result, runId))
.catch(e => ports.taskRegistrar.fail(runId, (e as Error).message))
const scriptPath = workflowFile ?? `<inline run ${runId}>`
return {
data: {
output: [
'Workflow started (running in the background).',
`run_id: ${runId}`,
`workflow: ${workflowName}`,
`script: ${scriptPath}`,
'',
'You will be notified on completion. Use /workflows to view live progress.',
].join('\n'),
},
}
},
mapToolResultToToolResultBlockParam(data, toolUseId) {
return {
tool_use_id: toolUseId,
type: 'tool_result',
content: [{ type: 'text', text: data.output }],
}
},
}
}
function onFinish(
ports: WorkflowPorts,
result: WorkflowRunResult,
runId: string,
): void {
if (result.status === 'completed') {
const summary =
result.returnValue == null
? '(no return value)'
: formatValue(result.returnValue)
ports.taskRegistrar.complete(runId, summary)
} else if (result.status === 'failed') {
ports.taskRegistrar.fail(runId, result.error ?? 'workflow failed')
} else {
ports.taskRegistrar.kill(runId)
}
}
function formatValue(v: unknown): string {
if (typeof v === 'string') return v.slice(0, 500)
try {
return JSON.stringify(v).slice(0, 500)
} catch {
return String(v)
}
}
/**
* Defensively normalize args: under the legacy `z.string()` contract the model may send a stringified JSON object.
* Only normalize when the string JSON.parses to an object/array; plain strings, numbers, etc. are preserved as-is.
*/
function normalizeArgs(raw: unknown): unknown {
if (typeof raw !== 'string') return raw
try {
const parsed: unknown = JSON.parse(raw)
if (typeof parsed === 'object' && parsed !== null) return parsed
return raw
} catch {
return raw
}
}
async function resolveScriptSource(
input: WorkflowInput,
cwd: string,
): Promise<{ script: string; workflowFile?: string }> {
if (input.script) return { script: input.script }
if (input.scriptPath) {
const resolved = resolve(cwd, input.scriptPath)
if (!containsPath(cwd, resolved)) {
throw new Error(
`scriptPath "${input.scriptPath}" is out of bounds (after resolve, ${resolved} is not within cwd ${cwd})`,
)
}
return {
script: await readFile(resolved, 'utf-8'),
workflowFile: resolved,
}
}
if (input.name) {
if (sanitizeWorkflowName(input.name) === null) {
throw new Error(
`Named workflow name "${input.name}" is invalid (contains path separators or is . / ..)`,
)
}
const found = await resolveNamedWorkflow(
join(cwd, WORKFLOW_DIR_NAME),
input.name,
)
if (!found) {
throw new Error(
`Named workflow "${input.name}" not found (looked in ${WORKFLOW_DIR_NAME}/)`,
)
}
return { script: found.content, workflowFile: found.path }
}
throw new Error('One of script, name, or scriptPath must be provided')
}

View File

@@ -0,0 +1 @@
export { WORKFLOW_TOOL_NAME } from '../constants.js'

View File

@@ -0,0 +1,28 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { WORKFLOW_RUNS_DIR } from '../constants.js'
/**
* Persist an inline workflow script to the run directory so the caller can
* iterate via `scriptPath` + `resumeFromRunId` without resending the full script
* (the round-trip the ultracode skill promises for the inline entry path).
*
* Mirrors engine/journal.ts: writes directly via node:fs/promises (no port) to
* `<cwd>/<WORKFLOW_RUNS_DIR>/<runId>/script.js` — the same directory as
* journal.jsonl, so journalStore.truncate(runId) cleans it up alongside the journal.
*
* Fixed filename `script.js`: parseScript ignores the extension and the runId
* already makes the directory unique, so a stable name aids muscle memory.
*/
export async function persistInlineScript(
script: string,
runId: string,
cwd: string,
): Promise<string> {
const dir = join(cwd, WORKFLOW_RUNS_DIR, runId)
await mkdir(dir, { recursive: true })
const filePath = join(dir, 'script.js')
await writeFile(filePath, script, 'utf-8')
return filePath
}

View File

@@ -0,0 +1,52 @@
import { z } from 'zod/v4'
/** Workflow tool input schema. args is any JSON value (object/array/string/etc.). */
export const workflowInputSchema = z.object({
script: z
.string()
.optional()
.describe('Self-contained workflow script source (inline)'),
name: z
.string()
.optional()
.describe('Named workflow, resolved to .claude/workflows/<name>.ts|js|mjs'),
scriptPath: z
.string()
.optional()
.describe('Absolute path to an existing script file'),
args: z
.unknown()
.optional()
.describe(
'The args global variable passed through to the script. Pass a real JSON value (object/array/string), not a JSON string.',
),
resumeFromRunId: z
.string()
.optional()
.describe('Resume the specified run, replaying the journal'),
description: z
.string()
.optional()
.describe('A short description of this invocation (3-5 words)'),
title: z.string().optional().describe('Progress viewer title'),
maxConcurrency: z
.number()
.int()
.min(1)
.max(16)
.optional()
.describe(
'Concurrency cap for agent(). Defaults to 3 (max 16). When the workflow contains heavy parallel/pipeline fan-out, you may confirm the desired concurrency with the user via AskUserQuestion before launching.',
),
})
/**
* Workflow tool input type — derived from the schema to avoid hand-written type/schema drift.
* In the old implementation {@link WorkflowInput} was hand-written in types.ts and the schema in schema.ts,
* bridged by a `as unknown as z.ZodType<WorkflowInput>` double assertion — when the schema changed fields
* but the type did not, TS would not flag it. With z.infer, schema/type stay in sync forever.
*/
export type WorkflowInput = z.infer<typeof workflowInputSchema>
/** typeof type of the schema (used for "schema is the source of truth" precise signatures). */
export type WorkflowInputSchema = typeof workflowInputSchema

View File

@@ -0,0 +1,130 @@
// Pure type definitions. No runtime dependencies.
// WorkflowInput has been migrated to tool/schema.ts and derived via z.infer to avoid drift from the schema.
/** Shape of the script's `export const meta = {...}` (must be a plain literal). */
export type WorkflowMeta = {
name: string
description: string
whenToUse?: string
phases?: Array<{ title: string; detail?: string }>
}
/** Parameters passed by agent() to the AgentRunner. */
export type AgentRunParams = {
prompt: string
/** JSON Schema; when provided, agent returns a validated object instead of text. */
schema?: object
model?: string
/** Output token cap (passed through to the agent backend, e.g. LLM max_tokens). */
maxTokens?: number
/** Custom subagent type (resolved from the registry). */
agentType?: string
isolation?: 'worktree'
allowedTools?: string[]
/** Display-only; not part of the journal key. */
label?: string
/** Display-only; not part of the journal key. */
phase?: string
}
/** Progress snapshot while the agent is running (onProgress callback payload; backend loop accumulates tokens/tools). */
export type AgentProgressUpdate = {
tokenCount: number
toolCount: number
}
/**
* Returned by AgentRunner. The ok variant carries model/toolCount for panel display (optional; standalone backends may leave them blank).
*
* dead carries optional reason/detail: the journal history only records `{kind:"dead"}` with no info,
* so during debugging you cannot distinguish "agent finished but produced no StructuredOutput" from "runAgent threw".
* reason lets the hooks retry log, the panel, and post-hoc auditing see the cause of death immediately.
*/
export type AgentRunResult =
| {
kind: 'ok'
output: string | object
usage: { outputTokens: number }
/** The actually-resolved model id (display-only). */
model?: string
/** Number of tool calls during the agent run. */
toolCount?: number
/** Total context tokens at completion (display-only; same basis as the real-time agent_progress). */
tokenCount?: number
}
| { kind: 'skipped' }
| {
kind: 'dead'
/**
* Cause-of-death classification for log aggregation / post-hoc auditing. Optional for backward compatibility with old journals.
* - no-structured-output: agent finished but finalize content has no StructuredOutput (neither called tools nor produced JSON in text)
* - runagent-threw: runAgent threw a non-abort error (API failure / context overflow / runtime error)
* - worktree-failed: isolation:'worktree' creation failed (fail-closed degradation)
* - unknown: unclassified (compatible with old backends / third-party adapters)
*/
reason?:
| 'no-structured-output'
| 'runagent-threw'
| 'worktree-failed'
| 'unknown'
/** Detail (error message / text preview) for logs; not shown to end users. */
detail?: string
}
/** A single record in the journal. seq = agent() call sequence number; read() re-sorts by it to stabilize resume. */
export type JournalEntry = {
key: string
/** agent() call order (from agentIdSeq; monotonically increasing across sub-workflows). */
seq: number
result: AgentRunResult
}
/** Progress events. All variants carry runId so the adapter can route to the corresponding task (multiple concurrent workflows). */
export type ProgressEvent =
| {
type: 'run_started'
runId: string
workflowName: string
meta: WorkflowMeta | null
}
| { type: 'phase_started'; runId: string; phase: string }
| { type: 'phase_done'; runId: string; phase: string }
| {
type: 'agent_started'
runId: string
agentId: number
label?: string
phase?: string
}
| {
type: 'agent_done'
runId: string
agentId: number
label?: string
phase?: string
result: AgentRunResult
}
| {
type: 'agent_progress'
runId: string
agentId: number
label?: string
phase?: string
tokenCount: number
toolCount: number
}
| { type: 'log'; runId: string; message: string }
| {
type: 'run_done'
runId: string
status: 'completed' | 'failed' | 'killed'
returnValue?: unknown
error?: string
}
/** Engine run result. */
export type WorkflowRunResult = {
status: 'completed' | 'failed' | 'killed'
returnValue?: unknown
error?: string
}

View File

@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"types": ["bun"],
"lib": ["ESNext"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View File

@@ -95,4 +95,7 @@ export const DEFAULT_BUILD_FEATURES = [
'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行 'SSH_REMOTE', // SSH 远程连接,本地 REPL + 远端工具执行
// Autofix PR // Autofix PR
'AUTOFIX_PR', // /autofix-pr 命令fork 引入docs/jira/AUTOFIX-PR-001.md 承诺默认开启) 'AUTOFIX_PR', // /autofix-pr 命令fork 引入docs/jira/AUTOFIX-PR-001.md 承诺默认开启)
// Persistent thread goal command — auto-continuation, JSONL persistence,
// strict completion/blocked audit. See src/services/goal.
'GOAL',
] as const ] as const

View File

@@ -60,6 +60,7 @@ import terminalSetup from './commands/terminalSetup/index.js'
import usage from './commands/usage/index.js' import usage from './commands/usage/index.js'
import theme from './commands/theme/index.js' import theme from './commands/theme/index.js'
import vim from './commands/vim/index.js' import vim from './commands/vim/index.js'
import webTools from './commands/web-tools/index.js'
import { feature } from 'bun:bundle' import { feature } from 'bun:bundle'
// Dead code elimination: conditional imports // Dead code elimination: conditional imports
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
@@ -162,6 +163,11 @@ const poor = feature('POOR')
require('./commands/poor/index.js') as typeof import('./commands/poor/index.js') require('./commands/poor/index.js') as typeof import('./commands/poor/index.js')
).default ).default
: null : null
const goalCmd = feature('GOAL')
? (
require('./commands/goal/index.js') as typeof import('./commands/goal/index.js')
).default
: null
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */
import thinkback from './commands/thinkback/index.js' import thinkback from './commands/thinkback/index.js'
import thinkbackPlay from './commands/thinkback-play/index.js' import thinkbackPlay from './commands/thinkback-play/index.js'
@@ -358,10 +364,12 @@ const COMMANDS = memoize((): Command[] => [
usage, usage,
usageReport, usageReport,
vim, vim,
webTools,
...(webCmd ? [webCmd] : []), ...(webCmd ? [webCmd] : []),
...(forkCmd ? [forkCmd] : []), ...(forkCmd ? [forkCmd] : []),
...(buddy ? [buddy] : []), ...(buddy ? [buddy] : []),
...(poor ? [poor] : []), ...(poor ? [poor] : []),
...(goalCmd ? [goalCmd] : []),
...(proactive ? [proactive] : []), ...(proactive ? [proactive] : []),
...(monitorCmd ? [monitorCmd] : []), ...(monitorCmd ? [monitorCmd] : []),
...(coordinatorCmd ? [coordinatorCmd] : []), ...(coordinatorCmd ? [coordinatorCmd] : []),
@@ -477,7 +485,7 @@ async function getSkills(cwd: string): Promise<{
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const getWorkflowCommands = feature('WORKFLOW_SCRIPTS') const getWorkflowCommands = feature('WORKFLOW_SCRIPTS')
? ( ? (
require('@claude-code-best/builtin-tools/tools/WorkflowTool/createWorkflowCommand.js') as typeof import('@claude-code-best/builtin-tools/tools/WorkflowTool/createWorkflowCommand.js') require('./workflow/namedWorkflowCommands.js') as typeof import('./workflow/namedWorkflowCommands.js')
).getWorkflowCommands ).getWorkflowCommands
: null : null
/* eslint-enable @typescript-eslint/no-require-imports */ /* eslint-enable @typescript-eslint/no-require-imports */

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { EffortPanel } from '../../components/EffortPanel/EffortPanel.js';
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
import { import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -161,9 +162,18 @@ export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, arg
} }
if (!args || args === 'current' || args === 'status') { if (!args || args === 'current' || args === 'status') {
if (args === 'current' || args === 'status') {
return <ShowCurrentEffort onDone={onDone} />; return <ShowCurrentEffort onDone={onDone} />;
} }
// 完全无参 → 打开交互面板
return <EffortPanelWrapper onDone={onDone} />;
}
const result = executeEffort(args); const result = executeEffort(args);
return <ApplyEffortAndClose result={result} onDone={onDone} />; return <ApplyEffortAndClose result={result} onDone={onDone} />;
} }
function EffortPanelWrapper({ onDone }: { onDone: (result: string) => void }): React.ReactNode {
const effortValue = useAppState(s => s.effortValue);
return <EffortPanel appStateEffort={effortValue} onDone={onDone} />;
}

View File

@@ -0,0 +1,75 @@
/**
* Confirmation dialog shown when the user runs `/goal <objective>`
* while a non-complete goal is already active.
*/
import * as React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { GoalState } from 'src/types/logs.js';
import { Select } from 'src/components/CustomSelect/index.js';
import { PermissionDialog } from 'src/components/permissions/PermissionDialog.js';
import { formatGoalElapsed, formatGoalStatusLabel } from 'src/services/goal/goalState.js';
type Props = {
currentGoal: GoalState;
newObjective: string;
onConfirm: () => void;
onCancel: () => void;
};
export function GoalReplaceConfirmDialog({ currentGoal, newObjective, onConfirm, onCancel }: Props): React.ReactNode {
function handleResponse(value: 'yes' | 'no'): void {
if (value === 'yes') onConfirm();
else onCancel();
}
const tokensDisplay =
currentGoal.tokenBudget !== null
? `${currentGoal.tokensUsed} / ${currentGoal.tokenBudget}`
: `${currentGoal.tokensUsed}`;
return (
<PermissionDialog color="warning" title="Replace active goal?">
<Box flexDirection="column" marginTop={1} paddingX={1}>
<Text>A goal is already in progress. Replacing it will reset all progress and counters.</Text>
<Box marginTop={1} flexDirection="column">
<Text dimColor>Current goal:</Text>
<Text>
<Text dimColor>· Objective: </Text>
{currentGoal.objective}
</Text>
<Text>
<Text dimColor>· Status: </Text>
{formatGoalStatusLabel(currentGoal.status)}
</Text>
<Text>
<Text dimColor>· Time: </Text>
{formatGoalElapsed(currentGoal)}
</Text>
<Text>
<Text dimColor>· Tokens: </Text>
{tokensDisplay}
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<Text dimColor>New objective:</Text>
<Text>{newObjective}</Text>
</Box>
<Box marginTop={1}>
<Select
options={[
{ label: 'Yes, replace the goal', value: 'yes' as const },
{ label: 'No, keep the current goal', value: 'no' as const },
]}
onChange={handleResponse}
onCancel={onCancel}
/>
</Box>
</Box>
</PermissionDialog>
);
}

207
src/commands/goal/goal.tsx Normal file
View File

@@ -0,0 +1,207 @@
/**
* `/goal` slash command — set, view, or control the persistent thread
* goal that drives auto-continuation across turns.
*
* Subcommands
* -----------
* `/goal` -> show current status
* `/goal status` -> alias of bare `/goal`
* `/goal clear` -> remove the active goal (persists tombstone)
* `/goal pause` -> pause auto-continuation
* `/goal resume` -> resume from paused state
* `/goal continue` -> reset turn counter after max-turns and continue
* `/goal complete` -> mark complete (manual override; tools usually do this)
* `/goal <objective>` -> set a new goal; if one is already active and not
* complete, a confirmation dialog appears first.
*/
import * as React from 'react';
import type { LocalJSXCommandContext } from 'src/commands.js';
import {
clearGoal,
completeGoal,
continueGoalFromMaxTurns,
formatGoalElapsed,
formatGoalStatusLabel,
getGoal,
incrementGoalTurns,
MAX_GOAL_TURNS,
pauseGoal,
resumeGoal,
setGoal,
} from 'src/services/goal/goalState.js';
import { persistCurrentGoal, persistGoalClear } from 'src/services/goal/goalStorage.js';
import type { LocalJSXCommandOnDone } from 'src/types/command.js';
import { removeByFilter } from 'src/utils/messageQueueManager.js';
import { GoalReplaceConfirmDialog } from './GoalReplaceConfirmDialog.js';
const MAX_OBJECTIVE_CHARS = 4000;
const MAX_DISPLAY_CHARS = 80;
function truncateForDisplay(objective: string): string {
const firstLine = objective.split('\n')[0] ?? objective;
if (firstLine.length <= MAX_DISPLAY_CHARS) return firstLine;
return firstLine.slice(0, MAX_DISPLAY_CHARS) + '…';
}
function drainGoalContinuationQueue(): void {
removeByFilter(cmd => cmd.origin === 'goal-continuation' || cmd.origin === 'goal-budget-limit');
}
function formatGoalStatus(): string {
const goal = getGoal();
if (!goal) {
return 'No active goal. Set one with `/goal <objective>`.';
}
const tokens = goal.tokenBudget !== null ? `${goal.tokensUsed} / ${goal.tokenBudget}` : `${goal.tokensUsed}`;
const lines = [
`Goal: ${goal.objective}`,
`Status: ${formatGoalStatusLabel(goal.status)}`,
`Time: ${formatGoalElapsed(goal)}`,
`Tokens: ${tokens}`,
`Continuation turns: ${goal.turnsExecuted}`,
];
if (goal.status === 'max_turns') {
lines.push(
`Hint: Max continuation turns reached (${MAX_GOAL_TURNS}). Run \`/goal continue\` to reset and continue.`,
);
}
return lines.join('\n');
}
function applySetGoal(objective: string): string {
setGoal(objective);
incrementGoalTurns();
persistCurrentGoal();
return 'Goal set.';
}
export async function call(
onDone: LocalJSXCommandOnDone,
_context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const trimmed = args.trim();
if (!trimmed || trimmed.toLowerCase() === 'status') {
onDone(formatGoalStatus(), { display: 'system' });
return null;
}
const lower = trimmed.toLowerCase();
if (lower === 'clear') {
const cleared = clearGoal();
if (cleared) {
persistGoalClear();
drainGoalContinuationQueue();
}
onDone(cleared ? 'Goal cleared.' : 'No active goal to clear.', {
display: 'system',
});
return null;
}
if (lower === 'pause') {
const g = pauseGoal();
if (g) {
persistCurrentGoal();
drainGoalContinuationQueue();
}
onDone(g ? 'Goal paused.' : 'No active goal to pause.', {
display: 'system',
});
return null;
}
if (lower === 'resume') {
const current = getGoal();
if (current?.status === 'max_turns') {
onDone(
`Goal reached max continuation turns (${MAX_GOAL_TURNS}). Run \`/goal continue\` to reset turn counter and continue.`,
{ display: 'system' },
);
return null;
}
const g = resumeGoal();
if (g) persistCurrentGoal();
onDone(g ? 'Goal resumed.' : 'No paused goal to resume.', {
display: 'system',
shouldQuery: Boolean(g),
});
return null;
}
if (lower === 'continue') {
const g = continueGoalFromMaxTurns();
if (g) persistCurrentGoal();
onDone(
g
? `Goal continuation counter reset (0/${MAX_GOAL_TURNS}). Continuing...`
: 'Current goal is not in max-turns state.',
{
display: 'system',
shouldQuery: Boolean(g),
},
);
return null;
}
if (lower === 'complete') {
const g = completeGoal();
if (g) {
persistCurrentGoal();
drainGoalContinuationQueue();
}
onDone(g ? 'Goal marked complete.' : 'No active goal to complete.', {
display: 'system',
});
return null;
}
if (trimmed.length > MAX_OBJECTIVE_CHARS) {
onDone(
`Goal objective is too long (${trimmed.length} chars; limit ${MAX_OBJECTIVE_CHARS}). Save the detailed instructions to a file and reference it from a shorter objective.`,
{ display: 'system' },
);
return null;
}
const existing = getGoal();
const needsConfirmation = existing && existing.status !== 'complete';
if (!needsConfirmation) {
const summary = applySetGoal(trimmed);
onDone(summary, {
display: 'system',
shouldQuery: true,
displayArgs: truncateForDisplay(trimmed),
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
});
return null;
}
return (
<GoalReplaceConfirmDialog
currentGoal={existing}
newObjective={trimmed}
onConfirm={() => {
drainGoalContinuationQueue();
const summary = applySetGoal(trimmed);
onDone(summary, {
display: 'system',
shouldQuery: true,
displayArgs: truncateForDisplay(trimmed),
metaMessages: [`<goal-objective-updated>\n${trimmed}\n</goal-objective-updated>`],
});
}}
onCancel={() => {
onDone('Kept the current goal. New objective discarded.', {
display: 'system',
});
}}
/>
);
}

View File

@@ -0,0 +1,13 @@
import type { Command } from 'src/commands.js'
const goal = {
type: 'local-jsx',
name: 'goal',
description:
'Set or view a persistent goal that drives auto-continuation across turns',
argumentHint: '[<objective> | status | clear | pause | resume | complete]',
bridgeSafe: false,
load: () => import('./goal.js'),
} satisfies Command
export default goal

View File

@@ -0,0 +1,10 @@
import type { Command } from '../../commands.js'
const webTools = {
type: 'local-jsx',
name: 'web-tools',
description: 'Configure web search and web fetch backends',
load: () => import('./web-tools.js'),
} satisfies Command
export default webTools

View File

@@ -0,0 +1,578 @@
import * as React from 'react';
import { useCallback, useState } from 'react';
import { Box, Text, Tabs, Tab, useInput } from '@anthropic/ink';
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { useIsInsideModal } from '../../context/modalContext.js';
import { getSettings_DEPRECATED, updateSettingsForSource } from '../../utils/settings/settings.js';
import type { LocalJSXCommandCall, LocalJSXCommandContext } from '../../types/command.js';
// ── Types ──────────────────────────────────────────────────────────────────
type SearchAdapterKey = 'tavily' | 'api' | 'bing' | 'brave' | 'exa';
type FetchAdapterKey = 'tavily' | 'http';
interface AdapterMeta {
key: SearchAdapterKey | FetchAdapterKey;
label: string;
description: string;
hasConfig: boolean;
}
type SettingsJson = Record<string, unknown> & {
webSearchAdapter?: 'api' | 'bing' | 'brave' | 'exa' | 'tavily';
webFetchAdapter?: 'tavily' | 'http';
tavilyEndpointUrl?: string;
braveApiKey?: string;
webFetchHttpTimeoutMs?: number;
exaApiKey?: string;
exaEndpointUrl?: string;
};
type ViewState = { kind: 'main' } | { kind: 'config'; adapter: AdapterMeta };
// ── Data ───────────────────────────────────────────────────────────────────
const SEARCH_ADAPTERS: AdapterMeta[] = [
{ key: 'tavily', label: 'Tavily', description: 'Tavily Search API (default)', hasConfig: true },
{ key: 'api', label: 'Anthropic API', description: 'Anthropic server-side web search', hasConfig: false },
{ key: 'bing', label: 'Bing', description: 'Scrape Bing HTML results', hasConfig: false },
{ key: 'brave', label: 'Brave', description: 'Brave Search API (needs API key)', hasConfig: true },
{ key: 'exa', label: 'Exa', description: 'Exa AI search (MCP endpoint)', hasConfig: true },
];
const FETCH_ADAPTERS: AdapterMeta[] = [
{ key: 'tavily', label: 'Tavily Extract', description: 'Use Tavily /extract (default)', hasConfig: true },
{ key: 'http', label: 'HTTP Direct', description: 'Fetch URL directly via HTTP', hasConfig: true },
];
// ── Config field definitions ───────────────────────────────────────────────
type ConfigField = {
key: string;
label: string;
placeholder: string;
maskInput: boolean;
getValue: (s: SettingsJson) => string;
setValue: (s: SettingsJson, v: string) => SettingsJson;
};
// ── Main View ──────────────────────────────────────────────────────────────
function MainView({
tab,
adapters,
current,
fieldLabel,
onConfigure,
onSwitchTab,
onSelectAdapter,
onClose,
contentHeight,
}: {
tab: 'search' | 'fetch';
adapters: AdapterMeta[];
current: string;
fieldLabel: string;
onConfigure: (adapter: AdapterMeta) => void;
onSwitchTab: (tab: 'search' | 'fetch') => void;
onSelectAdapter: (key: string) => void;
onClose: () => void;
contentHeight: number;
}): React.ReactNode {
const [cursor, setCursor] = useState(
Math.max(
0,
adapters.findIndex(a => a.key === current),
),
);
useInput((input, key) => {
if (key.upArrow) {
setCursor(c => Math.max(0, c - 1));
} else if (key.downArrow) {
setCursor(c => Math.min(c + 1, adapters.length - 1));
} else if (key.tab && tab === 'search') {
onSwitchTab('fetch');
setCursor(0);
} else if (key.tab && tab === 'fetch') {
onSwitchTab('search');
setCursor(0);
} else if (key.escape) {
onClose();
} else if (key.return) {
const adapter = adapters[cursor];
if (adapter) {
onConfigure(adapter);
}
}
// Space toggles selection without entering config
else if (input === ' ') {
const adapter = adapters[cursor];
if (adapter) {
onSelectAdapter(adapter.key);
}
}
});
return (
<Box flexDirection="column" padding={1}>
<Text bold>{fieldLabel}</Text>
<Box flexDirection="column" marginTop={1}>
{adapters.map((adapter, idx) => {
const isSelected = adapter.key === current;
const isCursor = idx === cursor;
const highlight = isCursor || isSelected;
return (
<Box key={adapter.key} flexDirection="row">
<Text color={isSelected ? 'success' : undefined}>
{isCursor ? '' : ' '}
<Text color={isSelected ? 'success' : undefined}> {isSelected ? '\u25CF' : '\u25CB'} </Text>
</Text>
<Text
bold={isSelected}
backgroundColor={highlight ? 'suggestion' : undefined}
color={highlight ? 'inverseText' : undefined}
>
{adapter.label}
</Text>
<Text> </Text>
<Text dimColor={!isSelected}>{adapter.description}</Text>
</Box>
);
})}
</Box>
<Box marginTop={1} flexDirection="row" gap={2}>
<Text dimColor>{'\u2191\u2193'} navigate · Space select · Enter config · Esc close</Text>
<Text dimColor>Tab switch tab</Text>
</Box>
</Box>
);
}
// ── Config View ────────────────────────────────────────────────────────────
function getConfigFields(adapter: AdapterMeta): ConfigField[] {
const fields: ConfigField[] = [];
switch (adapter.key) {
case 'tavily':
fields.push({
key: 'tavilyEndpointUrl',
label: 'Endpoint URL',
placeholder: 'https://tavily.claude-code-best.win',
maskInput: false,
getValue: s => s.tavilyEndpointUrl ?? 'https://tavily.claude-code-best.win',
setValue: (s, v) => ({ ...s, tavilyEndpointUrl: v || undefined }),
});
break;
case 'brave':
fields.push({
key: 'braveApiKey',
label: 'API Key',
placeholder: 'BSA...',
maskInput: true,
getValue: s => s.braveApiKey ?? '',
setValue: (s, v) => ({ ...s, braveApiKey: v || undefined }),
});
break;
case 'exa':
fields.push({
key: 'exaApiKey',
label: 'API Key',
placeholder: 'exa-...',
maskInput: true,
getValue: s => s.exaApiKey ?? '',
setValue: (s, v) => ({ ...s, exaApiKey: v || undefined }),
});
fields.push({
key: 'exaEndpointUrl',
label: 'Endpoint URL',
placeholder: 'https://mcp.exa.ai/mcp',
maskInput: false,
getValue: s => s.exaEndpointUrl ?? 'https://mcp.exa.ai/mcp',
setValue: (s, v) => ({ ...s, exaEndpointUrl: v || undefined }),
});
break;
case 'http':
fields.push({
key: 'webFetchHttpTimeoutMs',
label: 'Timeout (ms)',
placeholder: '60000',
maskInput: false,
getValue: s => String(s.webFetchHttpTimeoutMs ?? 60000),
setValue: (s, v) => ({ ...s, webFetchHttpTimeoutMs: v ? Number(v) || undefined : undefined }),
});
break;
default:
break;
}
return fields;
}
function ConfigView({
adapter,
onBack,
onSave,
onSelect,
}: {
adapter: AdapterMeta;
onBack: () => void;
onSave: (msg: string) => void;
onSelect: (msg: string) => void;
}): React.ReactNode {
const fields = getConfigFields(adapter);
const settings = getSettings_DEPRECATED() as unknown as SettingsJson;
if (fields.length === 0) {
return <NoConfigView adapter={adapter} onBack={onBack} onSelect={onSelect} />;
}
return <ConfigFieldsEditor fields={fields} adapter={adapter} onBack={onBack} onSave={onSave} settings={settings} />;
}
function NoConfigView({
adapter,
onBack,
onSelect,
}: {
adapter: AdapterMeta;
onBack: () => void;
onSelect: (msg: string) => void;
}): React.ReactNode {
const [cursor, setCursor] = useState(0);
useInput((input, key) => {
if (key.upArrow || key.downArrow) {
setCursor(c => (c === 0 ? 1 : 0));
} else if (key.escape) {
onBack();
} else if (key.return) {
if (cursor === 0) {
onSelect(`Selected ${adapter.label}.`);
} else {
onBack();
}
}
});
return (
<Box flexDirection="column" padding={1}>
<Text bold>{adapter.label}</Text>
<Box flexDirection="column" marginTop={1}>
<Text>{adapter.description}</Text>
<Box marginTop={1}>
<Text dimColor>No additional configuration needed.</Text>
</Box>
</Box>
<Box flexDirection="column" marginTop={1}>
<Box>
<Text>{cursor === 0 ? '\u203A' : ' '} </Text>
<Text
backgroundColor={cursor === 0 ? 'suggestion' : undefined}
color={cursor === 0 ? 'inverseText' : undefined}
bold
>
[ Select & Close ]
</Text>
</Box>
<Box>
<Text>{cursor === 1 ? '\u203A' : ' '} </Text>
<Text
backgroundColor={cursor === 1 ? 'suggestion' : undefined}
color={cursor === 1 ? 'inverseText' : undefined}
>
[ Back ]
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text dimColor>{'\u2191\u2193'} navigate · Enter confirm · Esc back</Text>
</Box>
</Box>
);
}
function ConfigFieldsEditor({
fields,
adapter,
onBack,
onSave,
settings,
}: {
fields: ConfigField[];
adapter: AdapterMeta;
onBack: () => void;
onSave: (msg: string) => void;
settings: SettingsJson;
}): React.ReactNode {
const [cursor, setCursor] = useState(0);
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const [editCursor, setEditCursor] = useState(0);
// Reset edit state when field cursor changes
const resetEdit = useCallback(() => {
setEditing(false);
setEditValue('');
setEditCursor(0);
}, []);
// Row count: fields + "Save" button + "Back" button
const fieldRowStart = 0;
const fieldRowEnd = fields.length - 1;
const saveRow = fields.length;
const backRow = fields.length + 1;
const handleSave = useCallback(() => {
let updated: SettingsJson = { ...settings } as SettingsJson;
for (const f of fields) {
const currentVal = f.getValue(settings);
updated = f.setValue(updated, currentVal);
}
updateSettingsForSource('userSettings', updated as Record<string, unknown> & SettingsJson);
onSave(`Configuration saved for ${adapter.label}.`);
}, [fields, settings, adapter.label, onSave]);
const handleFieldEdit = useCallback(() => {
const field = fields[cursor];
if (!field) return;
const currentVal = field.getValue(settings);
setEditValue(currentVal);
setEditCursor(currentVal.length);
setEditing(true);
}, [cursor, fields, settings]);
const handleEditSubmit = useCallback(() => {
const field = fields[cursor];
if (!field) return;
const updated = field.setValue({ ...settings } as SettingsJson, editValue);
// Store locally for preview, actual save on "Save"
Object.assign(settings, updated);
setEditing(false);
}, [cursor, fields, settings, editValue]);
useInput((input, key) => {
if (editing) {
// In edit mode, all typing goes to the field value
if (key.escape) {
resetEdit();
} else if (key.return) {
handleEditSubmit();
} else if (key.backspace || key.delete) {
setEditValue((v: string) => {
const pos = editCursor;
if (pos > 0) {
setEditCursor(pos - 1);
return v.slice(0, pos - 1) + v.slice(pos);
}
return v;
});
} else if (key.leftArrow) {
setEditCursor(c => Math.max(0, c - 1));
} else if (key.rightArrow) {
setEditCursor(c => Math.min(editValue.length, c + 1));
} else if (input && input.length === 1 && !key.ctrl && !key.meta) {
setEditValue((v: string) => {
const pos = editCursor;
setEditCursor(pos + 1);
return v.slice(0, pos) + input + v.slice(pos);
});
}
} else {
// Not editing — navigate fields
if (key.upArrow) {
setCursor(c => Math.max(0, c - 1));
} else if (key.downArrow) {
setCursor(c => Math.min(backRow, c + 1));
} else if (key.escape) {
onBack();
} else if (key.return) {
if (cursor === saveRow) {
handleSave();
} else if (cursor === backRow) {
onBack();
} else {
handleFieldEdit();
}
}
}
});
return (
<Box flexDirection="column" padding={1}>
<Text bold>{adapter.label} Configuration</Text>
<Box flexDirection="column" marginTop={1}>
{fields.map((field, idx) => {
const isCursor = idx === cursor && !editing;
const val = field.getValue(settings);
const displayVal =
editing && idx === cursor
? field.maskInput
? '\u2022'.repeat(editValue.length)
: editValue
: field.maskInput && val
? '\u2022'.repeat(Math.min(val.length, 16))
: val;
return (
<Box key={field.key} flexDirection="row">
<Text>{isCursor ? '' : ' '} </Text>
<Text dimColor>{field.label}: </Text>
<Text
backgroundColor={isCursor ? 'suggestion' : undefined}
color={editing && idx === cursor ? 'success' : isCursor ? 'inverseText' : undefined}
>
{displayVal || <Text dimColor>(empty)</Text>}
</Text>
{editing && idx === cursor && (
<Text dimColor>
{' |'} pos {editCursor}/{editValue.length}
</Text>
)}
</Box>
);
})}
<Box marginTop={1}>
<Text>{cursor === saveRow ? '' : ' '} </Text>
<Text
backgroundColor={cursor === saveRow ? 'suggestion' : undefined}
color={cursor === saveRow ? 'inverseText' : undefined}
bold
>
[ Save ]
</Text>
</Box>
<Box>
<Text>{cursor === backRow ? '' : ' '} </Text>
<Text
backgroundColor={cursor === backRow ? 'suggestion' : undefined}
color={cursor === backRow ? 'inverseText' : undefined}
>
[ Back ]
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text dimColor>
{editing
? '\u2190\u2192 move cursor · Type to edit · Enter confirm · Esc cancel edit'
: '\u2191\u2193 navigate · Enter edit field · Esc go back'}
</Text>
</Box>
</Box>
);
}
// ── Top-level panel ────────────────────────────────────────────────────────
function WebToolsPanel({
onClose,
_context: __context,
}: {
onClose: (result?: string) => void;
_context: LocalJSXCommandContext;
}): React.ReactNode {
const [currentTab, setCurrentTab] = useState<'search' | 'fetch'>('search');
const [view, setView] = useState<ViewState>({ kind: 'main' });
const settings = getSettings_DEPRECATED() as unknown as SettingsJson;
const currentSearch = settings.webSearchAdapter ?? 'tavily';
const currentFetch = settings.webFetchAdapter ?? 'tavily';
const insideModal = useIsInsideModal();
const { rows } = useTerminalSize();
const contentHeight = insideModal ? rows + 1 : Math.max(14, Math.min(Math.floor(rows * 0.7), 24));
useExitOnCtrlCDWithKeybindings();
const handleSelectAdapter = useCallback(
(key: string) => {
const t = currentTab;
const field = t === 'search' ? 'webSearchAdapter' : ('webFetchAdapter' as keyof SettingsJson);
updateSettingsForSource('userSettings', { [field]: key } as SettingsJson);
const adapters = t === 'search' ? SEARCH_ADAPTERS : FETCH_ADAPTERS;
const label = adapters.find(a => a.key === key)?.label ?? key;
onClose(`${t === 'search' ? 'Web search' : 'Web fetch'} backend set to ${label}.`);
},
[currentTab, onClose],
);
const handleConfigure = useCallback((adapter: AdapterMeta) => {
setView({ kind: 'config', adapter });
}, []);
const handleBackFromConfig = useCallback(() => {
setView({ kind: 'main' });
}, []);
const handleSaveConfig = useCallback(
(msg: string) => {
onClose(msg);
},
[onClose],
);
const handleSelectFromConfig = useCallback(
(msg: string) => {
// Also save the adapter selection when coming from config detail
const adapter = (view as Extract<ViewState, { kind: 'config' }>).adapter;
const tab =
view.kind === 'config' ? (SEARCH_ADAPTERS.some(a => a.key === adapter.key) ? 'search' : 'fetch') : currentTab;
const field = tab === 'search' ? ('webSearchAdapter' as const) : ('webFetchAdapter' as const);
updateSettingsForSource('userSettings', { [field]: adapter.key } as SettingsJson);
onClose(msg);
},
[onClose, view, currentTab],
);
if (view.kind === 'config') {
return (
<ConfigView
adapter={view.adapter}
onBack={handleBackFromConfig}
onSave={handleSaveConfig}
onSelect={handleSelectFromConfig}
/>
);
}
// Main view with tabs
const adapters = currentTab === 'search' ? SEARCH_ADAPTERS : FETCH_ADAPTERS;
const current = currentTab === 'search' ? currentSearch : currentFetch;
return (
<Tabs title="Web Tools" contentHeight={contentHeight}>
<Tab key="search" title="Search">
<MainView
tab={currentTab}
adapters={SEARCH_ADAPTERS}
current={currentSearch}
fieldLabel="Choose a web search backend:"
onConfigure={handleConfigure}
onSwitchTab={setCurrentTab}
onSelectAdapter={handleSelectAdapter}
onClose={() => onClose('Web tools panel dismissed')}
contentHeight={contentHeight}
/>
</Tab>
<Tab key="fetch" title="Fetch">
<MainView
tab={currentTab}
adapters={FETCH_ADAPTERS}
current={currentFetch}
fieldLabel="Choose a web fetch backend:"
onConfigure={handleConfigure}
onSwitchTab={setCurrentTab}
onSelectAdapter={handleSelectAdapter}
onClose={() => onClose('Web tools panel dismissed')}
contentHeight={contentHeight}
/>
</Tab>
</Tabs>
);
}
export const call: LocalJSXCommandCall = async (onDone, context) => {
return <WebToolsPanel onClose={onDone} _context={context} />;
};

View File

@@ -1,28 +1,11 @@
import type { Command, LocalCommandCall } from '../../types/command.js' import type { Command } from '../../types/command.js'
import { getWorkflowCommands } from '@claude-code-best/builtin-tools/tools/WorkflowTool/createWorkflowCommand.js'
import { getCwd } from '../../utils/cwd.js'
const call: LocalCommandCall = async (_args, _context) => {
const commands = await getWorkflowCommands(getCwd())
if (commands.length === 0) {
return {
type: 'text',
value:
'No workflows found. Add workflow files to .claude/workflows/ (YAML or Markdown).',
}
}
const list = commands
.map(cmd => ` /${cmd.name} - ${cmd.description}`)
.join('\n')
return { type: 'text', value: `Available workflows:\n${list}` }
}
const workflows = { const workflows = {
type: 'local', type: 'local-jsx',
name: 'workflows', name: 'workflows',
description: 'List available workflow scripts', description: 'Workflow 监控面板:实时 run/phase/agent 进度,键盘控制',
supportsNonInteractive: true, // 延迟加载面板实现,避免启动时拉入 Ink/React 依赖。
load: () => Promise.resolve({ call }), load: () => import('../../workflow/panel/panelCall.js'),
} satisfies Command } satisfies Command
export default workflows export default workflows

View File

@@ -19,6 +19,7 @@ import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js';
import { openBrowser } from '../utils/browser.js'; import { openBrowser } from '../utils/browser.js';
import { logError } from '../utils/log.js'; import { logError } from '../utils/log.js';
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js';
import { CHINA_LLM_PROVIDERS, type ProviderPreset, resolveChinaProviderBaseURL } from 'src/utils/chinaLlmProviders.js';
import { Select } from './CustomSelect/select.js'; import { Select } from './CustomSelect/select.js';
import { Spinner } from './Spinner.js'; import { Spinner } from './Spinner.js';
import TextInput from './TextInput.js'; import TextInput from './TextInput.js';
@@ -65,6 +66,10 @@ type OAuthStatus =
opusModel: string; opusModel: string;
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
} // Gemini Generate Content API platform } // Gemini Generate Content API platform
| { state: 'china_provider_select'; activeIndex: number } // China LLM: pick provider
| { state: 'china_mode_select'; provider: ProviderPreset; activeIndex: number } // China LLM: pick access mode
| { state: 'china_model_select'; provider: ProviderPreset; mode: 'api' | 'coding-plan'; activeIndex: number } // China LLM: pick model
| { state: 'china_apikey'; provider: ProviderPreset; mode: 'api' | 'coding-plan'; modelId: string; apiKey: string } // China LLM: enter API key
| { state: 'ready_to_start' } // Flow started, waiting for browser to open | { state: 'ready_to_start' } // Flow started, waiting for browser to open
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login | { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
| { state: 'creating_api_key' } // Got access token, creating API key | { state: 'creating_api_key' } // Got access token, creating API key
@@ -457,6 +462,15 @@ function OAuthStatusMessage({
), ),
value: 'openai_chat_api', value: 'openai_chat_api',
}, },
{
label: (
<Text>
China LLM Providers · <Text dimColor>DeepSeek, Zhipu GLM, Qwen, MiMo</Text>
{'\n'}
</Text>
),
value: 'china_providers',
},
{ {
label: ( label: (
<Text> <Text>
@@ -536,6 +550,9 @@ function OAuthStatusMessage({
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '', opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
activeField: 'base_url', activeField: 'base_url',
}); });
} else if (value === 'china_providers') {
logEvent('tengu_china_providers_selected', {});
setOAuthStatus({ state: 'china_provider_select', activeIndex: 0 });
} else if (value === 'chatgpt_subscription') { } else if (value === 'chatgpt_subscription') {
logEvent('tengu_chatgpt_subscription_selected', {}); logEvent('tengu_chatgpt_subscription_selected', {});
setOAuthStatus({ setOAuthStatus({
@@ -1274,6 +1291,274 @@ function OAuthStatusMessage({
); );
} }
case 'china_provider_select': {
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>Select China LLM Provider</Text>
<Text dimColor>Direct connection, no proxy needed. All providers are OpenAI-compatible.</Text>
<Box>
<Select
options={CHINA_LLM_PROVIDERS.map(p => ({
label: (
<Text>
{p.icon} {p.label} · <Text dimColor>{p.description}</Text>
{'\n'}
</Text>
),
value: p.id,
}))}
onChange={value => {
const provider = CHINA_LLM_PROVIDERS.find(p => p.id === value);
if (!provider) return;
logEvent('tengu_china_provider_selected', {});
if (provider.codingPlan) {
setOAuthStatus({ state: 'china_mode_select', provider, activeIndex: 0 });
} else {
setOAuthStatus({ state: 'china_model_select', provider, mode: 'api', activeIndex: 0 });
}
}}
/>
</Box>
</Box>
);
}
case 'china_mode_select': {
const { provider } = oauthStatus;
const modeOptions = [
{ id: 'api' as const, label: 'Pay-as-you-go (API)', desc: 'Top up freely, pay per use' },
{ id: 'coding-plan' as const, label: 'Coding Plan', desc: 'Fixed monthly fee, high usage' },
];
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>
{provider.icon} {provider.label} Select Access Mode
</Text>
<Box>
<Select
options={modeOptions.map(m => ({
label: (
<Text>
{m.label} · <Text dimColor>{m.desc}</Text>
{'\n'}
</Text>
),
value: m.id,
}))}
onChange={value => {
logEvent('tengu_china_mode_selected', {});
setOAuthStatus({
state: 'china_model_select',
provider,
mode: value as 'api' | 'coding-plan',
activeIndex: 0,
});
}}
/>
</Box>
<Text dimColor>
No plan? Select "Pay-as-you-go"
{provider.id === 'zhipu' ? ' · GLM-4.7-Flash is free forever' : ''}
</Text>
</Box>
);
}
case 'china_model_select': {
const { provider, mode: accessMode } = oauthStatus;
const models = provider.models;
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>
{provider.icon} {provider.label} Select Model
</Text>
<Box>
<Select
options={[
...models.map(m => {
const priceLabel =
m.inputPricePerMTok === 0 && m.outputPricePerMTok === 0
? 'Free'
: `¥${m.inputPricePerMTok}${m.outputPricePerMTok}`;
const tagLabel = m.tags?.length ? ` [${m.tags.join(', ')}]` : '';
return {
label: (
<Text>
{m.label} ·{' '}
<Text dimColor>
{priceLabel} · {m.contextWindow}
{tagLabel}
</Text>
{'\n'}
</Text>
),
value: m.id,
};
}),
{
label: (
<Text>
Custom model
<Text dimColor> · enter model name manually</Text>
{'\n'}
</Text>
),
value: '__custom__',
},
]}
onChange={value => {
logEvent('tengu_china_model_selected', {});
setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: value, apiKey: '' });
}}
/>
</Box>
</Box>
);
}
case 'china_apikey': {
const { provider, mode: accessMode, modelId } = oauthStatus;
const [chinaKeyValue, setChinaKeyValue] = useState('');
const [chinaKeyCursor, setChinaKeyCursor] = useState(0);
const [chinaKeyError, setChinaKeyError] = useState<string | null>(null);
const doChinaSave = useCallback(() => {
const effectiveModelId = modelId === '__custom__' ? chinaKeyValue.trim() : modelId;
if (!effectiveModelId) {
setChinaKeyError(modelId === '__custom__' ? 'Please enter a model name' : 'Please enter an API key');
return;
}
if (modelId === '__custom__') {
logEvent('tengu_china_custom_model_entered', {});
setOAuthStatus({ state: 'china_apikey', provider, mode: accessMode, modelId: effectiveModelId, apiKey: '' });
setChinaKeyValue('');
setChinaKeyError(null);
return;
}
if (!chinaKeyValue.trim()) {
setChinaKeyError('Please enter an API key');
return;
}
const baseUrl = resolveChinaProviderBaseURL(provider.id, accessMode);
const env: Record<string, string | undefined> = {
OPENAI_AUTH_MODE: undefined,
OPENAI_BASE_URL: baseUrl,
OPENAI_API_KEY: chinaKeyValue.trim(),
OPENAI_DEFAULT_SONNET_MODEL: modelId,
OPENAI_DEFAULT_HAIKU_MODEL: modelId,
OPENAI_DEFAULT_OPUS_MODEL: modelId,
};
const settingsUpdate: Parameters<typeof updateSettingsForSource>[1] = {
modelType: 'openai',
env: env as unknown as Record<string, string>,
};
const { error } = updateSettingsForSource('userSettings', settingsUpdate);
if (error) {
setOAuthStatus({
state: 'error',
message: 'Failed to save settings. Please try again.',
toRetry: { state: 'china_apikey', provider, mode: accessMode, modelId, apiKey: chinaKeyValue },
});
} else {
for (const [k, v] of Object.entries(env)) {
if (v === undefined) {
delete process.env[k];
} else {
process.env[k] = v;
}
}
logEvent('tengu_china_login_success', {});
setOAuthStatus({ state: 'success' });
void onDone();
}
}, [chinaKeyValue, provider, accessMode, modelId, onDone, setOAuthStatus]);
useKeybinding(
'confirm:no',
() => {
setOAuthStatus({ state: 'china_model_select', provider, mode: accessMode, activeIndex: 0 });
},
{ context: 'Confirmation' },
);
const isCustomModelEntry = modelId === '__custom__';
const allModels = CHINA_LLM_PROVIDERS.flatMap(p =>
p.models.map(m => ({ id: m.id, label: m.label, provider: p.label })),
);
const modelSuggestions = isCustomModelEntry
? chinaKeyValue.trim()
? allModels.filter(m => m.id.toLowerCase().includes(chinaKeyValue.trim().toLowerCase()))
: allModels
: [];
const keyPage = isCustomModelEntry
? provider.apiKeyPage
: accessMode === 'coding-plan' && provider.codingPlan
? provider.codingPlan.purchasePage
: provider.apiKeyPage;
const keyFormat = isCustomModelEntry
? provider.keyFormat
: accessMode === 'coding-plan' && provider.codingPlan
? provider.codingPlan.keyFormat
: provider.keyFormat;
return (
<Box flexDirection="column" gap={1} marginTop={1}>
<Text bold>
{provider.icon} {provider.label} {isCustomModelEntry ? '— Custom Model' : 'API Key'}
</Text>
<Box flexDirection="column" gap={0}>
{isCustomModelEntry ? (
<Text dimColor> Enter any model ID supported by this provider. Browse models: {provider.modelsPage}</Text>
) : (
<>
<Text dimColor> Get your key: {keyPage}</Text>
<Text dimColor>
{' '}
{accessMode === 'coding-plan' ? 'Use your Coding Plan credential here' : provider.freeTier}
</Text>
<Text dimColor> Key format: {keyFormat}</Text>
</>
)}
</Box>
<Box>
<Text>{isCustomModelEntry ? 'Model name: ' : 'API Key: '}</Text>
<TextInput
value={chinaKeyValue}
onChange={v => {
setChinaKeyValue(v);
setChinaKeyError(null);
}}
onSubmit={doChinaSave}
cursorOffset={chinaKeyCursor}
onChangeCursorOffset={setChinaKeyCursor}
columns={useTerminalSize().columns - 12}
mask={isCustomModelEntry ? undefined : '*'}
focus={true}
/>
</Box>
{chinaKeyError ? <Text color="error">{chinaKeyError}</Text> : null}
{isCustomModelEntry && modelSuggestions.length > 0 && (
<Box flexDirection="column" gap={0}>
<Text dimColor>{chinaKeyValue.trim() ? 'Matching models:' : 'Known models:'}</Text>
{modelSuggestions.map(m => (
<Text key={m.id} dimColor>
{' '}
{m.id}{' '}
<Text>
({m.label} {m.provider})
</Text>
</Text>
))}
</Box>
)}
<Text dimColor>
{isCustomModelEntry ? 'Enter to continue · Esc to go back' : 'Enter to confirm · Esc to go back'}
</Text>
</Box>
);
}
case 'platform_setup': case 'platform_setup':
return ( return (
<Box flexDirection="column" gap={1} marginTop={1}> <Box flexDirection="column" gap={1} marginTop={1}>

View File

@@ -0,0 +1,408 @@
import * as React from 'react';
import { BaseText, Box, Text, useTerminalSize } from '@anthropic/ink';
import { useKeybindings } from '../../keybindings/useKeybinding.js';
import { type EffortValue, getDisplayedEffortLevel, getEffortEnvOverride } from '../../utils/effort.js';
import {
type PanelPosition,
CANCEL_MESSAGE,
computeConfirmOutcome,
getInitialCursor,
moveLeft,
moveRight,
PANEL_POSITIONS,
} from './effortPanelState.js';
import { executeEffort } from '../../commands/effort/effort.js';
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
import { useSetAppState } from '../../state/AppState.js';
import { useRippleFrame } from './useRippleFrame.js';
import {
TRANSPARENT,
type Overlay,
type Segment,
applyOverlaysToCells,
cellsToSegments,
computeRippleCells,
fadeCells,
getHueShiftAtTime,
rotateHue,
} from './rippleAnimation.js';
/**
* 每档最小宽度(足够装下 'ultracode' 9 字符 + 居中留白)。
* 当终端窄时使用此值,保证最低可读性。
*/
const MIN_SEGMENT = 12;
const SUBLABEL_ULTRACODE = 'xhigh + workflows';
// 颜色与项目主题对齐suggestion=Medium blue #5769F7
const COLOR_LABEL_SELECTED = '#5769F7'; // 选中档位suggestion
const COLOR_LABEL_DEFAULT = '#7a8eff'; // 未选中档位(淡紫蓝,与波纹背景协调)
const COLOR_OVERLAY = '#5769F7'; // Faster / Smarter / ▲ 等 overlay 文字
// 淡入淡出每帧步长60ms 间隔下 5 帧达到目标 ≈ 300ms 动画时长。
const FADE_STEP = 0.2;
// 波纹震源 y 坐标相对波纹区域坐标系y=0 是档位名行)。
const RIPPLE_SOURCE_Y = 0;
/**
* 根据终端宽度计算每档实际宽度SEGMENT
*
* 规则:
* - 留出 paddingX={1} 的左右各 1 列 → 可用宽度 = columns - 2
* - 若可用宽度 <= MIN_SEGMENT * 672用 MIN_SEGMENT保持当前窄布局
* - 否则铺满floor(可用宽度 / 6)
*
* 即"窄则不变,宽则铺满"。最小宽度保证 'ultracode' 9 字符能正常显示。
*/
function computeSegment(terminalColumns: number): number {
const available = terminalColumns - 2; // paddingX={1} 两侧
const minNeeded = MIN_SEGMENT * PANEL_POSITIONS.length;
if (available <= minNeeded) return MIN_SEGMENT;
return Math.floor(available / PANEL_POSITIONS.length);
}
/**
* 计算波纹震源 x 坐标ultracode 段内 'ultracode' 标签的中心列)。
*
* 'ultracode' 是 9 字符,在 SEGMENT 列内居中:
* offset = floor((SEGMENT - 9) / 2)
* labelCenter = SEGMENT * 5 + offset + 4 4 是 9 字符串的中心偏移)
*
* SEGMENT=12 → 60 + 1 + 4 = 65与历史值一致
* SEGMENT=20 → 100 + 5 + 4 = 109
*/
function computeRippleSourceX(segment: number): number {
const LABEL_LEN = 9; // 'ultracode'
const offset = Math.max(0, Math.floor((segment - LABEL_LEN) / 2));
const labelCenter = Math.floor(LABEL_LEN / 2); // 4
return segment * (PANEL_POSITIONS.length - 1) + offset + labelCenter;
}
/**
* 计算某段 idx 内居中文字的起始列。
* 动态 segmenttextLen 字符在 segment 列内居中。
*/
function segmentTextStartX(idx: number, textLen: number, segment: number): number {
return segment * idx + Math.max(0, Math.floor((segment - textLen) / 2));
}
type Props = {
appStateEffort: EffortValue | undefined;
onDone: (message: string) => void;
};
export function EffortPanel({ appStateEffort, onDone }: Props): React.ReactNode {
const setAppState = useSetAppState();
const model = useMainLoopModel();
const { columns } = useTerminalSize();
// 自适应宽度:根据终端列数计算每档宽度。
// 终端变化resize时 columns 改变 → 重新计算 → 重渲染。
const segment = React.useMemo(() => computeSegment(columns), [columns]);
const panelWidth = segment * PANEL_POSITIONS.length;
const rippleSourceX = React.useMemo(() => computeRippleSourceX(segment), [segment]);
const envOverride = getEffortEnvOverride();
const displayed = getDisplayedEffortLevel(model, appStateEffort);
const initialCursor = getInitialCursor({ envOverride, appStateEffort, displayed });
const [cursor, setCursor] = React.useState<PanelPosition>(initialCursor);
const [done, setDone] = React.useState(false);
const isOnUltracode = cursor === 'ultracode';
const [fade, setFade] = React.useState(0);
// 仍在波纹模式cursor 在 ultracode或退出动画未结束fade > 0
const showingRipple = isOnUltracode || fade > 0.001;
const [rippleRef, time] = useRippleFrame(showingRipple);
// 淡入淡出驱动:每 ticktime 推进)朝目标步进 FADE_STEP。
// 退出动画完成后 fade 归零showingRipple 变 false时钟停止订阅。
React.useEffect(() => {
if (!showingRipple) return;
const target = isOnUltracode ? 1 : 0;
setFade(prev => {
if (prev === target) return prev;
const next = target > prev ? prev + FADE_STEP : prev - FADE_STEP;
return target > prev ? Math.min(target, next) : Math.max(target, next);
});
}, [time, isOnUltracode, showingRipple]);
const handleConfirm = React.useCallback(() => {
if (done) return;
setDone(true);
const outcome = computeConfirmOutcome(cursor, executeEffort);
if (outcome.kind === 'apply' && outcome.effortUpdate) {
setAppState(prev => ({
...prev,
effortValue: outcome.effortUpdate!.value,
}));
}
onDone(outcome.message);
}, [cursor, done, onDone, setAppState]);
const handleCancel = React.useCallback(() => {
if (done) return;
setDone(true);
onDone(CANCEL_MESSAGE);
}, [done, onDone]);
useKeybindings(
{
'effortPanel:decrease': () => setCursor(c => moveLeft(c)),
'effortPanel:increase': () => setCursor(c => moveRight(c)),
'effortPanel:home': () => setCursor('low'),
'effortPanel:end': () => setCursor('ultracode'),
'effortPanel:confirm': handleConfirm,
'effortPanel:cancel': handleCancel,
},
{ context: 'EffortPanel' },
);
const envActive = envOverride !== null && envOverride !== undefined;
const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL;
// 波纹行 cells 计算:返回该行所有 cell含 overlay 文字)
// fade 控制背景颜色亮度0 → 全 transparent1 → 完整波纹)。
// 文字 overlay 也乘以 fade让进入/退出动画整体淡入淡出。
const renderRippleRow = React.useCallback(
(relY: number, overlays: Overlay[]): Segment[] => {
const cells = computeRippleCells({
y: relY + RIPPLE_SOURCE_Y,
width: panelWidth,
time,
sourceX: rippleSourceX,
sourceY: RIPPLE_SOURCE_Y,
});
const overlayed = applyOverlaysToCells(cells, overlays);
const faded = fadeCells(overlayed, fade);
return cellsToSegments(faded);
},
[time, fade, panelWidth, rippleSourceX],
);
return (
<Box ref={rippleRef} flexDirection="column" paddingX={1} width={panelWidth + 2}>
<Text bold color="suggestion">
Effort
</Text>
{envActive && <Text color="warning">{`⚠ CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session`}</Text>}
{showingRipple ? (
<RippleContent
renderRow={renderRippleRow}
cursor={cursor}
fade={fade}
segment={segment}
panelWidth={panelWidth}
time={time}
/>
) : (
<>
<PlainContent cursor={cursor} segment={segment} panelWidth={panelWidth} />
<Box marginTop={1}>
<Text color="subtle">/ adjust · Enter confirm · Esc cancel</Text>
</Box>
</>
)}
</Box>
);
}
// ---- 普通模式(无波纹)----
function PlainContent({
cursor,
segment,
panelWidth,
}: {
cursor: PanelPosition;
segment: number;
panelWidth: number;
}): React.ReactNode {
return (
<>
<Box marginTop={1} flexDirection="row" justifyContent="space-between">
<Text color="suggestion">Faster</Text>
<Text color="suggestion">Smarter</Text>
</Box>
<Text color="subtle">{'─'.repeat(panelWidth)}</Text>
<Box flexDirection="row">
{PANEL_POSITIONS.map(p => (
<Box key={`cursor-${p}`} width={segment} justifyContent="center">
<Text bold color={cursor === p ? 'suggestion' : 'subtle'}>
{cursor === p ? '▲' : ' '}
</Text>
</Box>
))}
</Box>
<Box flexDirection="row">
{PANEL_POSITIONS.map(p => (
<Box key={`label-${p}`} width={segment} justifyContent="center">
<Text bold={cursor === p} color={cursor === p ? 'suggestion' : 'subtle'}>
{p}
</Text>
</Box>
))}
</Box>
<Box flexDirection="row">
<Box width={segment * (PANEL_POSITIONS.length - 1)} />
<Box width={segment} justifyContent="center">
<Text color="subtle">{SUBLABEL_ULTRACODE}</Text>
</Box>
</Box>
</>
);
}
// ---- 波纹模式cursor === 'ultracode'----
//
// 渲染策略:
// - 每行先 computeRippleCells 算出强度→颜色的 cell 数组(背景为空格 + 颜色)
// - applyOverlaysToCells 把文字 overlayFaster/▲/档位名/副标签)写入对应 cell
// - cellsToSegments 合并相邻同色段
// - 渲染层遍历 segments每个段判断是"空格波纹段"还是"文字段"
// - 空格段:用 backgroundColor 把空格染成色块pure color block
// - 文字段:用 color 染色文字(背景保持终端默认,让文字最清晰)
// - 混合段(既有空格又有文字,少见):拆为前后两个 Text
//
// 注意Segment 内可能同时有空格和非空格字符(如 " Faster " 居中文字)。
// 这种段用 color 渲染时,空格部分不显示色块——视觉上"色块断裂"。
// 解决:渲染时把 segment 按字符类型二次拆分runs of whitespace vs non-whitespace
type RippleContentProps = {
renderRow: (relY: number, overlays: Overlay[]) => Segment[];
cursor: PanelPosition;
fade: number;
segment: number;
panelWidth: number;
time: number;
};
function RippleContent({ renderRow, cursor, segment, panelWidth, time }: RippleContentProps): React.ReactNode {
// 光标索引跟随 cursor退出动画期间 cursor 已移到别处,
// 让 ▲ overlay 跟着移走ultracode 段恢复普通背景色)。
const cursorIdx = PANEL_POSITIONS.indexOf(cursor);
// 副标签固定在 ultracode 段下方,不跟随光标移动。
const ultracodeIdx = PANEL_POSITIONS.length - 1;
// 文字颜色跟随波浪色相旋转:取当前 time 的 hueShift
// 应用到所有 overlay 颜色,让文字与背景色环保持同步。
const hueShift = getHueShiftAtTime(time);
const overlayColor = rotateHue(COLOR_OVERLAY, hueShift);
const labelSelectedColor = rotateHue(COLOR_LABEL_SELECTED, hueShift);
const labelDefaultColor = rotateHue(COLOR_LABEL_DEFAULT, hueShift);
const fasterOverlay: Overlay = { text: 'Faster', x: 0, color: overlayColor };
const smarterOverlay: Overlay = {
text: 'Smarter',
x: panelWidth - 'Smarter'.length,
color: overlayColor,
};
const separatorOverlay: Overlay = {
text: '─'.repeat(panelWidth),
x: 0,
color: labelDefaultColor,
};
const cursorOverlay: Overlay = {
text: '▲',
x: segmentTextStartX(cursorIdx, 1, segment),
color: overlayColor,
};
const labelOverlays: Overlay[] = PANEL_POSITIONS.map((p, idx) => ({
text: p,
x: segmentTextStartX(idx, p.length, segment),
color: p === cursor ? labelSelectedColor : labelDefaultColor,
}));
const sublabelOverlay: Overlay = {
text: SUBLABEL_ULTRACODE,
x: segmentTextStartX(ultracodeIdx, SUBLABEL_ULTRACODE.length, segment),
color: labelDefaultColor,
};
// 各行 y 坐标(相对震源 RIPPLE_SOURCE_Y = 档位名行)
// y=-4: 顶部纯波纹行(视觉一致,无 overlay
// y=-3: Faster/Smarter
// y=-2: 分隔线
// y=-1: ▲
// y=0: 档位名(震源)
// y=1: 副标签
// y=2: 底部纯波纹行(视觉一致,无 overlay
//
// 快捷键行plain Text不参与波纹渲染无背景动画紧贴底部波纹行。
return (
<>
<RippleRow segments={renderRow(-4, [])} />
<RippleRow segments={renderRow(-3, [fasterOverlay, smarterOverlay])} />
<RippleRow segments={renderRow(-2, [separatorOverlay])} />
<RippleRow segments={renderRow(-1, [cursorOverlay])} />
<RippleRow segments={renderRow(0, labelOverlays)} />
<RippleRow segments={renderRow(1, [sublabelOverlay])} />
<RippleRow segments={renderRow(2, [])} />
<Text color={COLOR_LABEL_DEFAULT}>/ adjust · Enter confirm · Esc cancel</Text>
</>
);
}
/**
* 渲染一行波纹 segments。
*
* 每个 segment 可能含空格 + 文字混合(如 " Faster "
* - 空格部分用 backgroundColor 染色块(波纹颜色)
* - 文字部分用 color 染色(亮色,背景保持终端默认)
*
* 简化策略:遍历 segment 字符,按"是否为空格"二次拆分为 token。
* 相邻同类型 token 合并,避免 React key 爆炸。
*/
function RippleRow({ segments }: { segments: Segment[] }): React.ReactNode {
const tokens: Array<{ text: string; kind: 'space' | 'text'; color: string }> = [];
for (const seg of segments) {
// 拆分 seg.text 为空格段和非空格段
let buf = '';
let bufIsSpace: boolean | null = null;
const flush = (): void => {
if (buf === '' || bufIsSpace === null) return;
tokens.push({
text: buf,
kind: bufIsSpace ? 'space' : 'text',
color: seg.color,
});
buf = '';
bufIsSpace = null;
};
for (const ch of seg.text) {
const isSpace = ch === ' ';
if (bufIsSpace === null) {
buf = ch;
bufIsSpace = isSpace;
} else if (isSpace === bufIsSpace) {
buf += ch;
} else {
flush();
buf = ch;
bufIsSpace = isSpace;
}
}
flush();
}
return (
<Box flexDirection="row">
{tokens.map((tok, i) =>
tok.kind === 'space' ? (
tok.color === TRANSPARENT ? (
<BaseText key={i}>{tok.text}</BaseText>
) : (
<BaseText key={i} backgroundColor={tok.color as `#${string}`}>
{tok.text}
</BaseText>
)
) : (
<Text key={i} color={tok.color as `#${string}`} bold>
{tok.text}
</Text>
),
)}
</Box>
);
}

View File

@@ -0,0 +1,24 @@
import { expect, test } from 'bun:test';
import React from 'react';
import { EffortPanel } from '../EffortPanel.js';
// EffortPanel 是 UI 组件渲染依赖链useMainLoopModel / GrowthBook / settings
// 在测试环境模拟成本高且脆化。本文件只做"组件契约"sanity check
// 1) 默认导出为有效 React 组件
// 2) 接收正确 props 类型(编译期保证)
// 3) onDone 类型为 (message: string) => void
//
// 渲染输出与键盘交互通过 Step 6.2 手动验收覆盖;
// 确认/取消分支通过 computeConfirmOutcome 纯函数测试覆盖(见 effortPanelState.test.ts
test('EffortPanel 是有效 React 组件', () => {
expect(typeof EffortPanel).toBe('function');
});
test('EffortPanel 接受 props 并返回 React element不挂载', () => {
const element = React.createElement(EffortPanel, {
appStateEffort: undefined,
onDone: () => {},
});
expect(React.isValidElement(element)).toBe(true);
});

View File

@@ -0,0 +1,163 @@
import { describe, expect, test } from 'bun:test'
import type { EffortValue } from '../../../utils/effort.js'
import {
CANCEL_MESSAGE,
type ApplyFn,
ULTRACODE_HINT,
END_POSITION,
HOME_POSITION,
PANEL_POSITIONS,
type PanelPosition,
computeConfirmOutcome,
getInitialCursor,
isUltracode,
moveLeft,
moveRight,
} from '../effortPanelState.js'
describe('effortPanelState', () => {
test('PANEL_POSITIONS 顺序为 low → ultracode', () => {
expect(PANEL_POSITIONS).toEqual([
'low',
'medium',
'high',
'xhigh',
'max',
'ultracode',
])
})
test('moveLeft 在 low 处保持 low', () => {
expect(moveLeft('low')).toBe('low')
})
test('moveLeft 正常左移', () => {
expect(moveLeft('high')).toBe('medium')
expect(moveLeft('ultracode')).toBe('max')
})
test('moveRight 在 ultracode 处保持 ultracode', () => {
expect(moveRight('ultracode')).toBe('ultracode')
})
test('moveRight 正常右移', () => {
expect(moveRight('medium')).toBe('high')
expect(moveRight('max')).toBe('ultracode')
})
test('HOME_POSITION 等于 low', () => {
expect(HOME_POSITION).toBe('low')
})
test('END_POSITION 等于 ultracode', () => {
expect(END_POSITION).toBe('ultracode')
})
test('isUltracode 守卫', () => {
expect(isUltracode('ultracode')).toBe(true)
expect(isUltracode('max')).toBe(false)
})
test('getInitialCursorenv override 为合法档位时返回 env 值', () => {
expect(
getInitialCursor({
envOverride: 'high',
appStateEffort: 'medium',
displayed: 'high',
}),
).toBe('high')
})
test('getInitialCursorenv 为 nullunset时用 displayed', () => {
expect(
getInitialCursor({
envOverride: null,
appStateEffort: undefined,
displayed: 'medium',
}),
).toBe('medium')
})
test('getInitialCursorenv undefined 时用 displayed', () => {
expect(
getInitialCursor({
envOverride: undefined,
appStateEffort: 'high',
displayed: 'high',
}),
).toBe('high')
})
test('getInitialCursorenv 是数值ant-only时落回 displayed', () => {
// 数值不是合法 PanelPosition回退
expect(
getInitialCursor({
envOverride: 75,
appStateEffort: 'medium',
displayed: 'medium',
}),
).toBe('medium')
})
test('PanelPosition 类型编译期检查(隐式)', () => {
const p: PanelPosition = 'xhigh'
expect(p).toBe('xhigh')
})
})
describe('computeConfirmOutcome', () => {
const mockApply: ApplyFn = cursor => ({
message: `applied:${cursor}`,
// 测试里 cursor 是 PanelPosition含 ultracode但 ApplyFn 的契约要求 EffortValue。
// 实际运行时 mockApply 只会被 computeConfirmOutcome 在非 ultracode 档位调用,
// 因此 cast 是安全的。生产代码用真 executeEffort 不会出现 ultracode。
effortUpdate: { value: cursor as unknown as EffortValue },
})
test('ultracode → kind=ultracode-hint含 /ultracode 引导', () => {
const out = computeConfirmOutcome('ultracode', mockApply)
expect(out.kind).toBe('ultracode-hint')
if (out.kind === 'ultracode-hint') {
expect(out.message).toBe(ULTRACODE_HINT)
expect(out.message).toContain('/ultracode')
}
})
test('ultracode 不调 applyFn不会被副作用触发', () => {
let called = false
const spy: ApplyFn = c => {
called = true
return { message: `applied:${c}` }
}
computeConfirmOutcome('ultracode', spy)
expect(called).toBe(false)
})
test('low → kind=applymessage 来自 applyFneffortUpdate 透传', () => {
const out = computeConfirmOutcome('low', mockApply)
expect(out.kind).toBe('apply')
if (out.kind === 'apply') {
expect(out.message).toBe('applied:low')
expect(out.effortUpdate?.value).toBe('low')
}
})
test('high → apply 路径不调 ultracode 分支', () => {
const out = computeConfirmOutcome('high', mockApply)
expect(out.kind).toBe('apply')
})
test('applyFn 返回无 effortUpdate 时outcome.effortUpdate 为 undefined', () => {
const noUpdate: ApplyFn = c => ({ message: `applied:${c}` })
const out = computeConfirmOutcome('medium', noUpdate)
expect(out.kind).toBe('apply')
if (out.kind === 'apply') {
expect(out.effortUpdate).toBeUndefined()
}
})
})
test('常量字符串', () => {
expect(CANCEL_MESSAGE).toBe('Effort unchanged.')
expect(ULTRACODE_HINT).toContain('/ultracode <context>')
})

View File

@@ -0,0 +1,501 @@
import { describe, expect, test } from 'bun:test'
import {
type Cell,
type Overlay,
TRANSPARENT,
applyOverlaysToCells,
cellsToSegments,
computeRippleCells,
fadeCells,
fadeColor,
getHueShiftAtTime,
intensityToColor,
rotateHue,
} from '../rippleAnimation.js'
describe('intensityToColor', () => {
test('intensity=0 → 最暗档(不再是 transparent作面板底色', () => {
expect(intensityToColor(0)).toBe('#1a1f3a')
})
test('intensity < 0 钳到 0 → 最暗档', () => {
expect(intensityToColor(-0.5)).toBe('#1a1f3a')
})
test('intensity > 0 → 永远是 #hex 颜色字符串(不返回 transparent', () => {
for (const v of [0.05, 0.1, 0.2, 0.5, 0.8]) {
const c = intensityToColor(v)
expect(c).not.toBe(TRANSPARENT)
expect(c).toMatch(/^#[0-9a-fA-F]{6}$/)
}
})
test('intensity > 1 钳到 1 → 最高强度颜色', () => {
expect(intensityToColor(1.5)).toBe(intensityToColor(1))
})
test('intensity 单调递增 → 颜色档位递增(至少 3 档)', () => {
const samples = [0.2, 0.4, 0.6, 0.8, 1.0]
const colors = samples.map(intensityToColor)
const unique = new Set(colors)
expect(unique.size).toBeGreaterThanOrEqual(3)
})
test('intensity=1 → suggestion 档(波峰最高档)', () => {
expect(intensityToColor(1)).toBe('#5769F7')
})
test('hueShift=0 → 与无 hueShift 相同(快路径)', () => {
for (const v of [0, 0.2, 0.5, 0.8, 1]) {
expect(intensityToColor(v, 0)).toBe(intensityToColor(v))
}
})
test('hueShift ≠ 0 → 返回不同颜色(但仍是合法 hex', () => {
const base = intensityToColor(0.8)
const shifted = intensityToColor(0.8, 30)
expect(shifted).toMatch(/^#[0-9a-fA-F]{6}$/)
expect(shifted).not.toBe(base)
})
test('hueShift 180° → 大致补色(亮色变暗色族)', () => {
// #5769F7 ≈ HSL(233, 91, 65),旋转 180° → HSL(53, 91, 65) ≈ 黄色系
const shifted = intensityToColor(1, 180)
expect(shifted).toMatch(/^#[0-9a-fA-F]{6}$/)
// 不再是蓝紫族R 分量应明显大于 B 分量)
const r = parseInt(shifted.slice(1, 3), 16)
const b = parseInt(shifted.slice(5, 7), 16)
expect(r).toBeGreaterThan(b)
})
})
describe('rotateHue', () => {
test('hueShift=0 → 原样返回(快路径,无 round-trip 误差)', () => {
expect(rotateHue('#5769F7', 0)).toBe('#5769F7')
expect(rotateHue('#1a1f3a', 0)).toBe('#1a1f3a')
})
test('旋转 360° → 等同原色(一圈回起点,大小写无关)', () => {
expect(rotateHue('#5769F7', 360).toLowerCase()).toBe('#5769f7')
expect(rotateHue('#5769F7', -360).toLowerCase()).toBe('#5769f7')
})
test('旋转 ±n*360° → 等同原色(任意整圈)', () => {
expect(rotateHue('#3a4582', 720).toLowerCase()).toBe('#3a4582')
expect(rotateHue('#3a4582', -1080).toLowerCase()).toBe('#3a4582')
})
test('灰度色saturation=0旋转后不变', () => {
// #808080 = (128,128,128)saturation=0旋转无意义
expect(rotateHue('#808080', 90)).toBe('#808080')
})
test('非法 hex → 原样返回(防御式)', () => {
expect(rotateHue('not-a-color', 90)).toBe('not-a-color')
expect(rotateHue('#123', 90)).toBe('#123')
})
test('旋转后保持 6 位 hex 格式', () => {
const rotated = rotateHue('#5769F7', 45)
expect(rotated).toMatch(/^#[0-9a-fA-F]{6}$/)
})
})
describe('getHueShiftAtTime', () => {
test('time=0 → 0', () => {
expect(getHueShiftAtTime(0)).toBe(0)
})
test('time > 0 → 在 [0, 360) 范围内(连续旋转,非负)', () => {
for (const t of [100, 500, 1000, 2000, 5000, 10000, 50000, 100000]) {
const shift = getHueShiftAtTime(t)
expect(shift).toBeGreaterThanOrEqual(0)
expect(shift).toBeLessThan(360)
}
})
test('time 推进 → hueShift 单调递增(模 360', () => {
// 在一个周期内12000mshueShift 应单调递增
const samples = [0, 1000, 2000, 3000, 4000, 5000, 6000]
const shifts = samples.map(getHueShiftAtTime)
for (let i = 1; i < shifts.length; i++) {
expect(shifts[i]).toBeGreaterThan(shifts[i - 1])
}
})
test('周期 12000mstime=12000 应回到 0模 360', () => {
// 12000ms * 0.03 = 360% 360 = 0
const shift = getHueShiftAtTime(12000)
expect(shift).toBe(0)
})
test('半周期 6000ms → hueShift=180对面色相', () => {
// 6000ms * 0.03 = 180
expect(getHueShiftAtTime(6000)).toBe(180)
})
test('四分之一周期 3000ms → hueShift=90', () => {
expect(getHueShiftAtTime(3000)).toBe(90)
})
test('多周期循环time=24000 等同 time=0', () => {
expect(getHueShiftAtTime(24000)).toBe(0)
expect(getHueShiftAtTime(36000)).toBe(0)
})
})
describe('computeRippleCells', () => {
test('返回数组长度等于 width', () => {
const cells = computeRippleCells({
y: 2,
width: 30,
time: 100,
sourceX: 25,
sourceY: 2,
})
expect(cells.length).toBe(30)
})
test('每个 cell 的 char 是空格', () => {
const cells = computeRippleCells({
y: 0,
width: 10,
time: 0,
sourceX: 5,
sourceY: 0,
})
for (const cell of cells) {
expect(cell.char).toBe(' ')
}
})
test('每个 cell 的 color 是合法字符串', () => {
const cells = computeRippleCells({
y: 0,
width: 10,
time: 0,
sourceX: 5,
sourceY: 0,
})
for (const cell of cells) {
expect(typeof cell.color).toBe('string')
expect(
cell.color === TRANSPARENT || /^#[0-9a-fA-F]{6}$/.test(cell.color),
).toBe(true)
}
})
test('width=0 → 空数组', () => {
expect(
computeRippleCells({ y: 0, width: 0, time: 0, sourceX: 0, sourceY: 0 }),
).toEqual([])
})
test('width<0 → 空数组', () => {
expect(
computeRippleCells({ y: 0, width: -5, time: 0, sourceX: 0, sourceY: 0 }),
).toEqual([])
})
test('震源点 time=0 时为中间档((sin+1)/2 → intensity=0.5time 推进后扫过波峰/波谷', () => {
// v5 平滑波dist=0time=0 时 phase=0sin(0)=0(0+1)/2=0.5 → intensity=0.5 → 中间档
const t0 = computeRippleCells({
y: 5,
width: 11,
time: 0,
sourceX: 5,
sourceY: 5,
})
// 0.5 * 7 = 3.5, floor = 3, RIPPLE_COLOR_STOPS[3] = '#2e3870'
expect(t0[5].color).toBe('#2e3870')
// time 推进phase 变化,震源会扫过波峰(亮档)和波谷(暗档)
const t1 = computeRippleCells({
y: 5,
width: 11,
time: 1500,
sourceX: 5,
sourceY: 5,
})
// 不同 time 不同颜色(动画推进)
expect(t1[5].color).not.toBe('#2e3870')
})
test('覆盖半径扩大dist=65左侧远端仍有非最暗颜色', () => {
// 震源 x=65远端 x=0 → dist=65
// falloff = max(0, 1 - 65/90) = 0.278,波峰时 intensity ≈ 0.278
// 应映射到非最暗档(#15182b 或更亮)
const cells = computeRippleCells({
y: 0,
width: 66,
time: 0,
sourceX: 65,
sourceY: 0,
})
// 第 0 列 dist=65time=0 时 phase = 65*0.35 = 22.75 rad
// sin(22.75) ≈ -0.59 → wave = 0 → intensity = 0 → 最暗档
// 但 time 推进时波峰会扫过此处,强度变高
// 这里只验证 cell 有合法颜色(最暗档也算合法)
expect(cells[0].color).toMatch(/^#[0-9a-fA-F]{6}$/)
// 推进 time 后,左侧应出现非最暗颜色(波峰扫过)
const t1 = computeRippleCells({
y: 0,
width: 66,
time: 2000,
sourceX: 65,
sourceY: 0,
})
const nonDarkest = t1.filter(c => c.color !== '#1a1f3a')
expect(nonDarkest.length).toBeGreaterThan(0)
})
test('time 推进时颜色分布变化(动画效果)', () => {
const t0 = computeRippleCells({
y: 2,
width: 30,
time: 0,
sourceX: 25,
sourceY: 2,
})
const t1 = computeRippleCells({
y: 2,
width: 30,
time: 500,
sourceX: 25,
sourceY: 2,
})
// 至少有一个位置颜色不同
const diffs = t0.filter((c, i) => c.color !== t1[i].color)
expect(diffs.length).toBeGreaterThan(0)
})
})
describe('applyOverlaysToCells', () => {
function makeCells(colors: string[]): Cell[] {
return colors.map(c => ({ char: ' ', color: c }))
}
test('无 overlay 时原样返回(但为新数组)', () => {
const cells = makeCells(['#111', '#222', '#333'])
const out = applyOverlaysToCells(cells, [])
expect(out).toEqual(cells)
expect(out).not.toBe(cells) // 防御式拷贝
})
test('overlay 替换 char 但保留底层 colorcolor 未指定时)', () => {
const cells = makeCells([
TRANSPARENT,
TRANSPARENT,
TRANSPARENT,
TRANSPARENT,
])
const overlays: Overlay[] = [{ text: 'hi', x: 1 }]
const out = applyOverlaysToCells(cells, overlays)
expect(out[1].char).toBe('h')
expect(out[2].char).toBe('i')
expect(out[1].color).toBe(TRANSPARENT) // 保留底层色
expect(out[0].char).toBe(' ')
})
test('overlay 指定 color 时同时覆盖 char + color', () => {
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
const overlays: Overlay[] = [{ text: 'AB', x: 0, color: '#5769F7' }]
const out = applyOverlaysToCells(cells, overlays)
expect(out[0]).toEqual({ char: 'A', color: '#5769F7' })
expect(out[1]).toEqual({ char: 'B', color: '#5769F7' })
expect(out[2]).toEqual({ char: ' ', color: TRANSPARENT })
})
test('overlay 超出右边界被截断', () => {
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
const overlays: Overlay[] = [{ text: 'abcdef', x: 1 }]
const out = applyOverlaysToCells(cells, overlays)
expect(out[0].char).toBe(' ')
expect(out[1].char).toBe('a')
expect(out[2].char).toBe('b')
// 'cdef' 被截断
})
test('overlay x 为负数 → 从开头截断(不向左溢出)', () => {
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
const overlays: Overlay[] = [{ text: 'abc', x: -1 }]
const out = applyOverlaysToCells(cells, overlays)
expect(out[0].char).toBe('b') // 跳过 'a''b' 占 0
expect(out[1].char).toBe('c')
expect(out[2].char).toBe(' ')
})
test('多个 overlay 后者覆盖前者(同位置)', () => {
const cells = makeCells([TRANSPARENT, TRANSPARENT, TRANSPARENT])
const overlays: Overlay[] = [
{ text: 'AAA', x: 0, color: '#111' },
{ text: 'B', x: 1, color: '#222' },
]
const out = applyOverlaysToCells(cells, overlays)
expect(out[0]).toEqual({ char: 'A', color: '#111' })
expect(out[1]).toEqual({ char: 'B', color: '#222' }) // 第二个 overlay 覆盖
expect(out[2]).toEqual({ char: 'A', color: '#111' })
})
test('overlay 起始位置 >= 数组长度 → 完全跳过', () => {
const cells = makeCells([TRANSPARENT, TRANSPARENT])
const overlays: Overlay[] = [{ text: 'X', x: 5 }]
const out = applyOverlaysToCells(cells, overlays)
expect(out.every(c => c.char === ' ')).toBe(true)
})
test('不修改原数组(防御式拷贝)', () => {
const cells = makeCells([TRANSPARENT])
const snapshot = cells.map(c => ({ ...c }))
applyOverlaysToCells(cells, [{ text: 'X', x: 0 }])
expect(cells).toEqual(snapshot)
})
})
describe('cellsToSegments', () => {
test('空数组 → 空数组', () => {
expect(cellsToSegments([])).toEqual([])
})
test('单 cell → 单段', () => {
const cells: Cell[] = [{ char: 'a', color: '#111' }]
expect(cellsToSegments(cells)).toEqual([{ text: 'a', color: '#111' }])
})
test('全部同色 → 合并为一段', () => {
const cells: Cell[] = [
{ char: 'a', color: '#111' },
{ char: 'b', color: '#111' },
{ char: 'c', color: '#111' },
]
expect(cellsToSegments(cells)).toEqual([{ text: 'abc', color: '#111' }])
})
test('颜色交替 → 每个独立段', () => {
const cells: Cell[] = [
{ char: 'a', color: '#111' },
{ char: 'b', color: '#222' },
{ char: 'c', color: '#111' },
]
expect(cellsToSegments(cells)).toEqual([
{ text: 'a', color: '#111' },
{ text: 'b', color: '#222' },
{ text: 'c', color: '#111' },
])
})
test('相邻同色段合并,不同色段分开', () => {
const cells: Cell[] = [
{ char: 'a', color: TRANSPARENT },
{ char: 'b', color: TRANSPARENT },
{ char: 'X', color: '#5769F7' },
{ char: 'Y', color: '#5769F7' },
{ char: 'c', color: TRANSPARENT },
]
expect(cellsToSegments(cells)).toEqual([
{ text: 'ab', color: TRANSPARENT },
{ text: 'XY', color: '#5769F7' },
{ text: 'c', color: TRANSPARENT },
])
})
test('段文本拼接顺序保持原顺序', () => {
const cells: Cell[] = [
{ char: '1', color: '#111' },
{ char: '2', color: '#111' },
{ char: '3', color: '#111' },
]
expect(cellsToSegments(cells)[0].text).toBe('123')
})
})
describe('fadeColor', () => {
test('fade=1 → 原色(不变)', () => {
expect(fadeColor('#5769F7', 1)).toBe('#5769f7')
})
test('fade=0 → TRANSPARENTcell 不渲染)', () => {
expect(fadeColor('#5769F7', 0)).toBe(TRANSPARENT)
})
test('fade ≤ 0.01 → TRANSPARENT阈值', () => {
expect(fadeColor('#5769F7', 0.01)).toBe(TRANSPARENT)
expect(fadeColor('#5769F7', 0.009)).toBe(TRANSPARENT)
})
test('fade=0.5 → RGB 各分量减半', () => {
// #5769F7 = (87, 105, 247),减半 → (44, 53, 124) = #2c357c
// Math.round(87*0.5)=44, Math.round(105*0.5)=53, Math.round(247*0.5)=124
expect(fadeColor('#5769F7', 0.5)).toBe('#2c357c')
})
test('TRANSPARENT 输入 → 原样返回(不处理)', () => {
expect(fadeColor(TRANSPARENT, 1)).toBe(TRANSPARENT)
expect(fadeColor(TRANSPARENT, 0.5)).toBe(TRANSPARENT)
})
test('非法 hex 格式 → 原样返回(防御式)', () => {
expect(fadeColor('not-a-color', 0.5)).toBe('not-a-color')
expect(fadeColor('#123', 0.5)).toBe('#123') // 非 6 位 hex
})
test('fade < 0 钳到 0 → TRANSPARENT', () => {
expect(fadeColor('#5769F7', -0.5)).toBe(TRANSPARENT)
})
test('fade > 1 钳到 1 → 原色', () => {
expect(fadeColor('#5769F7', 1.5)).toBe('#5769f7')
})
test('结果始终为 6 位 hex前导零补全', () => {
// #010203 = (1, 2, 3)fade=0.5 → Math.round 后为 (1, 1, 2) = #010102
// 但 1*0.5 = 0.5, Math.round(0.5) = 1 banker's rounding 在 JS 中是 round half up
// 验证格式6 位 hex
const result = fadeColor('#010203', 0.5)
expect(result).toMatch(/^#[0-9a-f]{6}$/)
})
})
describe('fadeCells', () => {
test('空数组 → 空数组', () => {
expect(fadeCells([], 0.5)).toEqual([])
})
test('每个 cell 的颜色按 fade 缩放char 保留', () => {
const cells: Cell[] = [
{ char: ' ', color: '#5769F7' },
{ char: 'A', color: '#ffffff' },
]
const out = fadeCells(cells, 0.5)
expect(out[0]).toEqual({ char: ' ', color: '#2c357c' })
// #ffffff = (255, 255, 255)fade=0.5 → (128, 128, 128) = #808080
expect(out[1]).toEqual({ char: 'A', color: '#808080' })
})
test('不修改原数组(防御式拷贝)', () => {
const cells: Cell[] = [{ char: ' ', color: '#5769F7' }]
const snapshot = cells.map(c => ({ ...c }))
fadeCells(cells, 0.5)
expect(cells).toEqual(snapshot)
})
test('TRANSPARENT cell 保持 TRANSPARENT', () => {
const cells: Cell[] = [
{ char: ' ', color: TRANSPARENT },
{ char: ' ', color: '#5769F7' },
]
const out = fadeCells(cells, 0.5)
expect(out[0].color).toBe(TRANSPARENT)
expect(out[1].color).toBe('#2c357c')
})
test('fade=0 → 所有非 transparent 颜色变 TRANSPARENT', () => {
const cells: Cell[] = [
{ char: ' ', color: '#5769F7' },
{ char: ' ', color: '#1a1f3a' },
]
const out = fadeCells(cells, 0)
expect(out[0].color).toBe(TRANSPARENT)
expect(out[1].color).toBe(TRANSPARENT)
})
})

View File

@@ -0,0 +1,126 @@
import type { EffortValue } from '../../utils/effort.js'
/**
* 光标在面板上的位置。仅面板内部使用,不进入 AppState / settings / API。
* 'ultracode' 不是 EffortLevel它在本面板里仅作视觉占位与文案引导。
*/
export type PanelPosition =
| 'low'
| 'medium'
| 'high'
| 'xhigh'
| 'max'
| 'ultracode'
export const PANEL_POSITIONS: readonly PanelPosition[] = [
'low',
'medium',
'high',
'xhigh',
'max',
'ultracode',
] as const
export const HOME_POSITION: PanelPosition = 'low'
export const END_POSITION: PanelPosition = 'ultracode'
/**
* 判断一个值是否可作为面板光标位置(不含 ultracode因 ultracode 仅由面板内部产生)。
*/
function isNonUltracodePosition(
value: unknown,
): value is Exclude<PanelPosition, 'ultracode'> {
return (
typeof value === 'string' &&
value !== 'ultracode' &&
(PANEL_POSITIONS as readonly string[]).includes(value)
)
}
/**
* 把 EffortValue 归一化为面板可用的光标位置。
* - null / undefined / 数值ant-only/ ultracode → undefined让上层用 displayed
* - 合法 string 档位 → 返回该档位
*/
function normalizeToPanelPosition(
value: EffortValue | null | undefined,
): PanelPosition | undefined {
if (value === null || value === undefined) return undefined
if (typeof value === 'number') return undefined
if (isNonUltracodePosition(value)) {
return value
}
return undefined
}
export function moveLeft(cursor: PanelPosition): PanelPosition {
const idx = PANEL_POSITIONS.indexOf(cursor)
if (idx <= 0) return PANEL_POSITIONS[0]
return PANEL_POSITIONS[idx - 1]
}
export function moveRight(cursor: PanelPosition): PanelPosition {
const idx = PANEL_POSITIONS.indexOf(cursor)
if (idx === -1 || idx >= PANEL_POSITIONS.length - 1) {
return PANEL_POSITIONS[PANEL_POSITIONS.length - 1]
}
return PANEL_POSITIONS[idx + 1]
}
export function isUltracode(cursor: PanelPosition): boolean {
return cursor === 'ultracode'
}
/**
* 决定面板挂载时的初始光标位置。
* 优先级env override若是合法档位> displayed level
*
* @param envOverride getEffortEnvOverride() 的返回值EffortValue | null | undefined
* @param appStateEffort AppState.effortValue
* @param displayed getDisplayedEffortLevel(model, appStateEffort) —— 必传,避免此处再依赖 model
*/
export function getInitialCursor(args: {
envOverride: EffortValue | null | undefined
appStateEffort: EffortValue | undefined
displayed: PanelPosition
}): PanelPosition {
const fromEnv = normalizeToPanelPosition(args.envOverride)
if (fromEnv !== undefined) return fromEnv
// displayed 已经是 EffortLevel不含 ultracode合法
return args.displayed
}
// ---- 确认/取消决策(注入 ApplyFn 避免循环依赖 + 便于测试)----
export type ConfirmOutcome =
| {
kind: 'apply'
message: string
effortUpdate?: { value: EffortValue | undefined }
}
| { kind: 'ultracode-hint'; message: string }
export type ApplyFn = (cursor: PanelPosition) => {
message: string
effortUpdate?: { value: EffortValue | undefined }
}
export const ULTRACODE_HINT =
'ultracode is not an effort level. Use /ultracode <context> to start a multi-agent workflow.'
export const CANCEL_MESSAGE = 'Effort unchanged.'
export function computeConfirmOutcome(
cursor: PanelPosition,
applyFn: ApplyFn,
): ConfirmOutcome {
if (isUltracode(cursor)) {
return { kind: 'ultracode-hint', message: ULTRACODE_HINT }
}
const result = applyFn(cursor)
return {
kind: 'apply',
message: result.message,
effortUpdate: result.effortUpdate,
}
}

Some files were not shown because too many files have changed in this diff Show More