SLOPSHOPPER

workhorse

Hand coding, review and ops jobs to Codex as background subagents, with a live status band, watchdog warnings and completion notices

bandrowsguardcommandtoasttool
v0.5.1MITupdated 2026-10-13guojunjie0614-cyber/workhorse
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workhorse
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /workhorse-kill ⎿ workhorse: 本会话没有运行中的 workhorse 任务。 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

workhorse

A Claude Code mod that hands coding, review and ops work to Codex as background subagents.

workhorse 是一个 Claude Code 插件,用于将编码实现、代码审查和运维操作以子代理方式委托给后台的 Codex 处理。Claude 负责撰写四段式 brief 并把关 diff 与验收,Codex 在后台异步执行,任务完成后将报告送回会话,避免轮询与等待。

三个 workhorse 任务并行运行

<sub>三个任务并行:每次派发显示任务名、模式、推理强度和中文说明;提示框上方的面板实时刷新每个任务的状态和耗时。</sub>

仓库内容

  • hooks/register.ts:注册 mcp__workhorse__run 工具、权限拦截、/workhorse-kill 命令与实时状态面板,按会话隔离状态。
  • hooks/lib.ts:负责 brief 拼装、前置校验(lint)、参数解析、终端排版与中文文案。
  • bin/workhorse:独立的命令行执行引擎,负责环境隔离、规则块附加、Codex 进程管理、事件记录、生成报告与 Git worktree 维护。
  • prompts/blocks.md:附加至 Codex 任务的四段精简规则(action_safety、verification_loop、default_follow_through_policy、compact_output_contract)。
  • skills/workhorse/SKILL.md:指导 Claude 何时派活、如何书写 brief 以及如何审查结果的 Skill 定义。

免责声明

本插件会在后台调用 Codex 执行终端命令与文件改动。 虽然具备权限分级与看门狗提醒机制,但在将代码合并到主分支前,仍应始终核对 diff 并自行运行验收命令。请在适合自身环境与风险要求的场景下使用。

在 Claude Code 中试用

依赖环境:

  • Claude Code v2.1.287 或更新版本(已在 v2.1.296 测试)
  • Codex CLI,已登录且支持 codex exec --json(已在 0.162.1 测试)
  • bash、git、jq、perl;macOS 或 Linux

在 Claude Code 提示框中执行以下命令添加市场并安装:

/plugin marketplace add guojunjie0614-cyber/workhorse
/plugin install workhorse@workhorse

也可以在系统终端中运行等价命令:

claude plugin marketplace add guojunjie0614-cyber/workhorse
claude plugin install workhorse@workhorse

安装后在会话中执行 /reload-plugins,或重启会话以加载更新。

自然语言使用示例

安装完成后,在会话中直接向 Claude 提出修改需求:

修复 parser.py 中遇到 None 时的解析异常,验收命令是 pytest tests/test_parser.py。

Claude 会自动提取上下文并拼装 brief,通过后台子代理指派给 Codex 执行。

最小调用示例

插件通过 mcp__workhorse__run 工具派发任务,最小调用如下:

{
  "name": "fix-parser",
  "title": "修复解析器空值",
  "goal": "修复 parser.py 中遇到 None 时的解析异常",
  "context": "parser.py parse() 函数在输入为空时抛出 AttributeError",
  "done_when": "pytest tests/test_parser.py"
}

该工具调用后立即返回,Codex 在后台运行;完成后报告会自动送回对话,提醒 Claude 执行指定的验收命令。

任务模式与调用字段

三种模式

  • code(默认):仅允许写入目标目录,适合单点代码实现与 bug 修复。
  • review:只读模式,用于定位根因和排查缺陷,不修改任何文件。
  • ops:运维模式,允许访问仓库外路径与远程机器;启动及续做 ops 任务前会向用户发起确认。

调用字段说明

字段必填说明
name新任务必填英文短横线命名(如 fix-parser,匹配 ^[a-z][a-z0-9-]*$);续做时可省略
title是6–12 个中文字(至多 24 列宽),面板完整显示,如“修复解析器空值”
goal是本次具体的单一改动目标
context是关联的文件、函数、错误证据及路径
constraints否仅属于本任务特有的限制
done_when是一条具体的单项验收命令
mode否code(默认)、review、ops
dir / worktree否工作目录;或指定 worktree 名称从 HEAD 建立分支并行
resume / to_code否在原线程续做;将 review 结论转为修复代码
effort / model否覆盖默认的推理强度和模型

看门狗提醒

看门狗负责被动监控任务状态,只发提醒、不杀进程,每类提醒对单个任务仅触发一次:

