接力:/goal 把一句话写成 GOAL.md 并按 RELAY 行自动续跑到验收命令全过或真 owner 岔口才停;按 phase 切 effort;上下文到阈值自动交接、/clear 后带 GOAL 接着做;磁盘余量提醒和 /ops

Keeps a lead Claude Code session running toward a written GOAL until its acceptance commands pass, with phase-based effort, auto-handoff across /clear, and an /ops overview.
lead session 的续跑与停机:定好终点,一直跑到验收命令全过,或者碰到真正要 owner 拍板的岔口才停。文中 D-x / C-x / INV-x 是设计契约里的条目编号,代码注释沿用。
/goal <一句话>:把这句话加上仓库上下文(CLAUDE.md、_NEXT.md、docs/plan/TODO.md、上一份 GOAL)写成 docs/plan/<MM.DD>/GOAL.md(已存在就写 GOAL-2.md),写完立刻激活并发第一条开工 prompt。/goal 不带参数看状态,/goal clear 停。它替掉 Claude Code 自带的 /goal。/relay status | stop | resume | open:看状态 / 停 / 续跑 / 开 GOAL 面板。/ops:待拍板事项(docs/plan/DECISIONS-PENDING.md 未关闭行 + 最新 GOAL 的 §9)、worktree、在跑的 omd-lite run、磁盘余量。RELAY: continue phase=<plan|build|review|chore|wait>
RELAY: done
RELAY: owner <一句问题>
RELAY: replan <原因> -> <新项>
phase=wait 不发,挂 heartbeatMinutes 心跳;如果在跑的 omd-lite run 和子 agent 都是 0 个,直接回推「wait 不成立」。maxTurns 轮。turn.step 按 GOAL 的 effort 表给主线切 effort(默认 plan=xhigh · build=medium · review=high · chore=low),只在自动续跑时切。子 agent 按 subagentEffort 配置的类型映射来切。每次切换后,下一次请求的 cache_read_input_tokens 记进 $.store 的 effortSwitches,只记录不拦。
上下文到 thresholdTokens 时让 lead 写交接(~/.claude/handoffs/<仓>-<session>.md),等它回 HANDOFF-READY,delaySeconds 内可以取消;子 agent 没回来不 clear。配了 checkpointCommand 时 clear 之前先同步补存一次 checkpoint(会去 ~/.omd/projects/*/session/<id>/checkpoint.md 找),clear 后发续接 prompt。续接 prompt 里一定带上激活中的 GOAL 路径和 phase,新 session 里 relay 照样自动续跑。
$.state 的 relay.mode(idle | running | waiting-owner | done)给 cue 读。GOAL 路径、phase 和计数器同时存进 $.store 的 goal:<cwd>:同一个 session 重启后自动接上;换了 session,用 /relay resume 接着跑。
| 选项 | 默认 | 说明 |
|---|---|---|
thresholdTokens | 500000 | 上下文到这个量开始交接;0 关闭 |
delaySeconds | 30 | 交接写好后多久自动 /clear,期间发消息即取消 |
heartbeatMinutes | 25 | phase=wait 后的心跳间隔 |
maxTurns | 200 | 每个 GOAL 自动续跑轮数上限 |
subagentEffort | 空 | 子 agent 按类型切 effort,如 Explore=low, Plan=high |
reposDir | 空 = ~/repos | 放仓和 worktree 的目录;会话在其下时以 <reposDir>/<仓名> 为仓根,否则以 cwd 为仓根 |
worktreePrefixes | omdl-,agent- | reposDir 下按前缀认出派发脚本留下的 worktree(另外还列每个仓的 .claude/worktrees/*) |
diskPath | 空 = 仓根 | df 查余量的路径 |
diskMinGB | 50 | 余量低于它提醒一次;0 关闭 |
checkpointCommand | 空 | clear 前补存 checkpoint 的命令,{transcript} {session} 会被替换;空 = 不补存 |
/plugin marketplace add AbyssCN/claude-lead-harness
/plugin install relay@claude-lead-harnesshooks/register.ts 936 lines1import { atom, read, update } from 'claude-code'
2import type { CommandPresentation, EngineInterface, PromptOrigin, Register, RenderElement } from 'claude-code'
3
4import type { Handoff, Ops, RelayGoal, RelayMode, RelayView } from '../types'
5import { agentEffortMap, effortTable, pickEffort } from './effort'
6import type { Effort } from './effort'
7import {
8 acceptFailPrompt,
9 acceptance,
10 appendLedger,
11 checklist,
12 continuePrompt,
13 currentItem,
14 endsAsking,
15 goalPrompt,
16 goalTitle,
17 heartbeatPrompt,
18 kickoffPrompt,
19 ledger,
20 ledgerLine,
21 mmdd,
22 noAcceptancePrompt,
23 normalizeGoal,
24 nudgePrompt,
25 parseRelay,
26 tail,
27 waitInvalidPrompt,
28} from './goal'
29import type { ContextFile } from './goal'
30import { paneRows } from './pane'
31import { handoffNote, isReadyAnswer, openDecisionRows, openGoalNine, parseDf, resumePrompt, short } from './parse'
32
33const ops = atom({ plugin: 'relay', key: 'ops' } as const, null)
34const IDLE: Handoff = { phase: 'idle', path: '', armedAt: 0, snoozeTokens: 0, waitAgents: false }
35const handoff = atom({ plugin: 'relay', key: 'handoff' } as const, IDLE)
36const diskWarned = atom({ plugin: 'relay', key: 'diskWarned' } as const, false)
37// Subagents of this session still running (Opus / Sonnet / Haiku, workflow agents);
38// a /clear would drop them. External omd-lite runs are not counted: they survive it.
39const agents = atom({ plugin: 'relay', key: 'agents' } as const, [])
40const agentTypes = atom({ plugin: 'relay', key: 'agentTypes' } as const, {})
41const turnRunning = atom({ plugin: 'relay', key: 'turnRunning' } as const, false)
42// C-4: cue reads this.
43const mode = atom({ plugin: 'relay', key: 'mode' } as const, 'idle')
44const goal = atom({ plugin: 'relay', key: 'goal' } as const, null)
45const view = atom({ plugin: 'relay', key: 'view' } as const, null)
46const origin = atom({ plugin: 'relay', key: 'origin' } as const, 'work')
47const lastEffort = atom({ plugin: 'relay', key: 'lastEffort' } as const, {})
48const selfClear = atom({ plugin: 'relay', key: 'selfClear' } as const, false)
49
50// userConfig paths and the disk guard; set once in register().
51type Env = { reposDir: string; worktreePrefixes: RegExp | null; diskPath: string; diskMinGb: number; checkpointCommand: string[] }
52const env: Env = { reposDir: '', worktreePrefixes: /^(omdl-|agent-)/, diskPath: '', diskMinGb: 50, checkpointCommand: [] }
53const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
54const csv = (s: string): string[] => s.split(',').map(x => x.trim()).filter(Boolean)
55const DISK_TICK_MS = 5 * 60_000
56const SNOOZE_TOKENS = 100_000
57// While subagents or a turn still run at clear time, look again this often, up to the cap.
58const RECHECK_MS = 15_000
59const WAIT_CAP_MS = 30 * 60_000
60// An omd-lite run with files touched this recently and no result yet counts as running.
61const RUN_FRESH_MS = 20 * 60_000
62// Prompts that carry on a session's work: typed, from the phone, a loop's wake-up.
63const WORK_ORIGINS = new Set(['composer', 'bridge', 'scheduled-trigger', 'auto-continuation', 'task-notification'])
64const FROM_PERSON = new Set(['composer', 'bridge'])
65// Plugins whose prompts drive the loop: relay's own and delegate's wake-ups.
66const WORK_PLUGINS = new Set(['relay', 'delegate'])
67const STOP_WORDS = new Set(['clear', 'stop', 'off', 'reset', 'none', 'cancel'])
68const PANE = 'relay-goal'
69const PANE_MIN_COLUMNS = 110
70const ACCEPT_TIMEOUT_MS = 10 * 60_000
71const STUCK_TURNS = 3
72// omd-lite run dirs touched this recently are read for this session's running runs.
73const RUN_SCAN_MS = 48 * 3600_000
74
75const isWork = (o: PromptOrigin): boolean =>
76 WORK_ORIGINS.has(o.kind) || (o.kind === 'plugin' && 'name' in o && WORK_PLUGINS.has(String(o.name)))
77
78// C-0 (delegate's noun), read defensively: delegate may not be installed; null when it is not.
79type DelegateRunLike = { label: string; state: string }
80async function delegateRuns($: EngineInterface): Promise<DelegateRunLike[] | null> {
81 try {
82 // Absent noun: `$.delegate` is undefined and the call throws, caught below.
83 const runs = await $.delegate!.runs()
84 return Array.isArray(runs) ? runs : null
85 } catch {
86 return null
87 }
88}
89
90async function home($: EngineInterface): Promise<string> {
91 return (await $.env.get('HOME').catch(() => undefined)) || '.'
92}
93
94// `~` / `~/x` → $HOME-relative.
95async function expand($: EngineInterface, p: string): Promise<string> {
96 return p === '~' || p.startsWith('~/') ? `${await home($)}${p.slice(1)}` : p
97}
98
99// userConfig reposDir, default ~/repos: where repos and their sibling worktrees live.
100async function reposDir($: EngineInterface): Promise<string> {
101 return (await expand($, env.reposDir)) || `${await home($)}/repos`
102}
103
104// The repo the session is in: <reposDir>/<name> when cwd is under reposDir, else cwd itself.
105async function repoRoot($: EngineInterface): Promise<string> {
106 const cwd = await $.session.cwd()
107 const rd = await reposDir($)
108 if (!cwd.startsWith(`${rd}/`)) return cwd
109 return `${rd}/${cwd.slice(rd.length + 1).split('/')[0]}`
110}
111
112// userConfig diskPath, default the session's repo.
113async function diskPath($: EngineInterface): Promise<string> {
114 return (await expand($, env.diskPath)) || (await repoRoot($))
115}
116
117async function readText($: EngineInterface, path: string): Promise<string | null> {
118 try {
119 return String(await $.fs.read(path))
120 } catch {
121 return null
122 }
123}
124
125async function diskFree($: EngineInterface): Promise<number | null> {
126 try {
127 const r = await $.process.run(['df', '-BG', '--output=avail', await diskPath($)], { timeoutMs: 5000 })
128 return r.exitCode === 0 ? parseDf(r.stdout) : null
129 } catch {
130 return null
131 }
132}
133
134async function checkDisk($: EngineInterface): Promise<void> {
135 const free = await diskFree($)
136 const low = free !== null && env.diskMinGb > 0 && free < env.diskMinGb
137 if (low && !(await read($, diskWarned))) {
138 $.ui.toast(`磁盘只剩 ${free} GB(满盘可能拖垮整个环境):先清 worktree / 构建缓存,/ops 看清单`)
139 }
140 await update($, diskWarned, () => low)
141}
142
143// Worktrees the dispatch scripts leave beside the repos (names matching userConfig worktreePrefixes)
144// plus Claude's own under every repo's .claude/worktrees.
145async function worktrees($: EngineInterface): Promise<string[]> {
146 const rd = await reposDir($)
147 const out: string[] = []
148 const repos: string[] = []
149 try {
150 for (const d of await $.fs.list(rd)) {
151 if (d.kind !== 'dir') continue
152 if (env.worktreePrefixes?.test(d.name)) out.push(d.name)
153 else repos.push(d.name)
154 }
155 } catch {
156 // No reposDir.
157 }
158 for (const repo of repos) {
159 try {
160 for (const d of await $.fs.list(`${rd}/${repo}/.claude/worktrees`)) if (d.kind === 'dir') out.push(`${repo}/${d.name}`)
161 } catch {
162 // That repo has none.
163 }
164 }
165 return out
166}
167
168async function runningRuns($: EngineInterface, h: string, now: number): Promise<string[]> {
169 const out: string[] = []
170 try {
171 for (const d of await $.fs.list(`${h}/.omd-lite/runs`)) {
172 if (d.kind !== 'dir' || now - d.mtimeMs > RUN_FRESH_MS) continue
173 const files = await $.fs.list(`${h}/.omd-lite/runs/${d.name}`).catch(() => [])
174 if (files.some(f => f.name === 'result.json')) continue
175 if (now - Math.max(d.mtimeMs, ...files.map(f => f.mtimeMs)) < RUN_FRESH_MS) out.push(d.name)
176 }
177 } catch {
178 // omd-lite never ran here.
179 }
180 return out
181}
182
183// The repo's DECISIONS-PENDING rows and the newest GOAL.md's §9.
184async function decisions($: EngineInterface): Promise<string[]> {
185 const root = await repoRoot($)
186 const out: string[] = []
187 const pending = await readText($, `${root}/docs/plan/DECISIONS-PENDING.md`)
188 if (pending) out.push(...openDecisionRows(pending))
189 const newest = await newestGoal($, root)
190 const goalMd = newest ? await readText($, newest) : null
191 if (goalMd) out.push(...openGoalNine(goalMd))
192 return out
193}
194
195// The newest docs/plan/<dir>/GOAL.md under root, by mtime.
196async function newestGoal($: EngineInterface, root: string): Promise<string | null> {
197 let newest: { path: string; at: number } | null = null
198 try {
199 for (const d of await $.fs.list(`${root}/docs/plan`)) {
200 if (d.kind !== 'dir') continue
201 for (const name of ['GOAL.md', 'GOAL-2.md', 'GOAL-3.md']) {
202 const p = `${root}/docs/plan/${d.name}/${name}`
203 const st = await $.fs.stat(p).catch(() => null)
204 if (st && (!newest || st.mtimeMs > newest.at)) newest = { path: p, at: st.mtimeMs }
205 }
206 }
207 } catch {
208 // No docs/plan here.
209 }
210 return newest?.path ?? null
211}
212
213async function handoffPath($: EngineInterface): Promise<string> {
214 const h = await home($)
215 const name = (await repoRoot($)).split('/').pop() || 'session'
216 const id = (await $.session.id()).slice(0, 8)
217 return `${h}/.claude/handoffs/${name}-${id}.md`
218}
219
220async function contextTokens($: EngineInterface): Promise<number> {
221 const usage = await $.session.usage().catch(() => null)
222 return usage?.context.tokens ?? 0
223}
224
225// The newest docs/plan/*/GOAL.md and every LOOP-GOAL.md under docs/plan (three levels down), for the resume prompt.
226async function goalFiles($: EngineInterface): Promise<string[]> {
227 const plan = `${await repoRoot($)}/docs/plan`
228 const newest = { path: '', at: -1 }
229 const loops: string[] = []
230 const walk = async (dir: string, depth: number): Promise<void> => {
231 const entries = await $.fs.list(dir).catch(() => [])
232 for (const d of entries) {
233 const p = `${dir}/${d.name}`
234 if (d.kind === 'dir') {
235 if (depth < 3) await walk(p, depth + 1)
236 } else if (d.name === 'LOOP-GOAL.md') {
237 loops.push(p)
238 } else if (d.name === 'GOAL.md' && depth === 1 && d.mtimeMs > newest.at) {
239 newest.path = p
240 newest.at = d.mtimeMs
241 }
242 }
243 }
244 await walk(plan, 0)
245 return [...(newest.path ? [newest.path] : []), ...loops]
246}
247
248const WRITER_TIMEOUT_MS = 6 * 60_000
249
250// Writes the omd checkpoint once more, synchronously, so the new session reads the state at clear time
251// rather than the last 100k boundary. Runs userConfig checkpointCommand (e.g. the same writer omd's
252// PreCompact hook runs) with {transcript} / {session} filled in; skipped when unset. Fails open.
253async function flushCheckpoint($: EngineInterface): Promise<boolean> {
254 if (!env.checkpointCommand.length) return false
255 const h = await home($)
256 const id = await $.session.id()
257 let transcript: string | null = null
258 for (const d of await $.fs.list(`${h}/.claude/projects`).catch(() => [])) {
259 const p = `${h}/.claude/projects/${d.name}/${id}.jsonl`
260 if (d.kind === 'dir' && (await $.fs.exists(p))) {
261 transcript = p
262 break
263 }
264 }
265 if (!transcript) return false
266 try {
267 const argv = await Promise.all(
268 env.checkpointCommand.map(a => expand($, a.replace(/\{transcript\}/g, transcript).replace(/\{session\}/g, id))),
269 )
270 const r = await $.process.run(argv, {
271 cwd: await $.session.cwd(),
272 timeoutMs: WRITER_TIMEOUT_MS,
273 })
274 return r.exitCode === 0
275 } catch {
276 return false
277 }
278}
279
280// omd continuity hook 把每个 session 的 checkpoint 写在 ~/.omd/projects/<项目>/session/<session id>/checkpoint.md;
281// 项目名由 omd 按 cwd 推,这里不复刻那套规则,直接按 session id 找。
282async function findCheckpoint($: EngineInterface, sessionId: string): Promise<string | null> {
283 const root = `${await home($)}/.omd/projects`
284 try {
285 for (const d of await $.fs.list(root)) {
286 if (d.kind !== 'dir') continue
287 const p = `${root}/${d.name}/session/${sessionId}/checkpoint.md`
288 if (await $.fs.exists(p)) return p
289 }
290 } catch {
291 // omd 没装或目录不在:只用交接
292 }
293 return null
294}
295
296// ---------------------------------------------------------------- relay: GOAL loop
297
298type Saved = { mode: RelayMode; goal: RelayGoal }
299
300const storeKey = async ($: EngineInterface): Promise<string> => `goal:${await $.session.cwd()}`
301
302// $.state for the session and the pane; $.store keyed by cwd so a restart or resume can re-arm.
303async function persist($: EngineInterface): Promise<void> {
304 const g = await read($, goal)
305 const key = await storeKey($)
306 if (!g) {
307 await $.store.delete(key).catch(() => undefined)
308 return
309 }
310 const saved: Saved = { mode: await read($, mode), goal: g }
311 await $.store.set(key, saved).catch(() => undefined)
312}
313
314async function setMode($: EngineInterface, m: RelayMode): Promise<void> {
315 await update($, mode, () => m)
316}
317
318async function saveGoal($: EngineInterface, g: RelayGoal): Promise<void> {
319 await update($, goal, () => g)
320 await persist($)
321}
322
323// A prompt relay sends: the turn it starts is a work turn relay reacts to.
324function submit($: EngineInterface, text: string): void {
325 void (async () => {
326 await update($, origin, () => 'work')
327 await $.prompt.submit({ text, asUser: true })
328 })().catch(() => $.ui.toast('relay:续跑 prompt 没发出去,/relay resume 重试'))
329}
330
331async function tell($: EngineInterface, text: string): Promise<void> {
332 $.ui.toast(text)
333 await $.ui.notify(text, { title: 'relay' }).catch(() => undefined)
334}
335
336// Stops driving: owner fork, budget, stuck, done. Never submits.
337async function halt($: EngineInterface, m: RelayMode, why: string): Promise<void> {
338 const g = await read($, goal)
339 await setMode($, m)
340 if (g) await saveGoal($, { ...g, lastEvent: why })
341 else await persist($)
342 await tell($, `relay:${why}`)
343}
344
345async function gitHead($: EngineInterface): Promise<string> {
346 try {
347 const r = await $.process.run(['git', 'rev-parse', 'HEAD'], { cwd: await $.session.cwd(), timeoutMs: 5000 })
348 return r.exitCode === 0 ? r.stdout.trim() : ''
349 } catch {
350 return ''
351 }
352}
353
354// omd-lite runs of this session still going: meta.json names the session and has no wall_seconds yet.
355async function sessionRuns($: EngineInterface): Promise<string[]> {
356 const root = `${await home($)}/.omd-lite/runs`
357 const id = await $.session.id()
358 const now = await $.clock.now()
359 const out: string[] = []
360 for (const d of await $.fs.list(root).catch(() => [])) {
361 if (d.kind !== 'dir' || now - d.mtimeMs > RUN_SCAN_MS) continue
362 const text = await readText($, `${root}/${d.name}/meta.json`)
363 if (!text) continue
364 try {
365 const meta = JSON.parse(text) as { claude_session?: string; wall_seconds?: number | null }
366 if (meta.claude_session === id && (meta.wall_seconds === undefined || meta.wall_seconds === null)) out.push(d.name)
367 } catch {
368 // A meta.json mid-write.
369 }
370 }
371 return out
372}
373
374// INV-12: what a phase=wait could be waiting on — running external runs plus running subagents.
375async function waitingOn($: EngineInterface): Promise<number> {
376 const subs = (await read($, agents)).length
377 const runs = await delegateRuns($)
378 if (runs) return subs + runs.filter(r => r.state === 'running').length
379 return subs + (await sessionRuns($)).length
380}
381
382async function runsSummary($: EngineInterface): Promise<string | null> {
383 const runs = await delegateRuns($)
384 if (runs) {
385 const by = new Map<string, number>()
386 for (const r of runs) by.set(r.state, (by.get(r.state) ?? 0) + 1)
387 return [...by].map(([s, n]) => `${s} ${n}`).join(' · ') || '无'
388 }
389 const mine = await sessionRuns($)
390 return mine.length ? `${mine.length} 个在跑:${mine.join(' · ')}` : '本 session 无在跑'
391}
392
393// C-5's slot rule, read for the pane: max(1, ⌊(MemAvailable_GB − 8) ÷ 2⌋).
394async function gateSlots($: EngineInterface): Promise<number | null> {
395 const text = await readText($, '/proc/meminfo')
396 const kb = text?.match(/^MemAvailable:\s+(\d+)\s*kB/m)?.[1]
397 return kb ? Math.max(1, Math.floor((Number(kb) / 1024 / 1024 - 8) / 2)) : null
398}
399
400async function refreshView($: EngineInterface, md: string | null, threshold: number): Promise<void> {
401 const v: RelayView = {
402 checklist: md ? checklist(md) : [],
403 ledger: md ? ledger(md).slice(-3) : [],
404 contextTokens: await contextTokens($),
405 threshold,
406 runs: await runsSummary($).catch(() => null),
407 gateSlots: await gateSlots($),
408 diskFreeGb: await diskFree($),
409 diskMinGb: env.diskMinGb,
410 }
411 await update($, view, () => v)
412}
413
414async function openPane($: EngineInterface, p: CommandPresentation | null): Promise<void> {
415 // D-24 / reference: unasked, a pane opens only where it would be a sidebar.
416 if (p && !(p.isFullscreen && p.columns >= PANE_MIN_COLUMNS)) return
417 await $.ui.open({ id: PANE, title: 'GOAL' }).catch(() => undefined)
418}
419
420async function contextFiles($: EngineInterface, cwd: string): Promise<ContextFile[]> {
421 const out: ContextFile[] = []
422 for (const name of ['CLAUDE.md', '_NEXT.md', 'docs/plan/TODO.md']) {
423 const text = await readText($, `${cwd}/${name}`)
424 if (text) out.push({ name, text })
425 }
426 const last = await newestGoal($, cwd)
427 const text = last ? await readText($, last) : null
428 if (last && text) out.push({ name: `上一份 GOAL(${last})`, text })
429 return out
430}
431
432async function freePath($: EngineInterface, dir: string): Promise<string> {
433 for (let i = 1; ; i++) {
434 const p = `${dir}/GOAL${i === 1 ? '' : `-${i}`}.md`
435 if (!(await $.fs.exists(p).catch(() => false))) return p
436 }
437}
438
439// The text the fork (or, before the first turn, one completion) writes for the GOAL.
440async function writeGoalText($: EngineInterface, prompt: string): Promise<string | null> {
441 const forked = await $.model.fork({ prompt })
442 if (forked.isAnswered) return forked.text
443 if (forked.reason !== 'nothing-to-fork') return null
444 const model = await $.session.model()
445 const done = await $.model.complete({ model, prompt, maxTokens: 8000 })
446 return done.isAnswered ? done.text : null
447}
448
449function freshGoal(path: string, md: string, sessionId: string, now: number): RelayGoal {
450 return {
451 path,
452 title: goalTitle(md),
453 phase: 'plan',
454 item: currentItem(md),
455 turns: 0,
456 nudges: 0,
457 stuckKey: '',
458 stuckHead: '',
459 stuckCount: 0,
460 waitGen: 0,
461 efforts: effortTable(md),
462 startedAt: now,
463 sessionId,
464 lastEvent: '已激活',
465 }
466}
467
468type Cfg = { threshold: number; delayMs: number; heartbeatMin: number; maxTurns: number }
469
470// Clears only when no subagent and no turn is running; checks again until the cap.
471async function clearWhenQuiet($: EngineInterface, cfg: Cfg, path: string, since: number): Promise<void> {
472 const h = await read($, handoff)
473 if (h.phase !== 'ready') return
474 const out = (await read($, agents)).length
475 if (out > 0 || (await read($, turnRunning))) {
476 if (Date.now() - since > WAIT_CAP_MS) {
477 await update($, handoff, () => ({ ...IDLE, snoozeTokens: h.snoozeTokens }))
478 $.ui.toast(`relay:等了 30 分钟仍有 ${out} 个子 agent 或一轮在跑,没有自动 /clear;交接在 ${path}`)
479 return
480 }
481 $.clock.after(RECHECK_MS, () => void clearWhenQuiet($, cfg, path, since))
482 return
483 }
484 // 补存 checkpoint 要几十秒到几分钟:补存时仍是 ready,期间用户发消息照样能取消;
485 // 补完再看一次 —— 被取消了就不 clear,又有一轮 / 子 agent 起来了就回到等安静。
486 const flushing = env.checkpointCommand.length > 0
487 if (flushing) $.ui.toast('relay:先补存一次 omd checkpoint,写完就 /clear')
488 const flushed = await flushCheckpoint($)
489 if ((await read($, handoff)).phase !== 'ready') return
490 if ((await read($, agents)).length > 0 || (await read($, turnRunning))) {
491 $.clock.after(RECHECK_MS, () => void clearWhenQuiet($, cfg, path, since))
492 return
493 }
494 await update($, handoff, () => IDLE)
495 if (flushing && !flushed) $.ui.toast('relay:checkpoint 补存没成功,照旧用上一份')
496 const checkpoint = await findCheckpoint($, await $.session.id())
497 const goals = await goalFiles($).catch(() => [])
498 // D-20: carry the active GOAL across the clear and re-arm it in the new session.
499 const m = await read($, mode)
500 const g = await read($, goal)
501 const carry = g && (m === 'running' || m === 'waiting-owner') ? { mode: m, goal: g } : null
502 await update($, selfClear, () => true)
503 await $.command.run({ command: 'clear' })
504 await update($, selfClear, () => false)
505 if (carry) {
506 await setMode($, carry.mode)
507 await saveGoal($, { ...carry.goal, sessionId: await $.session.id(), nudges: 0, lastEvent: '交接后接上' })
508 }
509 await update($, origin, () => 'work')
510 await $.prompt.submit({
511 text: resumePrompt(path, checkpoint, goals.filter(p => p !== carry?.goal.path), carry ? { path: carry.goal.path, phase: carry.goal.phase } : null),
512 asUser: true,
513 })
514}
515
516async function statusText($: EngineInterface, cfg: Cfg): Promise<string> {
517 const m = await read($, mode)
518 const g = await read($, goal)
519 if (!g) return `relay:${m},没有激活的 GOAL。/goal <一句话> 开一个。`
520 return [
521 `relay:${m} · ${g.title || g.path}`,
522 `GOAL ${g.path}`,
523 `phase ${g.phase} · 当前项 ${g.item || '(清单已全勾)'} · 轮次 ${g.turns}/${cfg.maxTurns} · 连续违规 ${g.nudges}`,
524 `最近:${g.lastEvent}`,
525 ].join('\n')
526}
527
528async function startGoal($: EngineInterface, cfg: Cfg, line: string, p: CommandPresentation | null): Promise<void> {
529 $.ui.status('relay:正在写 GOAL.md…')
530 try {
531 const cwd = await $.session.cwd()
532 const now = await $.clock.now()
533 const day = mmdd(now)
534 const raw = await writeGoalText($, goalPrompt(line, await contextFiles($, cwd), day))
535 if (!raw) {
536 await tell($, 'relay:GOAL 没写出来(模型没有回答),没有激活;再 /goal 一次')
537 return
538 }
539 const md = normalizeGoal(raw, line)
540 const path = await freePath($, `${cwd}/docs/plan/${day}`)
541 await $.fs.write(path, md)
542 const prior = await read($, goal)
543 if (prior && prior.path !== path && (await read($, mode)) === 'running') $.ui.toast(`relay:新 GOAL 取代 ${prior.path}`)
544 await update($, goal, () => freshGoal(path, md, '', now))
545 const sid = await $.session.id()
546 await update($, goal, g => (g ? { ...g, sessionId: sid } : g))
547 await setMode($, 'running')
548 await persist($)
549 await refreshView($, md, cfg.threshold)
550 await openPane($, p)
551 submit($, kickoffPrompt(path))
552 } finally {
553 $.ui.status(undefined)
554 }
555}
556
557// INV-2: relay runs every acceptance command itself before it stops on done.
558async function runAcceptance($: EngineInterface, cfg: Cfg, g: RelayGoal, md: string): Promise<void> {
559 const list = acceptance(md)
560 if (!list.length) {
561 await saveGoal($, { ...g, lastEvent: 'done 但验收节无命令' })
562 submit($, noAcceptancePrompt(g.path))
563 return
564 }
565 const cwd = await $.session.cwd()
566 for (const a of list) {
567 let got: number | string
568 let out: string
569 try {
570 const r = await $.process.run(['bash', '-c', a.command], { cwd, timeoutMs: ACCEPT_TIMEOUT_MS })
571 got = r.exitCode
572 out = `${r.stdout}${r.stderr ? `\n${r.stderr}` : ''}`
573 } catch (err) {
574 got = '超时或无法运行'
575 out = String(err)
576 }
577 if (got !== a.exitCode) {
578 if ((await read($, mode)) !== 'running') return
579 await saveGoal($, { ...g, lastEvent: `验收没过:${short(a.command, 40)}` })
580 submit($, acceptFailPrompt(g.path, a, got, tail(out)))
581 return
582 }
583 }
584 if ((await read($, mode)) !== 'running') return
585 await halt($, 'done', `GOAL 验收全过(${list.length} 条):${g.title || g.path}`)
586}
587
588// One main-loop work turn ended while relay drives: read its RELAY line and act (C-2).
589async function relayTurn($: EngineInterface, cfg: Cfg, answer: string): Promise<void> {
590 if ((await read($, mode)) !== 'running') return
591 if ((await read($, origin)) !== 'work') return
592 const g0 = await read($, goal)
593 if (!g0) return
594 const md = await readText($, g0.path)
595 if (md === null) {
596 await halt($, 'waiting-owner', `GOAL 文件读不到:${g0.path}`)
597 return
598 }
599 const g: RelayGoal = { ...g0, turns: g0.turns + 1, title: goalTitle(md) || g0.title, item: currentItem(md), efforts: effortTable(md) }
600 await refreshView($, md, cfg.threshold)
601 if (g.turns > cfg.maxTurns) {
602 await saveGoal($, g)
603 await halt($, 'waiting-owner', `GOAL 已跑满 ${cfg.maxTurns} 轮(maxTurns),停下等你`)
604 return
605 }
606 const r = parseRelay(answer)
607 // D-14: no RELAY line, or a continue that ends on a question / options.
608 if (r.kind === 'none' || (r.kind === 'continue' && endsAsking(answer))) {
609 const nudges = g.nudges + 1
610 await saveGoal($, { ...g, nudges, lastEvent: `违规结尾 ${nudges}` })
611 if (nudges >= 2) await halt($, 'waiting-owner', '连续两轮没有 RELAY 行(或停在问句 / 选项),已停;回一句话就接着跑')
612 else submit($, nudgePrompt(g.path))
613 return
614 }
615 const ok: RelayGoal = { ...g, nudges: 0 }
616 switch (r.kind) {
617 case 'continue': {
618 const phase = r.phase ?? ok.phase
619 if (phase === 'wait') {
620 await saveGoal($, { ...ok, phase, lastEvent: 'wait' })
621 if ((await waitingOn($)) === 0) {
622 submit($, waitInvalidPrompt(ok.path))
623 return
624 }
625 const gen = ok.waitGen
626 $.clock.after(cfg.heartbeatMin * 60_000, () => void heartbeat($, cfg, gen))
627 return
628 }
629 const key = `${phase}|${ok.item}`
630 const head = await gitHead($)
631 const count = key === ok.stuckKey && head === ok.stuckHead ? ok.stuckCount + 1 : 1
632 const next: RelayGoal = { ...ok, phase, stuckKey: key, stuckHead: head, stuckCount: count, lastEvent: `continue ${phase}` }
633 await saveGoal($, next)
634 if (count >= STUCK_TURNS) {
635 await halt($, 'waiting-owner', `卡住:「${short(ok.item || phase, 40)}」${STUCK_TURNS} 轮没有新提交`)
636 return
637 }
638 submit($, continuePrompt(next.path, phase, next.item))
639 return
640 }
641 case 'done':
642 await saveGoal($, { ...ok, lastEvent: '验收中' })
643 await runAcceptance($, cfg, ok, md)
644 return
645 case 'owner':
646 await saveGoal($, ok)
647 await halt($, 'waiting-owner', `等你拍板:${r.question || '(没写问题)'}`)
648 return
649 case 'replan': {
650 const now = await $.clock.now()
651 const item = r.item || ok.item
652 const updated = appendLedger(md, ledgerLine(now, r.reason || '(未写原因)', item))
653 await $.fs.write(ok.path, updated)
654 const next: RelayGoal = { ...ok, item, stuckKey: '', stuckCount: 0, lastEvent: `改道 → ${short(item, 30)}` }
655 await saveGoal($, next)
656 await refreshView($, updated, cfg.threshold)
657 submit($, continuePrompt(next.path, next.phase, item, `改道已记账(${r.reason} → ${item}),先做它。`))
658 return
659 }
660 }
661}
662
663async function heartbeat($: EngineInterface, cfg: Cfg, gen: number): Promise<void> {
664 const g = await read($, goal)
665 if ((await read($, mode)) !== 'running' || !g || g.waitGen !== gen || (await read($, turnRunning))) return
666 submit($, heartbeatPrompt(g.path, cfg.heartbeatMin))
667}
668
669async function stopRelay($: EngineInterface, cfg: Cfg): Promise<void> {
670 await setMode($, 'idle')
671 const g = await read($, goal)
672 if (g) await saveGoal($, { ...g, lastEvent: '已停(/relay stop)' })
673}
674
675async function resumeRelay($: EngineInterface, cfg: Cfg): Promise<string> {
676 let g = await read($, goal)
677 if (!g) {
678 const saved = (await $.store.get(await storeKey($)).catch(() => undefined)) as Saved | undefined
679 g = saved?.goal ?? null
680 }
681 if (!g) return 'relay:没有可续的 GOAL。/goal <一句话> 开一个。'
682 if (!(await $.fs.exists(g.path).catch(() => false))) return `relay:GOAL 文件不在了:${g.path}`
683 await setMode($, 'running')
684 await saveGoal($, { ...g, nudges: 0, stuckCount: 0, sessionId: await $.session.id(), lastEvent: '续跑(/relay resume)' })
685 submit($, continuePrompt(g.path, g.phase, g.item))
686 return `relay:续跑 ${g.path}(phase=${g.phase})`
687}
688
689// A restart or `claude --resume`: the same session re-arms; another session in this cwd is offered it.
690async function restore($: EngineInterface, cfg: Cfg): Promise<void> {
691 const saved = (await $.store.get(await storeKey($)).catch(() => undefined)) as Saved | undefined
692 if (!saved?.goal || (saved.mode !== 'running' && saved.mode !== 'waiting-owner')) return
693 if (!(await $.fs.exists(saved.goal.path).catch(() => false))) return
694 await update($, goal, () => saved.goal)
695 const sid = await $.session.id()
696 if (saved.goal.sessionId === sid) {
697 await setMode($, saved.mode)
698 $.ui.toast(`relay:接上 GOAL ${saved.goal.path}(${saved.mode})`)
699 } else {
700 $.ui.toast(`relay:${saved.goal.path} 还没做完;/relay resume 在这个 session 接着跑`)
701 }
702}
703
704// The handoff's part of a turn's end; true when the turn belongs to the handoff and relay keeps out.
705async function handoffTurn($: EngineInterface, cfg: Cfg, answer: string): Promise<boolean> {
706 const h = await read($, handoff)
707 if (h.phase === 'ready') return true
708 if (h.phase !== 'armed' || !isReadyAnswer(answer)) return false
709 const out = (await read($, agents)).length
710 if (out > 0) {
711 await update($, handoff, () => ({ ...h, waitAgents: true }))
712 $.ui.toast(`relay:交接写了,但还有 ${out} 个子 agent 没返回;等它们回来、交接重写后再 clear`)
713 return true
714 }
715 const st = await $.fs.stat(h.path).catch(() => null)
716 if (!st || st.mtimeMs < h.armedAt - 1000) {
717 $.ui.toast(`relay:说交接写好了,但 ${h.path} 没有新内容,不自动 /clear`)
718 return true
719 }
720 await update($, handoff, () => ({ ...h, phase: 'ready' }))
721 $.ui.toast(`relay:交接已写到 ${h.path}。${Math.round(cfg.delayMs / 1000)} 秒后自动 /clear 并接着做;发任何消息即取消`)
722 const since = Date.now()
723 $.clock.after(cfg.delayMs, () => void clearWhenQuiet($, cfg, h.path, since))
724 return true
725}
726
727
728export const register: Register = (on, options) => {
729 const threshold = Number(options.thresholdTokens ?? 500_000)
730 const delayMs = Math.max(5, Number(options.delaySeconds ?? 30)) * 1000
731 const heartbeatMin = Math.max(1, Number(options.heartbeatMinutes ?? 25))
732 const maxTurns = Math.max(1, Number(options.maxTurns ?? 200))
733 const agentMap = agentEffortMap(String(options.subagentEffort ?? ''))
734 const cfg: Cfg = { threshold, delayMs, heartbeatMin, maxTurns }
735 env.reposDir = String(options.reposDir ?? '').trim()
736 const prefixes = csv(String(options.worktreePrefixes ?? 'omdl-,agent-'))
737 env.worktreePrefixes = prefixes.length ? new RegExp(`^(${prefixes.map(escapeRe).join('|')})`) : null
738 env.diskPath = String(options.diskPath ?? '').trim()
739 env.diskMinGb = Math.max(0, Number(options.diskMinGB ?? 50))
740 env.checkpointCommand = String(options.checkpointCommand ?? '').trim().split(/\s+/).filter(Boolean)
741
742 on('session.start', async ($, e, next) => {
743 await $.command.register({ name: 'ops', description: '列出待拍板事项、worktree、在跑的 omd-lite run 和磁盘余量' })
744 await $.command.register({ name: 'relay', description: 'relay:GOAL 续跑 status | stop | resume | open', argumentHint: 'status|stop|resume|open' })
745 void checkDisk($)
746 void restore($, cfg).catch(() => undefined)
747 $.clock.every(DISK_TICK_MS, () => void checkDisk($))
748 return next(e)
749 })
750
751 on('session.end', async ($, e, next) => {
752 // A /clear the user typed drops the loop (the GOAL stays for /relay resume); relay's own keeps it.
753 if (e.reason === 'clear' && !(await read($, selfClear)) && (await read($, mode)) === 'running') {
754 await setMode($, 'idle')
755 await persist($)
756 }
757 return next(e)
758 })
759
760 on('classic.SubagentStart', async ($, e, next) => {
761 await update($, agents, list => [...list.filter(id => id !== e.agent_id), e.agent_id])
762 await update($, agentTypes, m => ({ ...m, [e.agent_id]: e.agent_type }))
763 return next(e)
764 })
765
766 on('classic.SubagentStop', async ($, e, next) => {
767 await update($, agents, list => list.filter(id => id !== e.agent_id))
768 await update($, agentTypes, m => Object.fromEntries(Object.entries(m).filter(([id]) => id !== e.agent_id)))
769 return next(e)
770 })
771
772 // Only the main loop raises turn.start; a subagent's run does not.
773 on('turn.start', async ($, e, next) => {
774 await update($, turnRunning, () => true)
775 return next(e)
776 })
777
778 on('prompt.submit', async ($, e, next) => {
779 const work = isWork(e.origin)
780 if (!e.turnId) await update($, origin, () => (work ? 'work' : 'other'))
781 // INV-4: the owner answers the owner fork by typing; that resumes the loop.
782 if (FROM_PERSON.has(e.origin.kind)) {
783 const g = await read($, goal)
784 const m = await read($, mode)
785 if (g && (m === 'waiting-owner' || (m === 'running' && g.nudges > 0))) {
786 await setMode($, 'running')
787 await saveGoal($, { ...g, nudges: 0, stuckCount: 0, lastEvent: 'owner 回复,继续' })
788 }
789 }
790
791 if (threshold <= 0 || !work) return next(e)
792 const h = await read($, handoff)
793
794 // The subagents that held the handoff back are all in: have it rewritten with their results.
795 if (h.phase === 'armed' && h.waitAgents && (await read($, agents)).length === 0) {
796 await update($, handoff, () => ({ ...h, waitAgents: false, armedAt: Date.now() }))
797 const note = `[relay] 子 agent 都已返回。把它们的结果并进交接,重写 ${h.path},写完回复最后一行单独写 HANDOFF-READY。`
798 return next({ ...e, context: [...(e.context ?? []), note] })
799 }
800
801 // A message from the person while the clear waits: keep the session.
802 if (h.phase === 'ready' && FROM_PERSON.has(e.origin.kind)) {
803 const tokens = await contextTokens($)
804 await update($, handoff, () => ({ ...IDLE, snoozeTokens: tokens + SNOOZE_TOKENS }))
805 $.ui.toast(`relay:已取消自动 /clear,交接留在 ${h.path};上下文再长 100k 会再问一次`)
806 return next(e)
807 }
808 if (h.phase !== 'idle') return next(e)
809
810 const tokens = await contextTokens($)
811 if (tokens < threshold || tokens < h.snoozeTokens) return next(e)
812 const path = await handoffPath($)
813 await update($, handoff, () => ({ ...h, phase: 'armed', path, armedAt: Date.now() }))
814 return next({ ...e, context: [...(e.context ?? []), handoffNote(tokens, path)] })
815 // Never hold a prompt back over a fault here.
816 }).catch(($, e, next) => next(e))
817
818 on('turn.complete', async ($, e, next) => {
819 const done = await next(e)
820 if (e.agentId) return done
821 await update($, turnRunning, () => false)
822 await update($, goal, g => (g ? { ...g, waitGen: g.waitGen + 1 } : g))
823 if (e.isAborted || e.reason !== 'answer') return done
824 if (await handoffTurn($, cfg, e.answer)) return done
825 void relayTurn($, cfg, e.answer).catch(() => $.ui.toast('relay:这一轮的 RELAY 处理出错,/relay status 看状态'))
826 return done
827 })
828
829 // INV-9 / D-12 / D-13: effort per phase on the main loop, per agent type for subagents;
830 // after each switch, the cache the next request read is recorded (observation only).
831 on('turn.step', async function* ($, e, next) {
832 let want: Effort | null = null
833 try {
834 if (e.agentId) {
835 const types = await read($, agentTypes)
836 want = pickEffort({ isRunning: true, phase: '', table: {}, isSubagent: true, agentType: types[e.agentId], agentMap })
837 } else {
838 const g = await read($, goal)
839 if (g) want = pickEffort({ isRunning: (await read($, mode)) === 'running', phase: g.phase, table: g.efforts as Record<string, Effort>, isSubagent: false, agentMap })
840 }
841 } catch {
842 want = null
843 }
844 const sent = want ?? e.effort
845 const loop = e.agentId ?? 'main'
846 const prev = (await read($, lastEffort))[loop]
847 const result = want && want !== e.effort ? yield* next({ ...e, effort: want }) : yield* next(e)
848 if (sent !== undefined && String(sent) !== prev) {
849 await update($, lastEffort, m => ({ ...m, [loop]: String(sent) }))
850 if (prev !== undefined && result.usage) {
851 const entry = { at: await $.clock.now(), loop, from: prev, to: String(sent), cacheRead: result.usage.cache_read_input_tokens, input: result.usage.input_tokens }
852 const log = ((await $.store.get('effortSwitches').catch(() => undefined)) as unknown[] | undefined) ?? []
853 await $.store.set('effortSwitches', [...log, entry].slice(-500)).catch(() => undefined)
854 }
855 }
856 return result
857 })
858
859 // Claude Code ships a /goal of its own (a Stop-hook condition); relay answers it in its place (D-19).
860 on('command.describe', { command: 'goal' }, async ($, e, next) => ({
861 ...(await next(e)),
862 description: 'relay:把一句话写成 GOAL.md 并立刻开跑(/goal clear 停)',
863 argumentHint: '<一句话目标> | clear',
864 }))
865
866 on('command.run', { command: 'goal' }, async ($, e, next) => {
867 const line = e.args.trim()
868 if (!line) return { text: await statusText($, cfg) }
869 if (STOP_WORDS.has(line.toLowerCase())) {
870 await stopRelay($, cfg)
871 await next(e).catch(() => undefined)
872 return { text: 'relay:已停自动续跑(GOAL 文件保留,/relay resume 接着跑)' }
873 }
874 void startGoal($, cfg, line, e.presentation).catch(() => $.ui.toast('relay:写 GOAL 出错,没有激活'))
875 return { text: `relay:正在把「${short(line, 60)}」写成 GOAL.md,写完立刻开工。` }
876 }).catch(($, e, next) => (next.called ? next(e) : { text: 'relay:/goal 出错,没有激活' }))
877
878 on('command.run', { command: 'relay' }, async ($, e) => {
879 const arg = e.args.trim().toLowerCase()
880 if (arg === 'stop') {
881 await stopRelay($, cfg)
882 return { text: 'relay:已停自动续跑(GOAL 文件保留,/relay resume 接着跑)' }
883 }
884 if (arg === 'resume') return { text: await resumeRelay($, cfg) }
885 if (arg === 'open') {
886 const g = await read($, goal)
887 await refreshView($, g ? await readText($, g.path) : null, threshold)
888 const r = await $.ui.open({ id: PANE, title: 'GOAL' })
889 return { text: r.isPlaced ? 'relay:GOAL 面板已打开' : 'relay:面板等终端变宽后显示' }
890 }
891 return { text: await statusText($, cfg) }
892 })
893
894 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
895 const { Box, Text } = $.ui.resolve(e)
896 const rows = paneRows({
897 mode: await read($, mode),
898 goal: await read($, goal),
899 view: await read($, view),
900 effort: (await read($, lastEffort)).main,
901 maxTurns,
902 columns: e.props.bodyColumns,
903 })
904 const color = { accent: 'claude', success: 'success', warning: 'warning', error: 'error', dim: undefined } as const
905 return h(
906 Box,
907 { flexDirection: 'column' },
908 ...rows.map(r =>
909 h(Text, { wrap: 'truncate-end', ...(r.tone === 'dim' ? { dimColor: true } : r.tone ? { color: color[r.tone] } : {}), ...(r.isBold ? { bold: true } : {}) }, r.text || ' '),
910 ),
911 ) as RenderElement
912 })
913
914 on('command.run', { command: 'ops' }, async $ => {
915 const h = await home($)
916 const o: Ops = {
917 diskFreeGb: await diskFree($),
918 worktrees: await worktrees($),
919 decisions: await decisions($).catch(() => []),
920 runningRuns: await runningRuns($, h, Date.now()),
921 }
922 await update($, ops, () => o)
923 const ho = await read($, handoff)
924 const lines = [
925 `磁盘剩余(${await diskPath($)}):${o.diskFreeGb ?? '读不到'} GB · 上下文 ${Math.round((await contextTokens($)) / 1000)}k / 交接阈值 ${Math.round(threshold / 1000)}k(${ho.phase})`,
926 '',
927 `待拍板(${o.decisions.length}):`,
928 ...o.decisions.map((d, i) => ` ${i + 1}. ${short(d)}`),
929 '',
930 `在跑的 omd-lite run(${o.runningRuns.length}):${o.runningRuns.join(' · ') || '无'}`,
931 `worktree(${o.worktrees.length}):${o.worktrees.join(' · ') || '无'}`,
932 ]
933 return { text: lines.join('\n') }
934 })
935}
936hooks/effort.ts 47 lines1// Effort per phase (D-12 / D-13 / INV-9): the GOAL's effort table for the main loop,
2// a userConfig map by agent type for subagents.
3
4import { section } from './goal'
5
6export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
7export const EFFORTS: readonly Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
8
9export const DEFAULT_EFFORT: Readonly<Record<string, Effort>> = { plan: 'xhigh', build: 'medium', review: 'high', chore: 'low' }
10
11const PAIR = /([A-Za-z][\w-]*|[\p{Script=Han}]+)\s*(?:=|→|->|:|:|\|)\s*(low|medium|high|xhigh|max)\b/giu
12
13function pairs(text: string): Record<string, Effort> {
14 const out: Record<string, Effort> = {}
15 for (const m of text.matchAll(PAIR)) {
16 const key = m[1]?.trim()
17 const effort = m[2]?.toLowerCase() as Effort | undefined
18 if (key && effort && key.toLowerCase() !== 'phase' && key.toLowerCase() !== 'effort') out[key] = effort
19 }
20 return out
21}
22
23// `- plan=xhigh` · `build → medium` · `| review | high |`; the defaults fill phases the table leaves out.
24export function effortTable(goalMd: string): Record<string, Effort> {
25 const table: Record<string, Effort> = {}
26 for (const [k, v] of Object.entries(pairs(section(goalMd, 'effort').join('\n')))) table[k.toLowerCase()] = v
27 return { ...DEFAULT_EFFORT, ...table }
28}
29
30// userConfig `subagentEffort`: `Explore=low, Plan=high, general-purpose=medium`.
31export const agentEffortMap = (config: string): Record<string, Effort> => pairs(config.replace(/,/g, '\n'))
32
33// What a request should ask for: the phase's effort on the main loop while relay runs;
34// the agent type's mapped effort for a subagent; null leaves the request as it is.
35export function pickEffort(o: {
36 isRunning: boolean
37 phase: string
38 table: Readonly<Record<string, Effort>>
39 isSubagent: boolean
40 agentType?: string
41 agentMap: Readonly<Record<string, Effort>>
42}): Effort | null {
43 if (o.isSubagent) return o.agentType ? (o.agentMap[o.agentType] ?? null) : null
44 if (!o.isRunning) return null
45 return o.table[o.phase.toLowerCase()] ?? null
46}
47hooks/goal.ts 234 lines1// Pure readers and writers of GOAL.md (C-1), the RELAY line (C-2) and the prompts relay sends.
2
3export const SECTIONS = ['原话', '终点状态', '验收', '清单', '抢占规则', '停机条件', '不许停的情况', 'effort', '改道账'] as const
4export type SectionName = (typeof SECTIONS)[number]
5
6// C-1, verbatim: the shape every GOAL.md relay writes follows.
7export const GOAL_TEMPLATE = [
8 '# GOAL: <一句话终点>',
9 '## 原话 用户原话,逐字',
10 '## 终点状态 做完时世界长什么样(可观察,不写过程)',
11 '## 验收 每行一条可跑命令 + 期望;relay 在 done 时逐条跑',
12 '## 清单 - [ ] 项(每项勾选要带证据:提交号/命令输出路径)',
13 '## 抢占规则 命中即改道、不停:P0 类列表(本仓具体化,如:p95 延迟 > X ms)',
14 '## 停机条件 done:验收全过 · owner:列出哪些属 owner 岔口 · stuck:同一项 3 轮无新证据 · 预算:轮数/墙钟上限',
15 '## 不许停的情况 整理完、阶段性总结、「要不要继续」、给选项 —— 都不是停机条件',
16 '## effort phase → effort(默认 plan=xhigh · build=medium · review=high · chore=low)',
17 '## 改道账 relay 追加:时间 · 触发 · 从哪项转到哪项',
18].join('\n')
19
20const sectionOf = (heading: string): SectionName | null => {
21 const name = heading.replace(/^##\s*/, '').trim()
22 return SECTIONS.find(s => new RegExp(`^${s}(?![\\p{L}\\p{N}_])`, 'iu').test(name)) ?? null
23}
24
25// Body lines of one `## <name>` section (heading excluded), [] when absent.
26export function section(md: string, name: SectionName): string[] {
27 const out: string[] = []
28 let inside = false
29 for (const line of md.split('\n')) {
30 if (/^##\s/.test(line)) inside = sectionOf(line) === name
31 else if (/^#\s/.test(line)) inside = false
32 else if (inside) out.push(line)
33 }
34 return out
35}
36
37export const goalTitle = (md: string): string => md.match(/^#\s*GOAL\s*[::]\s*(.+)$/m)?.[1]?.trim() ?? ''
38
39export type Check = { isDone: boolean; text: string }
40
41export function checklist(md: string): Check[] {
42 const out: Check[] = []
43 for (const line of section(md, '清单')) {
44 const m = line.match(/^\s*[-*]\s*\[([ xX✓])\]\s*(.+)$/)
45 if (m) out.push({ isDone: m[1] !== ' ', text: (m[2] ?? '').trim() })
46 }
47 return out
48}
49
50export const currentItem = (md: string): string => checklist(md).find(c => !c.isDone)?.text ?? ''
51
52export const ledger = (md: string): string[] => section(md, '改道账').filter(l => /^\s*[-*]\s/.test(l)).map(l => l.trim())
53
54export type Acceptance = { command: string; exitCode: number; line: string }
55
56// Each list line of 验收 with a `code span` is a command; an expectation written as
57// `exit N` / `退出码 N` outside the span is the exit code wanted, 0 when unstated.
58export function acceptance(md: string): Acceptance[] {
59 const out: Acceptance[] = []
60 for (const line of section(md, '验收')) {
61 if (!/^\s*(?:[-*]|\d+[.)])\s/.test(line)) continue
62 const m = line.match(/`([^`]+)`/)
63 if (!m || !m[1]?.trim()) continue
64 const rest = line.replace(m[0], ' ')
65 const want = rest.match(/(?:exit(?:\s*code)?|退出码|退)\s*[=::]?\s*(\d+)/i)
66 out.push({ command: m[1].trim(), exitCode: want ? Number(want[1]) : 0, line: line.trim() })
67 }
68 return out
69}
70
71// Appends one ledger line at the end of 改道账 (adding the section when missing).
72export function appendLedger(md: string, entry: string): string {
73 const lines = md.replace(/\s+$/, '').split('\n')
74 const start = lines.findIndex(l => /^##\s/.test(l) && sectionOf(l) === '改道账')
75 if (start < 0) return `${lines.join('\n')}\n\n## 改道账\n${entry}\n`
76 let end = lines.length
77 for (let i = start + 1; i < lines.length; i++) {
78 if (/^#{1,2}\s/.test(lines[i] ?? '')) {
79 end = i
80 break
81 }
82 }
83 let at = end
84 while (at > start + 1 && (lines[at - 1] ?? '').trim() === '') at--
85 lines.splice(at, 0, entry)
86 return `${lines.join('\n')}\n`
87}
88
89const pad = (n: number): string => String(n).padStart(2, '0')
90export const mmdd = (ms: number): string => {
91 const d = new Date(ms)
92 return `${pad(d.getMonth() + 1)}.${pad(d.getDate())}`
93}
94export const hhmm = (ms: number): string => {
95 const d = new Date(ms)
96 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
97}
98export const ledgerLine = (ms: number, reason: string, item: string): string => `- ${mmdd(ms)} ${hhmm(ms)} · 触发 ${reason} · → ${item}`
99
100export type Relay =
101 | { kind: 'continue'; phase: string | null }
102 | { kind: 'done' }
103 | { kind: 'owner'; question: string }
104 | { kind: 'replan'; reason: string; item: string }
105 | { kind: 'none' }
106
107const meaningful = (answer: string): string[] =>
108 answer
109 .split('\n')
110 .map(l => l.trim())
111 .filter(l => l !== '' && !/^`{3,}\w*$/.test(l))
112
113const bare = (line: string): string => line.replace(/^[>*_`\s]+|[*_`\s]+$/g, '')
114
115// C-2: the RELAY line must be the answer's last line.
116export function parseRelay(answer: string): Relay {
117 const last = meaningful(answer).pop()
118 const m = last ? bare(last).match(/^RELAY\s*[::]\s*(continue|done|owner|replan)\b\s*(.*)$/i) : null
119 if (!m) return { kind: 'none' }
120 const rest = (m[2] ?? '').trim()
121 switch (m[1]?.toLowerCase()) {
122 case 'continue':
123 return { kind: 'continue', phase: rest.match(/phase\s*=\s*([\w-]+)/i)?.[1]?.toLowerCase() ?? null }
124 case 'done':
125 return { kind: 'done' }
126 case 'owner':
127 return { kind: 'owner', question: rest }
128 default: {
129 const [reason = '', ...item] = rest.split(/\s*(?:->|→)\s*/)
130 return { kind: 'replan', reason: reason.trim(), item: item.join(' → ').trim() }
131 }
132 }
133}
134
135const ASKING = /(要不要|是否继续|还是说|你来定|你选|选哪|请选择|需要我|要我继续|shall i|should i|want me to|which option)/i
136const OPTION = /^(?:[-*]\s*)?(?:[A-D][).、:]|选项\s*[A-D1-9]|方案\s*[A-D1-9])/
137
138// D-14: the answer (before its RELAY line) ends on a question or a menu of options.
139export function endsAsking(answer: string): boolean {
140 const lines = meaningful(answer)
141 if (lines.length && /^RELAY\s*[::]/i.test(bare(lines[lines.length - 1] ?? ''))) lines.pop()
142 const last = lines[lines.length - 1]
143 if (!last) return false
144 if (/[??]\s*$/.test(last) || ASKING.test(last)) return true
145 return lines.slice(-3).filter(l => OPTION.test(l)).length >= 2
146}
147
148// The tail of a command's output for the failing-acceptance prompt.
149export const tail = (text: string, n = 40): string => text.replace(/\s+$/, '').split('\n').slice(-n).join('\n')
150
151export const PROTOCOL = [
152 '每轮回答最后一行单独写一行 RELAY(relay 机械解析):',
153 '`RELAY: continue phase=<plan|build|review|chore|wait>` 还有活 · `RELAY: done` 认为做完(relay 亲自跑验收)·',
154 '`RELAY: owner <一句问题>` 只限终点本身要改 · `RELAY: replan <原因> -> <新项>` 命中抢占规则改道(不停)。',
155 'phase=wait 只在确有在跑的 omd-lite run / 子 agent 时写。整理完、阶段总结、问「要不要继续」、给选项都不是停机条件。',
156].join('\n')
157
158export const kickoffPrompt = (path: string): string =>
159 `[relay] GOAL 已写好并激活:${path}。先读它,从「清单」最上面未勾项开工,按 GOAL 推进,直到「验收」命令全过。\n${PROTOCOL}`
160
161const WAKE_STEP = '看在跑的 run / 子 agent 结果 → 读 diff 对任务书 → 合并 → 推进最上面未勾项 → 更新 GOAL 清单(带证据)与 _NEXT.md'
162
163export const continuePrompt = (path: string, phase: string, item: string, note = ''): string =>
164 `[relay] 按 GOAL 继续:${path}(phase=${phase}${item ? `;当前项:${item}` : ''})。${note}每次醒来:${WAKE_STEP}。末行照写 RELAY 行。`
165
166export const nudgePrompt = (path: string): string =>
167 `[relay] 上一轮没有以 RELAY 行收尾,或停在了问句 / 选项上。整理、总结、问要不要继续都不是停机条件:按 GOAL ${path} 直接做下一步;只有终点本身要 owner 改才写 RELAY: owner <问题>。\n${PROTOCOL}`
168
169export const waitInvalidPrompt = (path: string): string =>
170 `[relay] 你写了 phase=wait,但现在没有在跑的 omd-lite run,也没有在跑的子 agent:没有在等的东西,wait 不成立。按 GOAL ${path} 推进下一项;真要等外部的事,先把它启动起来再写 wait。`
171
172export const heartbeatPrompt = (path: string, minutes: number): string =>
173 `[relay] 心跳:已等 ${minutes} 分钟。查在等的 run / 子 agent:结束了就收结果,按 GOAL ${path} 推进;还在跑就再写 RELAY: continue phase=wait。`
174
175export const acceptFailPrompt = (path: string, a: Acceptance, got: number | string, out: string): string =>
176 `[relay] 你写了 RELAY: done,但验收没过(GOAL ${path}):\n\`${a.command}\` 期望退出码 ${a.exitCode},实际 ${got}。输出末尾:\n\`\`\`\n${out}\n\`\`\`\n修好再写 RELAY: done;验收命令本身写错了就改 GOAL 的「验收」节并在改道账说明。`
177
178export const noAcceptancePrompt = (path: string): string =>
179 `[relay] 你写了 RELAY: done,但 GOAL ${path} 的「验收」节没有可跑的命令(每行要有一个 \`命令\`,可写「→ exit 0」)。补上验收命令并跑过,再写 RELAY: done。`
180
181export type ContextFile = { name: string; text: string }
182
183const CONTEXT_CAP = 12_000
184const clip = (text: string): string => (text.length > CONTEXT_CAP ? `${text.slice(0, CONTEXT_CAP)}\n…(截断)` : text)
185
186// The one message the fork answers: turn the user's line into a GOAL.md that follows C-1.
187export function goalPrompt(line: string, files: readonly ContextFile[], day: string): string {
188 return [
189 `你是这个仓库的 lead,替用户把他的一句话目标写成 GOAL.md(docs/plan/${day}/GOAL.md)。relay 会机械地读它:驱动 loop、在 RELAY: done 时逐条跑「验收」命令、按 effort 表切 effort、往改道账追加。`,
190 '',
191 `用户的原话(逐字):${line}`,
192 '',
193 '严格照下面模板的九个二级标题、原样标题文字和顺序写,不增不删,每节写本仓具体内容(标题后的说明文字删掉,换成内容):',
194 GOAL_TEMPLATE,
195 '',
196 '要求:',
197 '- 第一行 `# GOAL: <一句话终点>`。',
198 '- 原话:用户这句原话逐字放第一行(`> ` 引用),会话里他与本目标有关的原话也逐字引上。',
199 '- 终点状态:做完时可观察的世界(文件、命令输出、页面行为),不写过程。',
200 '- 验收:每行 `- \\`命令\\` → exit 0`,命令在仓根直接可跑、不交互、10 分钟内结束,合起来能证明终点状态;用仓里真有的脚本(看 CLAUDE.md、package.json 惯例)。',
201 '- 清单:`- [ ] 项`,按先后排,每项小到一两轮能交付;不写做法与第一步。',
202 '- 抢占规则:本仓的 P0 类(线上回归、性能超预算、数据错、安全、主干红)具体化成可判断的条件;命中即 RELAY: replan,不停。',
203 '- 停机条件:done / owner(只列真正改终点的岔口)/ stuck(同一项 3 轮无新证据)/ 预算。',
204 '- 不许停的情况:照模板写明整理完、阶段总结、问要不要继续、给选项都不是停机条件。',
205 '- effort:每行 `- <phase>=<low|medium|high|xhigh|max>`,至少含 plan、build、review、chore。',
206 '- 改道账:留空(relay 追加)。',
207 '只输出 GOAL.md 的 markdown 本身:不要代码围栏、不要前言或说明。',
208 '',
209 '仓库上下文:',
210 ...(files.length ? files.map(f => `=== ${f.name} ===\n${clip(f.text)}`) : ['(没有 CLAUDE.md / _NEXT.md / TODO / 上一份 GOAL)']),
211 ].join('\n')
212}
213
214// Makes a generated GOAL safe for relay to read: fences stripped, a title, the user's line verbatim
215// under 原话, and every C-1 section present (missing ones appended with the template's hint).
216export function normalizeGoal(raw: string, line: string): string {
217 let md = raw.trim().replace(/^```\w*\n/, '').replace(/\n```\s*$/, '').trim()
218 const titleAt = md.search(/^#\s*GOAL\s*[::]/m)
219 if (titleAt > 0) md = md.slice(titleAt)
220 if (titleAt < 0) md = `# GOAL: ${line}\n\n${md}`
221 const hints = new Map(GOAL_TEMPLATE.split('\n').slice(1).map(l => [sectionOf(l), l.replace(/^##\s*\S+\s*/, '')]))
222 for (const name of SECTIONS) {
223 if (md.split('\n').some(l => /^##\s/.test(l) && sectionOf(l) === name)) continue
224 md += `\n\n## ${name}\n${name === '原话' ? '' : `(待补:${hints.get(name) ?? ''})`}`
225 }
226 if (!section(md, '原话').some(l => l.includes(line))) {
227 const lines = md.split('\n')
228 const at = lines.findIndex(l => /^##\s/.test(l) && sectionOf(l) === '原话')
229 lines.splice(at + 1, 0, `> ${line}`)
230 md = lines.join('\n')
231 }
232 return `${md.trim()}\n`
233}
234hooks/pane.ts 80 lines1// What the GOAL pane shows, as plain rows sized to the pane's body (D-24).
2
3import type { RelayGoal, RelayMode, RelayView } from '../types'
4
5export type Row = { text: string; tone?: 'accent' | 'success' | 'warning' | 'error' | 'dim'; isBold?: boolean }
6
7const MODE_TEXT: Record<RelayMode, { text: string; tone: Row['tone'] }> = {
8 idle: { text: '空闲', tone: 'dim' },
9 running: { text: '自动续跑', tone: 'success' },
10 'waiting-owner': { text: '等 owner 拍板', tone: 'warning' },
11 done: { text: '验收通过,已停', tone: 'accent' },
12}
13
14// Display width: CJK and full-width glyphs take two cells.
15const cells = (ch: string): number => (/[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u.test(ch) ? 2 : 1)
16
17export function fit(text: string, columns: number): string {
18 const room = Math.max(4, columns)
19 const chars = [...text]
20 if (chars.reduce((n, ch) => n + cells(ch), 0) <= room) return text
21 let used = 0
22 let out = ''
23 for (const ch of chars) {
24 if (used + cells(ch) > room - 1) break
25 used += cells(ch)
26 out += ch
27 }
28 return `${out}…`
29}
30
31const bar = (used: number, total: number, width: number): string => {
32 const n = Math.max(4, Math.min(20, width))
33 const filled = total > 0 ? Math.min(n, Math.round((used / total) * n)) : 0
34 return `${'█'.repeat(filled)}${'░'.repeat(n - filled)}`
35}
36
37export function paneRows(o: {
38 mode: RelayMode
39 goal: RelayGoal | null
40 view: RelayView | null
41 effort: string | undefined
42 maxTurns: number
43 columns: number
44}): Row[] {
45 const { mode, goal, view, columns } = o
46 const rows: Row[] = []
47 const push = (text: string, tone?: Row['tone'], isBold?: boolean): void => {
48 rows.push({ text: fit(text, columns), ...(tone ? { tone } : {}), ...(isBold ? { isBold } : {}) })
49 }
50 if (!goal) {
51 push('没有激活的 GOAL', 'dim')
52 push('/goal <一句话> 写 GOAL.md 并立刻开跑', 'dim')
53 return rows
54 }
55 push(goal.title || goal.path, undefined, true)
56 const m = MODE_TEXT[mode]
57 push(`${m.text} · phase ${goal.phase} · effort ${o.effort ?? '默认'}`, m.tone)
58 push(`轮次 ${goal.turns}/${o.maxTurns}${goal.lastEvent ? ` · ${goal.lastEvent}` : ''}`, 'dim')
59 if (view) {
60 const k = (n: number): string => `${Math.round(n / 1000)}k`
61 const room = Math.max(4, Math.min(20, columns - 22))
62 push(`上下文 ${bar(view.contextTokens, view.threshold, room)} ${k(view.contextTokens)}/${k(view.threshold)}`, view.contextTokens >= view.threshold ? 'warning' : undefined)
63 const done = view.checklist.filter(c => c.isDone).length
64 push('')
65 push(`清单 ${done}/${view.checklist.length}`, undefined, true)
66 for (const c of view.checklist) push(`${c.isDone ? '☑' : '☐'} ${c.text}`, c.isDone ? 'dim' : c.text === goal.item ? 'accent' : undefined)
67 if (view.ledger.length) {
68 push('')
69 push('改道账', undefined, true)
70 for (const l of view.ledger.slice(-3)) push(l.replace(/^[-*]\s*/, ''), 'dim')
71 }
72 push('')
73 if (view.runs !== null) push(`run:${view.runs}`)
74 if (view.gateSlots !== null) push(`闸槽:${view.gateSlots}`, 'dim')
75 if (view.diskFreeGb !== null) push(`磁盘:${view.diskFreeGb} GB`, view.diskFreeGb < (view.diskMinGb ?? 50) ? 'error' : 'dim')
76 }
77 push(fit(goal.path, columns), 'dim')
78 return rows
79}
80hooks/parse.ts 67 lines1// Pure readers of the files and command output the status is built from.
2
3// `df -BG --output=avail <path>` → free gigabytes.
4export function parseDf(stdout: string): number | null {
5 const m = stdout.trim().split('\n').pop()?.trim().match(/^(\d+)G$/)
6 return m ? Number(m[1]) : null
7}
8
9// Open rows of docs/plan/DECISIONS-PENDING.md: `| 12 | 问题 | …`, a struck-out
10// question (~~…~~) or one marked 关闭 counts as closed.
11export function openDecisionRows(md: string): string[] {
12 return md
13 .split('\n')
14 .filter(l => /^\|\s*\d+\s*\|/.test(l))
15 .map(l => l.split('|')[2]?.trim() ?? '')
16 .filter(q => q !== '' && !q.startsWith('~~') && !/关闭/.test(q))
17}
18
19// Open bullets under a GOAL.md's `## §9` heading; struck-out ones are settled.
20export function openGoalNine(md: string): string[] {
21 const out: string[] = []
22 let inside = false
23 for (const line of md.split('\n')) {
24 if (/^## /.test(line)) inside = /^## §9/.test(line)
25 else if (inside && /^- /.test(line) && !/^- ~~/.test(line)) out.push(line.slice(2).trim())
26 }
27 return out
28}
29
30export const short = (s: string, n = 60): string => {
31 const plain = s.replace(/\*\*/g, '').replace(/`/g, '')
32 return plain.length > n ? `${plain.slice(0, n)}…` : plain
33}
34
35// The note the agent reads beside the prompt once the threshold is passed.
36export function handoffNote(tokens: number, path: string): string {
37 return [
38 `[relay] 上下文已到 ${Math.round(tokens / 1000)}k。先把这条消息处理完,然后写交接到 ${path}(覆盖写),供清空上下文后的新 session 接着做。必须包含:`,
39 '1. 当前目标与用户最近的原话要求;2. 已完成(带提交号 / 文件路径);3. 在跑的东西(子 agent、外部 run(omd-lite 等)、后台命令)及怎么收;',
40 '4. 下一步按顺序;5. 开工要读的文件;6. 若本 session 有 /loop 或 goal,原样写出它的 prompt,新 session 要重新启动;7. 等用户拍板的事项。',
41 '会话内的子 agent / Workflow 先收完再写(/clear 会丢掉它们);外部 run(omd-lite 等)、tmux 进程不受影响,写进交接即可。',
42 '写完后,回复最后一行单独写 HANDOFF-READY。之后会自动 /clear 并让新 session 读这个文件。不要在没写完交接时写这个标记。',
43 ].join('\n')
44}
45
46// checkpoint: omd continuity hook 给上一段 session 写的 checkpoint(没有就是 null);clear 前已补存过一次,是 clear 那一刻的。
47// goals: 仓里的目标文件(最新的 docs/plan/*/GOAL.md 与 docs/plan 下的 LOOP-GOAL.md),交接漏写 loop / goal 时新 session 从这里拿。
48// 交接是 agent 写的意图与下一步,checkpoint 是模型从 transcript 蒸馏的历史;两份互证。
49// active: relay 激活中的 GOAL(D-20):续接 prompt 必带它的路径与 phase,新 session 里 relay 已重新接上续跑。
50export const resumePrompt = (
51 path: string,
52 checkpoint: string | null = null,
53 goals: readonly string[] = [],
54 active: { path: string; phase: string } | null = null,
55): string =>
56 `[relay] 上一段上下文已自动清空。先读交接 ${path}` +
57 (checkpoint ? `,再读 omd checkpoint ${checkpoint} 补细节(两者冲突时以交接为准)` : '') +
58 (goals.length ? `,再读目标文件 ${goals.join('、')}` : '') +
59 `;与 git log / 仓内文件冲突时以后者为准。若交接里有 /loop 或 goal,用相同 prompt 重新启动` +
60 (goals.length ? '(交接没写就按目标文件里的 loop 写法重启)' : '') +
61 `;然后按「下一步」接着做。` +
62 (active
63 ? `\n激活中的 GOAL:${active.path}(phase=${active.phase})。relay 已在新 session 接上自动续跑:按它的清单推进,每轮回答最后一行照写 RELAY 行。`
64 : '')
65
66export const isReadyAnswer = (answer: string): boolean => /^\s*HANDOFF-READY\s*$/m.test(answer)
67types/index.d.ts 79 lines1export type Ops = {
2 diskFreeGb: number | null
3 worktrees: string[]
4 decisions: string[]
5 runningRuns: string[]
6}
7
8// armed: the note asking for a handoff went out; ready: the handoff file is
9// written and the clear is scheduled for `clearAt`.
10// snoozeTokens: after a cancel, ask again only past this context size.
11// waitAgents: the agent said ready while subagents were still out; ask it to
12// rewrite the handoff once they are all back.
13export type Handoff = { phase: 'idle' | 'armed' | 'ready'; path: string; armedAt: number; snoozeTokens: number; waitAgents: boolean }
14
15// C-4 (frozen): relay writes, cue reads. running = GOAL active and relay drives the loop;
16// waiting-owner = stopped at an owner fork; done = acceptance passed; idle = no GOAL or /relay stop.
17export type RelayMode = 'idle' | 'running' | 'waiting-owner' | 'done'
18
19// The active GOAL and its loop counters. Mirrored to $.store under `goal:<cwd>` so a restart can re-arm.
20export type RelayGoal = {
21 path: string
22 title: string
23 phase: string
24 item: string
25 // Turns relay has reacted to under this GOAL (budget: userConfig maxTurns).
26 turns: number
27 // Consecutive answers that ended without a RELAY line or on a question (D-14).
28 nudges: number
29 // Stuck guard: same phase+item with HEAD unchanged.
30 stuckKey: string
31 stuckHead: string
32 stuckCount: number
33 // Bumped on every main-loop turn end; a heartbeat armed under an older value is stale.
34 waitGen: number
35 // The GOAL's effort table (phase → effort), re-read on every wake.
36 efforts: Record<string, string>
37 startedAt: number
38 sessionId: string
39 lastEvent: string
40}
41
42export type RelayCheck = { isDone: boolean; text: string }
43
44// What the pane draws; refreshed whenever relay reads the GOAL or measures the session.
45export type RelayView = {
46 checklist: RelayCheck[]
47 ledger: string[]
48 contextTokens: number
49 threshold: number
50 runs: string | null
51 gateSlots: number | null
52 diskFreeGb: number | null
53 // userConfig diskMinGB; below it the disk row turns red (0 = never).
54 diskMinGb?: number
55}
56
57// Who sent the prompt of the turn now running: 'work' (the user, relay, delegate, a loop, a task
58// notification) or 'other' (another plugin, a peer); relay reacts to work turns only.
59export type RelayOrigin = 'work' | 'other'
60
61declare module 'claude-code' {
62 interface PluginState {
63 relay: {
64 ops: Ops | null
65 handoff: Handoff
66 diskWarned: boolean
67 agents: string[]
68 agentTypes: Record<string, string>
69 turnRunning: boolean
70 mode: RelayMode
71 goal: RelayGoal | null
72 view: RelayView | null
73 origin: RelayOrigin
74 lastEffort: Record<string, string>
75 selfClear: boolean
76 }
77 }
78}
79