SLOPSHOPPER

relay

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

newpanecommandpromptmodelprocess
v0.1.0MITupdated 2026-10-10AbyssCN/claude-lead-harness/mods/relay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · relay
│ ┃ relay-goal ✕ › fix the failing auth test and add an audit log call │ ┃ 没有激活的 GOAL │ ┃ /goal <一句话> 写 GOAL.md 并立刻开跑 ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ops │ ⎿ relay: 磁盘剩余(/work/app):读不到 GB · 上下文 97k / 交接阈值 500k(idle) │ ⎿ relay: │ ⎿ relay: 待拍板(0): │ ⎿ relay: │ ⎿ relay: 在跑的 omd-lite run(0):无 │ ⎿ relay: worktree(0):无 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · relay-goal
没有激活的 GOAL /goal <一句话> 写 GOAL.md 并立刻开跑
README

relay(接力)

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、磁盘余量。

lead 每轮最后一行(C-2)

RELAY: continue phase=<plan|build|review|chore|wait>
RELAY: done
RELAY: owner <一句问题>
RELAY: replan <原因> -> <新项>
  • continue:马上发「按 GOAL 继续」。phase=wait 不发,挂 heartbeatMinutes 心跳;如果在跑的 omd-lite run 和子 agent 都是 0 个,直接回推「wait 不成立」。
  • done:relay 自己逐条跑「验收」里的命令,全过才停并通知;有一条没过,就把那条命令和输出末尾 40 行推回去。
  • owner:停下通知,不替 owner 回答;owner 回一句话就接着跑。
  • replan:往「改道账」追加一行,然后接着跑。
  • 没写 RELAY 行,或者停在问句、选项上:第一次推回一条提醒,连续第二次就停下通知。同一 phase 同一项连续 3 轮没有新提交算卡住,也停;每个 GOAL 最多跑 maxTurns 轮。

effort

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 接着跑。

配置(plugin userConfig)

选项默认说明
thresholdTokens500000上下文到这个量开始交接;0 关闭
delaySeconds30交接写好后多久自动 /clear,期间发消息即取消
heartbeatMinutes25phase=wait 后的心跳间隔
maxTurns200每个 GOAL 自动续跑轮数上限
subagentEffort空子 agent 按类型切 effort,如 Explore=low, Plan=high
reposDir空 = ~/repos放仓和 worktree 的目录;会话在其下时以 <reposDir>/<仓名> 为仓根,否则以 cwd 为仓根
worktreePrefixesomdl-,agent-reposDir 下按前缀认出派发脚本留下的 worktree(另外还列每个仓的 .claude/worktrees/*)
diskPath空 = 仓根df 查余量的路径
diskMinGB50余量低于它提醒一次;0 关闭
checkpointCommand空clear 前补存 checkpoint 的命令,{transcript} {session} 会被替换;空 = 不补存

安装

/plugin marketplace add AbyssCN/claude-lead-harness
/plugin install relay@claude-lead-harness
Source 6 files
hooks/register.ts 936 lines
1import { 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}
936
hooks/effort.ts 47 lines
1// 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}
47
hooks/goal.ts 234 lines
1// 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}
234
hooks/pane.ts 80 lines
1// 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}
80
hooks/parse.ts 67 lines
1// 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)
67
types/index.d.ts 79 lines
1export 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