情况触发条件(设置项)默认阈值
无输出无命令运行且无新事件间隔超过阈值(stall_minutes)10 分钟
命令跑太久单条命令持续运行超过阈值(command_minutes)20 分钟
原地打转相同命令与退出码连续出现且未改动文件(repeat_limit)3 次
测试跑太多测试类命令执行次数达到上限(max_tests)2 次
token 过多单个任务家族各轮次累计 token 达到阈值(family_tokens_warn)1000000

需要中断任务时,可在 Claude Code 中输入 /workhorse-kill [name],或在终端执行 workhorse -k [name]。被终止的任务在面板上标记为 cancelled。

命令行引擎

bin/workhorse 是独立可用的底层引擎,也可以软链接到 PATH(例如 ln -s ~/workhorse/bin/workhorse ~/.local/bin/workhorse):

命令说明
workhorse -C dir <<'EOF' … EOF在指定目录启动 code 任务,brief 从 stdin 读入
workhorse --review / --ops启动只读诊断或运维模式任务
workhorse -w name新建 worktree <repo>-name(分支 workhorse/name)并执行
workhorse -r [name] [--code]在原 Codex 线程上续做(--code 对应转为代码修复)
workhorse -l查看近期任务列表及结果概况
workhorse -t [name]查看最近执行的命令、测试次数与最新输出间隔
workhorse -s [name …]输出任务状态 JSON,供外部或插件读取
workhorse -k [name]终止指定任务及其子进程
workhorse --clean name合并后清理对应 worktree 和分支(未合并或存在未提交改动时拒绝清理)
workhorse -h显示命令行帮助与当前配置

任务记录保存在 ~/.cache/workhorse/<name>/(包括 prompt.md、events.jsonl、codex.log、final.md、report.txt、session.txt 等),超出 keep_days 后自动清理。

设置项

可以在 /plugin configure workhorse@workhorse 中配置,或写入 ~/.config/workhorse/config(格式为 WORKHORSE_XXX=值)及环境变量:

设置项引擎环境变量默认值说明
model_code / model_review / model_opsWORKHORSE_MODEL_*gpt-6-astra / gpt-6-astra / gpt-6.1-sol各模式默认模型
effort_code / effort_review / effort_opsWORKHORSE_EFFORT_*medium / high / high各模式推理强度
fast_matchWORKHORSE_FAST_MATCH*sol*匹配的模型走 Fast 档,默认 high 强度
disable_mcpWORKHORSE_DISABLE_MCPnode_repl禁用的 MCP 服务
allow_opsWORKHORSE_ALLOW_OPStrue是否允许 ops 模式
stall_minutes-10无输出提醒阈值(分钟)
command_minutes-20单命令超时提醒阈值(分钟)
repeat_limit-3重复命令触发提醒次数
max_tests-2测试类命令执行上限提醒
family_tokens_warn-1000000累计 token 消耗提醒阈值
keep_daysWORKHORSE_KEEP_DAYS14任务历史记录保留天数
skip_skillsWORKHORSE_SKIP_SKILLSkarpathy-guidelines,code-review,diagnosing-bugs,tddCodex 忽略的通用流程技能
banned_phrases-baseline,全量测试,full test suite,前后对比,before/afterbrief 禁止包含的门禁词汇(逗号分隔)
hide_codex_rescue-true安装官方 Codex 插件时隐藏 codex:codex-rescue

工作原理

Claude ──mcp__workhorse__run──▶ hooks/register.ts ──lint / 权限──▶ bin/workhorse --detach
                                     ▲                                   │
                                     │ 状态(-s,事件变化即刷新)          ▼
                              面板 / 提醒 / 完成通知  ◀── events.jsonl ◀── codex exec --json
  • hooks/register.ts:注册工具接口与状态面板,维护会话级任务状态,每 30 秒轮询或随任务新事件即时刷新。
  • hooks/lib.ts:在任务派发前执行 brief lint 检查,若缺少 done_when、标题不合规或包含门禁词则直接拦截。
  • bin/workhorse:为 Codex 构建执行参数与规则块,通过 codex exec --json 启动子进程并输出事件流。
  • 完成通知确认送达至 Claude 会话后才删除任务标记;Codex 线程 ID 在启动时持久化记录,保证异常中断后仍可续做。

开发

运行校验与测试:

claude plugin validate . --strict
claude plugin test

测试用例位于 hooks/workhorse.test.ts,使用 Claude Code 自带的 mod 测试机制模拟状态与进程调用,不消耗 token。

许可证

MIT

