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

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

<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 并自行运行验收命令。请在适合自身环境与风险要求的场景下使用。
依赖环境:
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_ops | WORKHORSE_MODEL_* | gpt-6-astra / gpt-6-astra / gpt-6.1-sol | 各模式默认模型 |
effort_code / effort_review / effort_ops | WORKHORSE_EFFORT_* | medium / high / high | 各模式推理强度 |
fast_match | WORKHORSE_FAST_MATCH | *sol* | 匹配的模型走 Fast 档,默认 high 强度 |
disable_mcp | WORKHORSE_DISABLE_MCP | node_repl | 禁用的 MCP 服务 |
allow_ops | WORKHORSE_ALLOW_OPS | true | 是否允许 ops 模式 |
stall_minutes | - | 10 | 无输出提醒阈值(分钟) |
command_minutes | - | 20 | 单命令超时提醒阈值(分钟) |
repeat_limit | - | 3 | 重复命令触发提醒次数 |
max_tests | - | 2 | 测试类命令执行上限提醒 |
family_tokens_warn | - | 1000000 | 累计 token 消耗提醒阈值 |
keep_days | WORKHORSE_KEEP_DAYS | 14 | 任务历史记录保留天数 |
skip_skills | WORKHORSE_SKIP_SKILLS | karpathy-guidelines,code-review,diagnosing-bugs,tdd | Codex 忽略的通用流程技能 |
banned_phrases | - | baseline,全量测试,full test suite,前后对比,before/after | brief 禁止包含的门禁词汇(逗号分隔) |
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 plugin validate . --strict
claude plugin test
测试用例位于 hooks/workhorse.test.ts,使用 Claude Code 自带的 mod 测试机制模拟状态与进程调用,不消耗 token。
hooks/register.ts 254 lines1import 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}
254hooks/lib.ts 265 lines1import 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