Source 2 files
hooks/register.ts 254 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2import { argv, brief, environment, DONE_LINGER_MINUTES, FRAME_MS, lint, MESSAGES, MS_PER_MINUTE, MS_PER_SECOND, panelLines, POLL_SECONDS, REPORT_MAX_CHARS, started, toolLine } from './lib'
3import type { Input, Job, PanelTask, Status } from './lib'
4
5// One module instance can serve several sessions, so all UI state is per session.
6type SessionState = { statuses: PanelTask[]; ticking?: { cancel: () => void }; polling: boolean; pollAgain: boolean; seen: Map<string, string> }
7const sessions = new Map<string, SessionState>()
8
9function sessionState(id: string, reset = false): SessionState {
10  let state = sessions.get(id)
11  if (reset) state?.ticking?.cancel()
12  if (!state || reset) {
13    state = { statuses: [], polling: false, pollAgain: false, seen: new Map() }
14    sessions.set(id, state)
15  }
16  return state
17}
18
19async function pollOnce($: EngineInterface, options: PluginOptions) {
20  const state = sessionState(await $.session.id())
21  if (state.polling) {
22    state.pollAgain = true
23    return
24  }
25  state.polling = true
26  try {
27    do {
28      state.pollAgain = false
29      await poll($, options, state)
30    } while (state.pollAgain)
31  } catch (error) {
32    state.ticking?.cancel()
33    state.ticking = undefined
34    throw error
35  } finally {
36    state.polling = false
37  }
38}
39
40async function poll($: EngineInterface, options: PluginOptions, state: SessionState) {
41  const lingerMs = DONE_LINGER_MINUTES * MS_PER_MINUTE
42  const session = await $.session.id()
43  const now = await $.clock.now()
44  const owned: [string, Job][] = []
45  for (const key of await $.store.keys()) {
46    if (!key.startsWith('job:')) continue
47    const job = await $.store.get(key) as Job | undefined
48    if (job?.session === session) owned.push([key, job])
49  }
50  if (!owned.length) {
51    state.statuses = state.statuses.filter(status => status.completedAt !== undefined && now - status.completedAt < lingerMs)
52    state.ticking?.cancel()
53    state.ticking = undefined
54    $.ui.invalidate('ui.render')
55    return
56  }
57  const result = await $.process.run([`${$.plugin.root}/bin/workhorse`, '-s', ...owned.map(([, job]) => job.name)], { env: environment(options) })
58  const polled = JSON.parse(result.stdout) as Status[]
59  const fresh = polled.filter(status => status.state !== 'missing').map(status => ({
60    ...status,
61    goal: owned.find(([, job]) => job.name === status.name)![1].goal,
62    title: owned.find(([, job]) => job.name === status.name)![1].title,
63    completedAt: status.state === 'done' || status.state === 'dead'
64      ? state.statuses.find(previous => previous.name === status.name)?.completedAt ?? now
65      : undefined,
66  }))
67  state.statuses = [
68    ...state.statuses.flatMap(previous => {
69      const current = fresh.find(status => status.name === previous.name)
70      if (current) return [current]
71      return previous.completedAt !== undefined && now - previous.completedAt < lingerMs ? [previous] : []
72    }),
73    ...fresh.filter(status => !state.statuses.some(previous => previous.name === status.name)),
74  ]
75  $.ui.invalidate('ui.render')
76  if (polled.some(status => status.state === 'running')) {
77    state.ticking ??= $.clock.every(FRAME_MS, async () => {
78      $.ui.invalidate('ui.render')
79      for (const status of state.statuses) {
80        if (status.state !== 'running') continue
81        const stat = await $.fs.stat(`${status.path}/events.jsonl`).catch(() => undefined)
82        const mark = stat && `${stat.mtimeMs}:${stat.size}`
83        const changed = mark !== undefined && mark !== state.seen.get(status.name)
84        if (mark) state.seen.set(status.name, mark)
85        if (changed || await $.fs.exists(`${status.path}/report.txt`)) {
86          void pollOnce($, options)
87          return
88        }
89      }
90    })
91  } else {
92    state.ticking?.cancel()
93    state.ticking = undefined
94  }
95  for (const [key, job] of owned) {
96    try {
97      const status = polled.find(s => s.name === job.name)!
98      if (status.family_tokens >= Number(options.family_tokens_warn) && !job.warnedFamilyTokens) {
99        const text = MESSAGES.familyTokens(job.name, status.family_tokens)
100        await $.ui.toast(text)
101        await $.prompt.submit({ text })
102        job.warnedFamilyTokens = true
103        await $.store.set(key, job)
104      }
105      if (status.state === 'missing') {
106        if (!await $.store.get(key)) continue
107        await $.prompt.submit({ text: MESSAGES.missing(job.name) })
108        await $.store.delete(key)
109      } else if (status.state === 'done' || status.state === 'dead') {
110        const fullReport = status.state === 'done' ? await $.fs.read(`${status.path}/report.txt`) : ''
111        const report = fullReport.slice(0, REPORT_MAX_CHARS) + (fullReport.length > REPORT_MAX_CHARS ? MESSAGES.truncated(status.path) : '')
112        const reminder = status.cancelled
113          ? MESSAGES.cancelledReminder(job.name)
114          : status.state === 'dead'
115          ? MESSAGES.deadReminder(job.name)
116          : status.mode === 'review'
117            ? MESSAGES.reviewReminder(status.base)
118            : status.mode === 'ops'
119              ? MESSAGES.opsReminder
120              : MESSAGES.codeReminder(job.doneWhen, status.base)
121        if (!await $.store.get(key)) continue
122        await $.prompt.submit({ text: MESSAGES.finished(job.name, report, reminder) })
123        await $.store.delete(key)
124      } else if (status.state === 'running') {
125        if (status.repeat >= Number(options.repeat_limit) && !job.warnedRepeat?.includes(status.repeat_cmd)) {
126          const text = MESSAGES.repeated(job.name, status.repeat, status.repeat_cmd)
127          await $.ui.toast(text)
128          await $.prompt.submit({ text })
129          job.warnedRepeat = [...(job.warnedRepeat ?? []), status.repeat_cmd]
130          await $.store.set(key, job)
131        }
132        if (status.busy && status.busy_min >= Number(options.command_minutes) && job.warnedBusy !== status.busy) {
133          const text = MESSAGES.longCommand(job.name, status.busy_min, status.busy)
134          await $.ui.toast(text)
135          await $.prompt.submit({ text })
136          job.warnedBusy = status.busy
137        }
138        if (!status.busy && status.idle_min >= Number(options.stall_minutes) && !job.warnedStall) {
139          job.warnedStall = true
140          const text = MESSAGES.stalled(job.name, status.idle_min)
141          await $.ui.toast(text)
142          void $.prompt.submit({ text })
143        }
144        if (status.tests > Number(options.max_tests) && !job.warnedTests) {
145          job.warnedTests = true
146          const text = MESSAGES.excessTests(job.name, status.tests, Number(options.max_tests))
147          await $.ui.toast(text)
148          void $.prompt.submit({ text })
149        }
150        if (await $.store.get(key)) await $.store.set(key, job)
151      }
152    } catch (error) {
153      await $.ui.toast(MESSAGES.statusError(job.name, String(error)))
154    }
155  }
156}
157
158export const register: Register = (on, options) => {
159  on('agent.offer', ($, e, next) => {
160    if (options.hide_codex_rescue && e.agent === 'codex:codex-rescue') return { isOffered: false }
161    return next(e)
162  })
163
164  on('session.start', async ($, e, next) => {
165    sessionState(await $.session.id(), true)
166    await $.tool.register({
167      name: 'run',
168      description: MESSAGES.runDescription,
169      inputSchema: {
170        type: 'object',
171        properties: {
172          title: { type: 'string', description: MESSAGES.titleDescription },
173          goal: { type: 'string' }, context: { type: 'string' }, done_when: { type: 'string' },
174          constraints: { type: 'string' }, mode: { type: 'string', enum: ['code', 'review', 'ops'], default: 'code' },
175          dir: { type: 'string' }, worktree: { type: 'string' }, name: { type: 'string' }, resume: { type: 'string' },
176          to_code: { type: 'boolean' }, effort: { type: 'string', description: MESSAGES.effortDescription }, model: { type: 'string' },
177        },
178        required: ['title', 'goal', 'context', 'done_when'],
179      },
180    })
181    await $.command.register({ name: 'workhorse-kill', description: MESSAGES.killDescription, argumentHint: MESSAGES.killArgumentHint, immediate: true })
182    $.clock.every(POLL_SECONDS * MS_PER_SECOND, () => pollOnce($, options))
183    return next(e)
184  })
185
186  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
187    const now = await $.clock.now()
188    const lines = panelLines(sessionState(await $.session.id()).statuses, now)
189    if (!lines.length) return next(e)
190    const { Box, Text } = $.ui.resolve(e)
191    return Box({ flexDirection: 'column', children: lines.map(line => Text({ wrap: 'truncate-end', children: line.map(segment => Text({ color: 'text', ...segment })) })) })
192  })
193
194  on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
195    if (e.props.tool !== 'mcp__workhorse__run') return next(e)
196    const { Text } = $.ui.resolve(e)
197    return Text({ color: 'text', wrap: 'truncate-end', children: toolLine(e.props.input as Input) })
198  })
199
200  on('tool.check', { tool: 'mcp__workhorse__run' }, async ($, e) => {
201    const input = e.input as Input
202    let mode: string | undefined = input.mode ?? (input.to_code ? 'code' : undefined)
203    if (!mode && input.resume) {
204      // Fail closed: when the resumed job's mode can't be read, ask as for ops.
205      const result = await $.process.run([`${$.plugin.root}/bin/workhorse`, '-s', input.resume], { env: environment(options) })
206      try {
207        mode = (JSON.parse(result.stdout) as Status[])[0]?.mode ?? 'ops'
208      } catch {
209        mode = 'ops'
210      }
211    }
212    return mode === 'ops'
213      ? { decision: 'ask', reason: MESSAGES.opsPermission }
214      : { decision: 'allow' }
215  })
216
217  on('tool.call', { tool: 'mcp__workhorse__run' }, async ($, e) => {
218    const { tool, tool_use_id, agentId, ...args } = e
219    const input = args as Input
220    const deny = lint(input, String(options.banned_phrases))
221    if (deny) return { deny }
222    const cwd = input.dir || await $.session.cwd()
223    const result = await $.process.run(argv($.plugin.root, input, cwd), { cwd, env: environment(options), stdin: brief(input) })
224    if (result.exitCode !== 0) return { result: result.stderr }
225    const job = started(result.stdout)
226    await $.store.set(`job:${job.path}`, { name: job.name, session: await $.session.id(), title: input.title, goal: input.goal, doneWhen: input.done_when, warnedStall: false, warnedTests: false })
227    void pollOnce($, options)
228    const lines = result.stdout.split('\n')
229    const warnings = lines.slice(lines.findIndex(line => line.startsWith('started ')) + 1).filter(line => line.startsWith('warning:'))
230    return { result: [MESSAGES.launched(job.name), ...warnings].join('\n') }
231  })
232
233  on('command.run', { command: 'workhorse-kill' }, async ($, e) => {
234    let name = e.args.trim()
235    if (!name) {
236      const session = await $.session.id()
237      const names: string[] = []
238      for (const key of await $.store.keys()) {
239        if (!key.startsWith('job:')) continue
240        const job = await $.store.get(key) as Job
241        if (job.session === session) names.push(job.name)
242      }
243      if (!names.length) return { text: MESSAGES.noRunning }
244      const result = await $.process.run([`${$.plugin.root}/bin/workhorse`, '-s', ...names], { env: environment(options) })
245      const running = (JSON.parse(result.stdout) as Status[]).filter(status => status.state === 'running')
246      if (!running.length) return { text: MESSAGES.noRunning }
247      if (running.length > 1) return { text: MESSAGES.multipleRunning(running.map(status => status.name)) }
248      name = running[0]!.name
249    }
250    const result = await $.process.run([`${$.plugin.root}/bin/workhorse`, '-k', name], { env: environment(options) })
251    return { text: result.stdout + result.stderr }
252  })
253}
254
hooks/lib.ts 265 lines
1import type { PluginOptions } from 'claude-code'
2
3// Seconds between job status polls.
4export const POLL_SECONDS = 30
5// Minutes to keep finished jobs visible while other jobs run.
6export const DONE_LINGER_MINUTES = 3
7// Maximum terminal columns for each job title.
8export const TITLE_COLUMNS = 24
9// New job names: English kebab-case.
10export const NAME_PATTERN = /^[a-z][a-z0-9-]*$/
11// Maximum report characters included in completion notices.
12export const REPORT_MAX_CHARS = 6000
13
14export const FRAME_MS = 250
15export const SPINNER_FRAMES = Array.from('·✢✳✶✻✽')
16export const MS_PER_SECOND = 1000
17export const SECONDS_PER_MINUTE = 60
18export const MINUTES_PER_HOUR = 60
19export const MS_PER_MINUTE = MS_PER_SECOND * SECONDS_PER_MINUTE
20export const TIME_DIGITS = 2
21export const NEGATION_WINDOW = 5
22export const NEGATIONS = ['不', '没', '无', '别', '勿', '禁止', '\\bno\\b', '\\bnot\\b', "\\bdon't\\b", '\\bwithout\\b', '\\bavoid\\b', '\\bskip\\b']
23
24// The band keeps Claude's own agents-panel wording.
25const PANEL_TEXT = {
26  working: 'Working…',
27  summary: (total: number, running: number, done: number, failed: number, cancelled: number) => `${total} agents · ${running} running · ${done} done${failed ? ` · ${failed} failed` : ''}${cancelled ? ` · ${cancelled} cancelled` : ''}`,
28  states: { running: 'running', done: 'done', dead: 'dead', missing: 'missing', failed: 'failed', cancelled: 'cancelled' },
29  busy: (minutes: number, command: string) => `running ${minutes}m: ${command}`,
30  activity: (activity: string, cmds: number, files: number) => `${activity} · ${cmds} cmds · ${files} files`,
31}
32
33export const MESSAGES = {
34  missing: (name: string) => `workhorse: job ${name} 记录已丢失`,
35  truncated: (path: string) => `\n已截断,完整报告见 ${path}/report.txt`,
36  deadReminder: (name: string) => `用 workhorse -t ${name} 看日志`,
37  cancelledReminder: (name: string) => `任务已取消;用 workhorse -t ${name} 查看停止前的日志`,
38  repeated: (name: string, repeat: number, command: string) => `workhorse: job ${name} 原地打转:相同命令和退出码已连续出现 ${repeat} 次:${command};请检查执行方向,仅提醒,不会自动终止`,
39  familyTokens: (name: string, tokens: number) => `workhorse: job ${name} 家族累计已使用 ${tokens} token;请检查剩余工作和预算`,
40  reviewReminder: (base: string) => `核对结论和证据;要修复就用 resume ${base} + to_code 交回`,
41  opsReminder: '核对产物和远端状态,不要重复执行部署',
42  codeReminder: (doneWhen: string, base: string) => `对照 brief 读 diff,自己运行 ${doneWhen};要修复就用 mcp__workhorse__run 工具 resume ${base} 交回`,
43  finished: (name: string, report: string, reminder: string) => `workhorse: job ${name} 已结束\n${report}\n${reminder}`,
44  stalled: (name: string, minutes: number) => `workhorse: job ${name} 已 ${minutes} 分钟无输出,用 workhorse -t ${name} 查看,卡住就 workhorse -k ${name} 后把 brief 缩小重派`,
45  longCommand: (name: string, minutes: number, command: string) => `workhorse: job ${name} 的命令已运行 ${minutes} 分钟:${command};用 workhorse -t ${name} 查看,仅提醒,不会自动终止`,
46  excessTests: (name: string, tests: number, max: number) => `workhorse: job ${name} 已运行 ${tests} 次测试,超过 max_tests=${max};用 workhorse -t ${name} 查看,停止重复测试,必要时 workhorse -k ${name} 后缩小 brief 重派`,
47  statusError: (name: string, error: string) => `workhorse: job ${name} 状态处理失败:${error}`,
48  runDescription: '让 Codex 启动一个后台 job,一个 job 只做一个改动。调用后立即返回,报告会以 workhorse 消息的形式送达,不要轮询。根因不明时先用 mode=review,再用 resume + to_code 转成修复。brief 里不要写 baseline 或全量测试。',
49  effortDescription: '推理强度字符串;常用值为 low、medium、high。',
50  killDescription: '终止一个 Codex 任务',
51  killArgumentHint: '<任务名>',
52  opsPermission: 'ops 任务让 Codex 在仓库外以完整权限运行',
53  launched: (name: string) => `已启动 ${name};报告会以 workhorse 消息送达,不要轮询。`,
54  noRunning: '本会话没有运行中的 workhorse 任务。',
55  multipleRunning: (names: string[]) => `本会话有多个运行中的任务,请用 /workhorse-kill <name> 指名:\n${names.join('\n')}`,
56  missingDoneWhen: 'done_when 必须填写明确的完成条件',
57  titleDescription: '用中文写 6–12 个字说明这个任务在做什么',
58  missingTitle: 'title 必须填写简短中文说明',
59  titleTooWide: `title 显示宽度不能超过 ${TITLE_COLUMNS} 列`,
60  invalidName: '新任务 name 必填,须以小写英文字母开头,仅含小写英文字母、数字和短横线',
61  bannedPhrase: (field: string, phrase: string) => `${field} 含禁用词:${phrase}`,
62  ...PANEL_TEXT,
63  auto: '自动',
64  defaultEffort: '默认',
65}
66
67export type Input = {
68  title: string
69  goal: string
70  context: string
71  done_when: string
72  constraints?: string
73  mode?: 'code' | 'review' | 'ops'
74  dir?: string
75  worktree?: string
76  name?: string
77  resume?: string
78  to_code?: boolean
79  effort?: string
80  model?: string
81}
82
83export type Job = {
84  title?: string
85  name: string
86  session: string
87  goal: string
88  doneWhen: string
89  warnedStall: boolean
90  warnedTests: boolean
91  warnedBusy?: string
92  warnedRepeat?: string[]
93  warnedFamilyTokens?: boolean
94}
95
96export type Status = {
97  name: string
98  base: string
99  round: number
100  busy: string
101  busy_min: number
102  mode: string
103  path: string
104  state: 'running' | 'done' | 'dead' | 'missing'
105  exit: number | null
106  started: number
107  idle_min: number
108  cmds: number
109  activity: string
110  files: number
111  tests: number
112  cancelled: boolean
113  repeat: number
114  repeat_cmd: string
115  family_tokens: number
116}
117
118export type PanelTask = Status & { goal: string; title?: string; completedAt?: number }
119type Segment = { children: string; color?: 'claude' | 'success' | 'error' | 'inactive'; dimColor?: boolean }
120
121export function panelLines(tasks: PanelTask[], now: number): Segment[][] {
122  const visible = tasks.filter(task => task.state !== 'missing' && (task.completedAt === undefined || now - task.completedAt < DONE_LINGER_MINUTES * MS_PER_MINUTE))
123  if (!visible.some(task => task.state === 'running')) return []
124  const frame = Math.floor(now / FRAME_MS)
125  const star = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]!
126  const running = visible.filter(task => task.state === 'running').length
127  const nameWidth = Math.max(...visible.map(task => width(taskName(task))))
128  const titleWidth = Math.min(TITLE_COLUMNS, Math.max(...visible.map(task => width(task.title ?? headline(task.goal)))))
129  const done = visible.filter(task => !task.cancelled && task.state === 'done' && task.exit === 0).length
130  const failed = visible.filter(task => !task.cancelled && (task.state === 'dead' || (task.state === 'done' && task.exit !== 0))).length
131  const cancelled = visible.filter(task => task.cancelled).length
132  return [
133    [{ children: `${star} ${MESSAGES.working}`, color: 'claude' }],
134    [{ children: MESSAGES.summary(visible.length, running, done, failed, cancelled), dimColor: true }],
135    ...visible.flatMap((task, index) => taskLines(task, now, star, index === visible.length - 1, nameWidth, task.title === undefined ? fit(headline(task.goal), titleWidth) : pad(task.title, titleWidth))),
136  ]
137}
138
139const wide = [[0x1100, 0x115f], [0x2e80, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f300, 0x1faff], [0x20000, 0x3fffd]]
140
141function charWidth(char: string): number {
142  const code = char.codePointAt(0)!
143  if (code < 0x20 || (code >= 0x300 && code <= 0x36f) || code === 0x200b) return 0
144  return wide.some(([low, high]) => code >= low! && code <= high!) ? 2 : 1
145}
146
147function width(text: string): number {
148  return Array.from(text).reduce((sum, char) => sum + charWidth(char), 0)
149}
150
151// Older stored jobs have no title; use their goal's first clause.
152function headline(text: string): string {
153  return oneLine(text).split(/\s*[::((。;;]/)[0]!.trim() || oneLine(text)
154}
155
156function oneLine(text: string): string {
157  return text.replace(/\s+/g, ' ').trim()
158}
159
160function fit(text: string, columns: number): string {
161  const line = oneLine(text)
162  return width(line) > columns ? pad(line, columns - 1) + '…' : pad(line, columns)
163}
164
165function pad(text: string, columns: number): string {
166  let out = ''
167  for (const char of text.replace(/\s+/g, ' ')) {
168    if (width(out + char) > columns) break
169    out += char
170  }
171  return out + ' '.repeat(columns - width(out))
172}
173
174function taskName(status: Status): string {
175  return status.base + (status.round > 1 ? ` #${status.round}` : '')
176}
177
178function taskLines(status: PanelTask, now: number, star: string, last: boolean, nameWidth: number, goal = ''): Segment[][] {
179  const seconds = Math.floor((status.completedAt ?? now) / MS_PER_SECOND - status.started)
180  const minutes = Math.floor(seconds / SECONDS_PER_MINUTE)
181  const elapsed = seconds < SECONDS_PER_MINUTE ? `${seconds}s`
182    : minutes < MINUTES_PER_HOUR ? `${minutes}m${String(seconds % SECONDS_PER_MINUTE).padStart(TIME_DIGITS, '0')}s`
183      : `${Math.floor(minutes / MINUTES_PER_HOUR)}h${String(minutes % MINUTES_PER_HOUR).padStart(TIME_DIGITS, '0')}m`
184  const failed = !status.cancelled && (status.state === 'dead' || (status.state === 'done' && status.exit !== 0))
185  return [
186    [
187      { children: last ? '└─ ' : '├─ ' },
188      { children: status.cancelled ? '·' : status.state === 'running' ? star : failed ? '✗' : '✓', color: status.cancelled ? 'inactive' : failed ? 'error' : status.state === 'done' ? 'success' : 'claude' },
189      { children: ` ${pad(taskName(status), nameWidth)}  ${goal}  ` },
190      { children: pad(MESSAGES.states[status.cancelled ? 'cancelled' : failed ? 'failed' : status.state], Math.max(...Object.values(MESSAGES.states).map(width))), color: status.cancelled ? 'inactive' : failed ? 'error' : 'claude' },
191      { children: `  ${elapsed}` },
192    ],
193    [{ children: `${last ? '   ' : '│  '}  ${MESSAGES.activity(status.busy ? MESSAGES.busy(status.busy_min, status.busy) : status.activity, status.cmds, status.files)}`, dimColor: true }],
194  ]
195}
196
197export function toolLine(input: Input): string {
198  return `workhorse ▸ ${input.name || input.resume || MESSAGES.auto} · ${input.mode ?? 'code'} · ${input.effort ?? MESSAGES.defaultEffort} — ${input.title}`
199}
200
201export function lint(input: Input, banned: string): string | undefined {
202  const negation = new RegExp(`(?:${NEGATIONS.join('|')}).{0,${NEGATION_WINDOW}}$`, 'i')
203  if (!input.done_when?.trim()) return MESSAGES.missingDoneWhen
204  if (!input.title?.trim()) return MESSAGES.missingTitle
205  if (width(input.title) > TITLE_COLUMNS) return MESSAGES.titleTooWide
206  if (!input.resume && (!input.name || !NAME_PATTERN.test(input.name))) return MESSAGES.invalidName
207  for (const phrase of banned.split(',').map(s => s.trim()).filter(Boolean)) {
208    for (const field of ['goal', 'constraints', 'done_when'] as const) {
209      const text = (input[field] ?? '').toLowerCase()
210      let index = text.indexOf(phrase.toLowerCase())
211      while (index !== -1) {
212        if (!negation.test(text.slice(0, index))) return MESSAGES.bannedPhrase(field, phrase)
213        index = text.indexOf(phrase.toLowerCase(), index + phrase.length)
214      }
215    }
216  }
217}
218
219export function brief(input: Input): string {
220  return `Goal: ${input.goal}\nContext: ${input.context}\n${input.constraints ? `Constraints: ${input.constraints}\n` : ''}Done when: ${input.done_when}`
221}
222
223export function argv(root: string, input: Input, cwd: string): string[] {
224  const args = [`${root}/bin/workhorse`, '--detach']
225  if (input.resume) {
226    args.push('-r', input.resume)
227    if (input.mode) args.push(`--${input.mode}`)
228    else if (input.to_code) args.push('--code')
229  } else {
230    args.push(...(input.worktree ? ['-w', input.worktree] : ['-C', input.dir || cwd]))
231    args.push(`--${input.mode ?? 'code'}`)
232    if (input.name) args.push('-n', input.name)
233  }
234  if (input.effort) args.push('-e', input.effort)
235  if (input.model) args.push('-m', input.model)
236  return args
237}
238
239export const ENVIRONMENT_OPTIONS = {
240  model_code: 'WORKHORSE_MODEL_CODE',
241  model_review: 'WORKHORSE_MODEL_REVIEW',
242  model_ops: 'WORKHORSE_MODEL_OPS',
243  effort_code: 'WORKHORSE_EFFORT_CODE',
244  effort_review: 'WORKHORSE_EFFORT_REVIEW',
245  effort_ops: 'WORKHORSE_EFFORT_OPS',
246  fast_match: 'WORKHORSE_FAST_MATCH',
247  disable_mcp: 'WORKHORSE_DISABLE_MCP',
248  allow_ops: 'WORKHORSE_ALLOW_OPS',
249  keep_days: 'WORKHORSE_KEEP_DAYS',
250  skip_skills: 'WORKHORSE_SKIP_SKILLS',
251}
252
253export function environment(options: PluginOptions): Record<string, string> {
254  const env: Record<string, string> = {}
255  for (const [key, variable] of Object.entries(ENVIRONMENT_OPTIONS)) {
256    if (options[key] !== '' || key === 'skip_skills') env[variable] = typeof options[key] === 'boolean' ? (options[key] ? '1' : '0') : String(options[key])
257  }
258  return env
259}
260
261export function started(output: string): { name: string; path: string } {
262  const match = /^started (\S+) (.+)$/m.exec(output)!
263  return { name: match[1]!, path: match[2]! }
264}
265