Project progress in one row above the Claude Code prompt: your /goal, checklists, roadmap, counters, beads epics, GitHub milestones, the agents working beside…

English | 简体中文
Where your project stands, in one row above the Claude Code prompt. Written as a Claude Code mod.

Every item is a soft capsule in Apple system colours, light or dark with your Claude Code theme. When the row is short, the least important capsules give way first. /waypoint opens a pane with the details: open checklist items, every phase, each agent and what it holds.
<img src="docs/pane.png" alt="The /waypoint pane" width="384">
Requires Claude Code 2.1.289 or newer. Mods are an early-access API that may change between releases. If waypoint stops drawing after an update, please open an issue.
In Claude Code:
/plugin marketplace add VibeMage/claude-mod-waypoint
/plugin install waypoint@claude-mod-waypoint
The row appears right away. It works with no setup: it shows your /goal, Claude's todo list, the first TODO.md that has checkboxes and the phase table in ROADMAP.md. Add a config file to follow exactly what you care about.
| Capsule | What it is | Source |
|---|---|---|
| ◎ Goal | The session's /goal, ✓ once Claude Code's evaluator finds it met, with the todo tally beside it | the session transcript (goal_status, /goal) |
| ▸ Todos | Without a goal: done/total and the item in progress | TodoWrite and the Task tools |
| Checklist | - [x] / - [ ] items of one section of a Markdown file, with a bar | any .md file |
| Counter | A tally you keep by hand, or one a command prints as done/total | config |
| ●◉○ Roadmap | One dot per phase: done, current, ahead | a Markdown table with a phase and a status column |
| Epic | A beads epic's closed/total children | bd epic status --json, at most every 5 minutes |
| Milestone | A GitHub milestone's closed/total issues | gh api, at most every 5 minutes, only when configured |
| Agents | The Claude Code and Codex panes working on the same project: busy, waiting for approval, stuck (rate limit, model at capacity) | Otty panes whose folder is the project or one of its git worktrees |
| Lock | Who holds a lock file, and on which task | config |
waypoint reads, in order:
.claude/waypoint.json in the project, a single project object;~/.claude/waypoint.json, as { "projects": [ { "paths": ["~/code/app"], ... } ] }, for projects whose repository you would rather not touch.{
"name": "My App",
"checklists": [{ "file": "docs/TODO.md", "marker": "current sprint" }],
"roadmap": { "file": "docs/ROADMAP.md", "current": "Beta" },
"counters": [{ "label": "Screens", "done": 12, "total": 30 }],
"beads": { "epic": "app-12" },
"github": { "milestone": "v1.0" },
"team": { "panes": { "p_1a2b3c_4": "Codex A" } },
"locks": [{ "path": ".local/deploy.lock", "label": "deploy", "worktrees": true }],
"panes": ["p_1a2b3c_3"]
}
| Field | Meaning |
|---|---|
name | The project's name in the pane |
root | The folder relative paths resolve against (default: the repository root) |
paths | Global file only: the folders this entry applies to |
panes | Draw the row only in these Otty panes (p_… ids); the pane still opens anywhere |
checklists[] | file, then which section: section (a heading that contains this text), or marker (the last heading that contains it, for files where each new batch is appended), else the first section with open items. label overrides the name taken from the heading. Only items at the left margin count; indented ones are sub-steps |
roadmap | file and optionally current (the phase name it starts with), or false |
counters[] | label with done and total, or command (an argv array run in the root; its output must contain done/total) |
beads | true, false or { "epic": "<id>" }. On by default when the root has .beads/ |
github | true or { "milestone": "<title>" }. Off by default |
team | false, or { "panes": { "<pane id>": "<name>" } } to name exactly which panes, or { "cwds": [...], "agents": ["Codex"], "stuck": ["text that means stuck"] } to widen or narrow the automatic match |
locks[] | path (a file, or a directory holding owner.json), label, and worktrees: true to look for it in every git worktree. The holder is read from actor, owner, holder or user, the task from task or issue |
Settings (/config › waypoint): language (auto, en, zh), style (capsule or plain), refresh interval (20 s by default), and whether to show agents.
/waypoint: open or close the detail pane/waypoint refresh: re-read everything now/waypoint config: which config file is in usewaypoint is a TypeScript module that Claude Code runs in its mod sandbox. It has no dependencies, downloads nothing and writes no files.
TODO.md / ROADMAP.md when there is none), its config files, the session's transcript (only the /goal records and todo tool calls), and your Claude Code theme setting.git worktree list, grep over the session transcript for /goal records, and with Otty, otty pane list and otty pane capture --lines 12 on the agent panes it shows, to tell busy, stuck and waiting apart. Every 5 minutes at most: bd epic status when beads is on, gh api for milestones when GitHub is configured (the one network request, made by your own gh). Plus any command you configure for a counter.tool.call only for TodoWrite, TaskCreate and TaskUpdate, to read the todo list as Claude writes it; the calls pass through unchanged. It also hooks session.start (to register /waypoint and start the refresh timer), turn.complete (to refresh after each turn), session.end on /clear (to forget the goal and todos), and draws the band and the pane with ui.render.find once per session, only when the transcript is not at its usual path, to locate it under ~/.claude/projects.See PRIVACY.md.
claude --plugin-dir . # load it for one session; saving a file reloads it
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 925 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { WpAgent, WpCount, WpGoal, WpLock, WpPhase, WpSnapshot, WpTodo } from '../types'
5import {
6 APPROVAL_PATTERNS,
7 STUCK_PATTERNS,
8 bar,
9 clipWidth,
10 currentPhase,
11 displayWidth,
12 fitSegments,
13 hasCjk,
14 isUnder,
15 latestGoal,
16 normalizePane,
17 paneState,
18 phaseShort,
19 pickSection,
20 replayTodo,
21 roadmapPhases,
22} from './parse'
23
24const PANE = 'waypoint'
25
26// Sources: the project's files, Otty, lock files and trackers, all read-only
27
28export type ChecklistSource = { file: string; section?: string; marker?: string; label?: string }
29export type CounterSource = { label: string; done?: number; total?: number; command?: string[] }
30export type LockSource = { path: string; label?: string; worktrees?: boolean }
31
32export type ProjectConfig = {
33 name?: string
34 root?: string
35 paths?: string[]
36 panes?: string[]
37 checklists?: ChecklistSource[]
38 roadmap?: { file?: string; current?: string } | false
39 counters?: CounterSource[]
40 beads?: { epic?: string } | boolean
41 github?: { milestone?: string } | boolean
42 team?: { panes?: Record<string, string>; cwds?: string[]; agents?: string[]; stuck?: string[] } | false
43 locks?: LockSource[]
44}
45
46type Resolved = { cfg: ProjectConfig; root: string; path?: string; problems: string[] }
47
48const SLOW_MS = 5 * 60_000
49const WORKTREE_MS = 60_000
50
51// Results of the slow sources (bd, gh, git worktree), kept between refreshes in the module
52const cache = new Map<string, { at: number; value: unknown }>()
53
54async function cached<T>(key: string, ttl: number, now: number, load: () => Promise<T>): Promise<T> {
55 const hit = cache.get(key)
56 if (hit && now - hit.at < ttl) return hit.value as T
57 const value = await load()
58 cache.set(key, { at: now, value })
59
60 return value
61}
62
63function expand(path: string, home: string): string {
64 return path === '~' ? home : path.startsWith('~/') ? `${home}${path.slice(1)}` : path
65}
66
67function join(root: string, path: string): string {
68 return path.startsWith('/') ? path : `${root.replace(/\/+$/, '')}/${path}`
69}
70
71async function readJson($: EngineInterface, path: string): Promise<unknown> {
72 return JSON.parse(await $.fs.read(path))
73}
74
75async function run($: EngineInterface, argv: string[], cwd: string, timeoutMs = 8000): Promise<string | null> {
76 try {
77 const { exitCode, stdout } = await $.process.run(argv, { cwd, timeoutMs })
78 return exitCode === 0 ? stdout : null
79 } catch {
80 return null
81 }
82}
83
84// The project's own `.claude/waypoint.json`, else the entry of `~/.claude/waypoint.json` whose paths hold the session
85async function resolveConfig($: EngineInterface, homeDir: string): Promise<Resolved> {
86 const problems: string[] = []
87 const cwd = await $.session.cwd()
88 const root = await $.session.root().catch(() => cwd)
89
90 const own = `${root}/.claude/waypoint.json`
91 if (await $.fs.exists(own).catch(() => false)) {
92 try {
93 const cfg = (await readJson($, own)) as ProjectConfig
94 return { cfg, root: cfg.root ? expand(cfg.root, homeDir) : root, path: own, problems }
95 } catch (err) {
96 problems.push(`${own}: ${String(err)}`)
97 }
98 }
99
100 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${homeDir}/.claude`
101 const global = `${configDir}/waypoint.json`
102 if (await $.fs.exists(global).catch(() => false)) {
103 try {
104 const file = (await readJson($, global)) as { projects?: ProjectConfig[] }
105 for (const cfg of file.projects ?? []) {
106 const paths = (cfg.paths ?? (cfg.root ? [cfg.root] : [])).map(p => expand(p, homeDir))
107 if (paths.some(p => isUnder(cwd, p) || isUnder(root, p))) {
108 return { cfg, root: cfg.root ? expand(cfg.root, homeDir) : (paths[0] ?? root), path: global, problems }
109 }
110 }
111 } catch (err) {
112 problems.push(`${global}: ${String(err)}`)
113 }
114 }
115
116 return { cfg: {}, root, problems }
117}
118
119async function checklistCounts($: EngineInterface, cfg: ProjectConfig, root: string, problems: string[]): Promise<WpCount[]> {
120 let sources = cfg.checklists
121 if (!sources) {
122 // No config: the first conventional file that holds checkboxes
123 for (const file of ['TODO.md', 'docs/TODO.md', 'ROADMAP.md', 'docs/ROADMAP.md', 'PLAN.md', 'docs/PLAN.md']) {
124 const text = await $.fs.read(join(root, file)).catch(() => null)
125 if (text && /^[-*+] \[[ xX]\]/m.test(text)) {
126 sources = [{ file }]
127 break
128 }
129 }
130 }
131 const out: WpCount[] = []
132 for (const src of sources ?? []) {
133 const text = await $.fs.read(join(root, src.file)).catch(() => null)
134 if (text === null) {
135 problems.push(`${src.file}: not found`)
136 continue
137 }
138 const name = src.file.split('/').pop()!.replace(/\.md$/i, '')
139 const section = pickSection(text, src, name)
140 if (!section) {
141 if (src.section) problems.push(`${src.file}: no section "${src.section}"`)
142 continue
143 }
144 out.push({
145 kind: 'checklist',
146 label: src.label ?? section.label,
147 done: section.done,
148 total: section.total,
149 detail: src.file,
150 items: section.items,
151 })
152 }
153
154 return out
155}
156
157async function roadmap($: EngineInterface, cfg: ProjectConfig, root: string): Promise<{ phases: WpPhase[]; current: number }> {
158 if (cfg.roadmap === false) return { phases: [], current: -1 }
159 const files = cfg.roadmap?.file ? [cfg.roadmap.file] : ['ROADMAP.md', 'docs/ROADMAP.md']
160 for (const file of files) {
161 const text = await $.fs.read(join(root, file)).catch(() => null)
162 if (text === null) continue
163 const phases = roadmapPhases(text)
164 if (phases.length > 0) return { phases, current: currentPhase(phases, cfg.roadmap?.current) }
165 }
166
167 return { phases: [], current: -1 }
168}
169
170async function counters($: EngineInterface, cfg: ProjectConfig, root: string, problems: string[]): Promise<WpCount[]> {
171 const out: WpCount[] = []
172 for (const c of cfg.counters ?? []) {
173 if (c.command) {
174 const stdout = await run($, c.command, root)
175 const m = stdout?.match(/(\d+)\s*\/\s*(\d+)/)
176 if (!m) {
177 problems.push(`${c.label}: the command printed no "done/total"`)
178 continue
179 }
180 out.push({ kind: 'counter', label: c.label, done: Number(m[1]), total: Number(m[2]) })
181 } else if (typeof c.done === 'number' && typeof c.total === 'number') {
182 out.push({ kind: 'counter', label: c.label, done: c.done, total: c.total })
183 }
184 }
185
186 return out
187}
188
189type BeadsEpic = {
190 epic: { id: string; title: string; status: string; updated_at?: string }
191 total_children: number
192 closed_children: number
193}
194
195async function beads($: EngineInterface, cfg: ProjectConfig, root: string, now: number): Promise<WpCount[]> {
196 if (cfg.beads === false) return []
197 if (cfg.beads === undefined && !(await $.fs.exists(`${root}/.beads`).catch(() => false))) return []
198 const epics = await cached(`bd:${root}`, SLOW_MS, now, async () => {
199 const stdout = await run($, ['bd', 'epic', 'status', '--json'], root, 15_000)
200 try {
201 return stdout ? (JSON.parse(stdout) as BeadsEpic[]) : []
202 } catch {
203 return []
204 }
205 })
206 const pinned = typeof cfg.beads === 'object' ? cfg.beads.epic : undefined
207 const open = epics.filter(e => (pinned ? e.epic.id === pinned : e.epic.status !== 'closed'))
208
209 return open.slice(0, pinned ? 1 : 2).map(e => ({
210 kind: 'beads',
211 label: e.epic.title,
212 done: e.closed_children,
213 total: e.total_children,
214 detail: `beads ${e.epic.id}`,
215 }))
216}
217
218type Milestone = { title: string; open_issues: number; closed_issues: number; due_on?: string | null }
219
220async function github($: EngineInterface, cfg: ProjectConfig, root: string, now: number): Promise<WpCount[]> {
221 if (!cfg.github) return []
222 const list = await cached(`gh:${root}`, SLOW_MS, now, async () => {
223 const stdout = await run($, ['gh', 'api', 'repos/{owner}/{repo}/milestones?state=open'], root, 15_000)
224 try {
225 return stdout ? (JSON.parse(stdout) as Milestone[]) : []
226 } catch {
227 return []
228 }
229 })
230 const wanted = typeof cfg.github === 'object' ? cfg.github.milestone : undefined
231 const pick = wanted ? list.find(m => m.title === wanted) : list[0]
232 if (!pick) return []
233
234 return [
235 {
236 kind: 'github',
237 label: pick.title,
238 done: pick.closed_issues,
239 total: pick.open_issues + pick.closed_issues,
240 detail: 'GitHub milestone',
241 },
242 ]
243}
244
245async function worktrees($: EngineInterface, root: string, now: number): Promise<string[]> {
246 return cached(`wt:${root}`, WORKTREE_MS, now, async () => {
247 const stdout = await run($, ['git', 'worktree', 'list', '--porcelain'], root)
248 const list = (stdout ?? '')
249 .split('\n')
250 .filter(l => l.startsWith('worktree '))
251 .map(l => l.slice(9))
252
253 return list.length > 0 ? list : [root]
254 })
255}
256
257type OttyPane = { id: string; agent?: string; agent_state?: string; cwd?: string; process?: string }
258
259// `✳ Fix the build` → `Fix the build`: Otty's title keeps the agent's spinner glyph in front
260function paneName(pane: OttyPane): string {
261 const title = (pane.process ?? '').replace(/^[^\p{L}\p{N}]+/u, '').trim()
262
263 return title || pane.agent || pane.id
264}
265
266async function teamPanes($: EngineInterface, cfg: ProjectConfig, root: string, self: string | undefined, homeDir: string, dirs: string[]): Promise<WpAgent[] | null> {
267 if (cfg.team === false || !self) return null
268 const stdout = await run($, ['otty', 'pane', 'list', '--json'], root, 4000)
269 if (!stdout) return null
270 let panes: OttyPane[] = []
271 try {
272 const parsed = JSON.parse(stdout) as { data?: OttyPane[] | { panes?: OttyPane[] } }
273 panes = Array.isArray(parsed.data) ? parsed.data : (parsed.data?.panes ?? [])
274 } catch {
275 return null
276 }
277 const named = cfg.team?.panes
278 const agents = cfg.team?.agents ?? ['Claude Code', 'Codex']
279 const places = [...dirs, ...(cfg.team?.cwds ?? []).map(d => expand(d, homeDir))]
280 const members = panes.filter(p =>
281 named
282 ? p.id in named
283 : p.id !== self && agents.includes(p.agent ?? '') && places.some(d => isUnder(p.cwd ?? '', d)),
284 )
285 const stuck = cfg.team?.stuck ?? STUCK_PATTERNS
286
287 return Promise.all(
288 members.map(async p => {
289 const screen = (await run($, ['otty', 'pane', 'capture', '--pane', p.id, '--lines', '12', '--trim'], root, 3000)) ?? ''
290 return {
291 pane: p.id,
292 name: named?.[p.id] ?? paneName(p),
293 agent: p.agent ?? '',
294 state: paneState(p.agent_state ?? '', screen, stuck, APPROVAL_PATTERNS, p.agent ?? ''),
295 cwd: p.cwd ?? '',
296 }
297 }),
298 )
299}
300
301async function locks($: EngineInterface, cfg: ProjectConfig, root: string, homeDir: string, dirs: string[]): Promise<WpLock[]> {
302 const out: WpLock[] = []
303 const seen = new Set<string>()
304 for (const src of cfg.locks ?? []) {
305 const path = expand(src.path, homeDir)
306 const candidates = src.worktrees && !path.startsWith('/') ? dirs.map(d => join(d, path)) : [join(root, path)]
307 for (const candidate of candidates) {
308 const stat = await $.fs.stat(candidate, { resolve: true }).catch(() => undefined)
309 if (!stat) continue
310 const real = stat.realPath ?? candidate
311 if (seen.has(real)) continue
312 seen.add(real)
313 // A lock directory names its holder in owner.json; a lock file is JSON itself or held by being there
314 const file = stat.kind === 'dir' ? `${candidate}/owner.json` : candidate
315 let info: Record<string, unknown> = {}
316 try {
317 info = (await readJson($, file)) as Record<string, unknown>
318 } catch {
319 // Held, holder unnamed
320 }
321 const str = (...keys: string[]) => {
322 for (const k of keys) if (typeof info[k] === 'string' && info[k]) return info[k] as string
323 return undefined
324 }
325 out.push({
326 label: src.label ?? path.split('/').filter(Boolean).pop() ?? 'lock',
327 actor: str('actor', 'owner', 'holder', 'user', 'agent') ?? '?',
328 task: str('task', 'issue', 'ticket'),
329 since: str('utc', 'since', 'time', 'created_at', 'acquired_at'),
330 path: candidate,
331 })
332 }
333 }
334
335 return out
336}
337
338// The last /goal the transcript holds: the evaluator's goal_status record, or the command itself
339async function readGoal($: EngineInterface, transcript: string | undefined): Promise<WpGoal | null | undefined> {
340 if (!transcript) return undefined
341 const stdout = await run($, ['grep', '-E', '"goal_status"|<command-name>/goal<', transcript], '/', 4000)
342 if (stdout === null) return null
343 return latestGoal(stdout.split('\n'))
344}
345
346// The session's transcript file: `<config>/projects/<cwd with every non-alphanumeric as ->/<session id>.jsonl`
347async function transcriptPath($: EngineInterface, homeDir: string): Promise<string | undefined> {
348 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${homeDir}/.claude`
349 const id = await $.session.id()
350 const cwd = await $.session.cwd()
351 const guess = `${configDir}/projects/${cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${id}.jsonl`
352 if (await $.fs.exists(guess).catch(() => false)) return guess
353 const found = await run($, ['find', `${configDir}/projects`, '-maxdepth', '2', '-name', `${id}.jsonl`], '/', 5000)
354
355 return found?.split('\n').find(Boolean)
356}
357
358async function collect($: EngineInterface, homeDir: string, self: string | undefined): Promise<WpSnapshot> {
359 const now = await $.clock.now()
360 const { cfg, root, path, problems } = await resolveConfig($, homeDir)
361 const dirs = await worktrees($, root, now)
362 const [checklists, road, counts, epics, milestones, members, held] = await Promise.all([
363 checklistCounts($, cfg, root, problems),
364 roadmap($, cfg, root),
365 counters($, cfg, root, problems),
366 beads($, cfg, root, now),
367 github($, cfg, root, now),
368 teamPanes($, cfg, root, self, homeDir, dirs),
369 locks($, cfg, root, homeDir, dirs),
370 ])
371
372 return {
373 project: cfg.name ?? root.split('/').filter(Boolean).pop() ?? root,
374 isShown: !cfg.panes || (self !== undefined && cfg.panes.map(normalizePane).includes(self)),
375 counts: [...checklists, ...counts, ...epics, ...milestones],
376 phases: road.phases,
377 current: road.current,
378 team: members,
379 locks: held,
380 hasLocks: (cfg.locks ?? []).length > 0,
381 problems,
382 configPath: path,
383 at: now,
384 }
385}
386
387const goal = atom({ plugin: 'waypoint', key: 'goal' } as const, null)
388const todos = atom({ plugin: 'waypoint', key: 'todos' } as const, [])
389const snapshot = atom({ plugin: 'waypoint', key: 'snapshot' } as const, null)
390
391type Palette = {
392 text: string
393 subtext: string
394 overlay: string
395 fill: string
396 track: string
397 blue: string
398 green: string
399 orange: string
400 red: string
401 indigo: string
402}
403
404// Apple's system colours (HIG), the accents at their increased-contrast values so they read on a fill
405const CUPERTINO: Record<'light' | 'dark', Palette> = {
406 light: {
407 text: '#1d1d1f',
408 subtext: '#6e6e73',
409 overlay: '#8e8e93',
410 fill: '#ececf0',
411 track: '#c7c7cc',
412 blue: '#0071e3',
413 green: '#248a3d',
414 orange: '#c93400',
415 red: '#d70015',
416 indigo: '#5e5ce6',
417 },
418 dark: {
419 text: '#f5f5f7',
420 subtext: '#98989f',
421 overlay: '#7c7c80',
422 fill: '#2c2c2e',
423 track: '#48484a',
424 blue: '#0a84ff',
425 green: '#30d158',
426 orange: '#ff9f0a',
427 red: '#ff453a',
428 indigo: '#7d7aff',
429 },
430}
431
432const WORDS = {
433 en: {
434 goal: 'Goal',
435 met: 'met',
436 todos: 'todos',
437 team: 'agents',
438 busy: 'busy',
439 idle: 'idle',
440 approval: 'approval',
441 stuck: 'stuck',
442 unknown: '?',
443 lock: 'lock',
444 free: 'free',
445 roadmap: 'Roadmap',
446 checklists: 'Progress',
447 locks: 'Locks',
448 problems: 'Notes',
449 noConfig: 'No waypoint.json: showing what was found. /waypoint config shows how to add one.',
450 nothing: 'Nothing to track yet: no /goal, todos, checklist or roadmap found.',
451 },
452 zh: {
453 goal: '目标',
454 met: '已达成',
455 todos: '任务',
456 team: '团队',
457 busy: '忙',
458 idle: '闲',
459 approval: '待批',
460 stuck: '卡',
461 unknown: '?',
462 lock: '锁',
463 free: '无',
464 roadmap: '路线图',
465 checklists: '进度',
466 locks: '锁',
467 problems: '提示',
468 noConfig: '没有 waypoint.json,只显示自动找到的内容。/waypoint config 查看配置方法。',
469 nothing: '还没有可跟踪的内容:没有 /goal、任务清单、勾选清单或路线图。',
470 },
471} as const
472
473type Lang = keyof typeof WORDS
474type Words = (typeof WORDS)[Lang]
475
476type Config = { language: 'auto' | Lang; refreshSeconds: number; style: 'capsule' | 'plain'; showTeam: boolean }
477
478export function readConfig(options: Record<string, unknown>): Config {
479 const n = Number(options.refreshSeconds)
480
481 return {
482 language: options.language === 'en' || options.language === 'zh' ? options.language : 'auto',
483 refreshSeconds: Number.isFinite(n) ? Math.min(600, Math.max(5, Math.round(n))) : 20,
484 style: options.style === 'plain' ? 'plain' : 'capsule',
485 showTeam: options.showTeam !== false,
486 }
487}
488
489async function palette($: EngineInterface): Promise<Palette> {
490 try {
491 return String((await $.settings.read()).theme ?? 'dark').includes('light') ? CUPERTINO.light : CUPERTINO.dark
492 } catch {
493 return CUPERTINO.dark
494 }
495}
496
497function language(cfg: Config, s: WpSnapshot | null, g: WpGoal | null, list: WpTodo[], lang: string): Lang {
498 if (cfg.language !== 'auto') return cfg.language
499 if (lang.toLowerCase().startsWith('zh')) return 'zh'
500 const seen = [g?.condition ?? '', ...list.map(t => t.text), ...(s?.counts ?? []).map(c => c.label), ...(s?.phases ?? []).map(p => p.name)]
501
502 return seen.some(hasCjk) ? 'zh' : 'en'
503}
504
505type Part = { text: string; color: string; bold?: boolean }
506type Segment = { key: string; priority: number; width: number; parts: Part[] }
507
508function segment(key: string, priority: number, parts: Part[]): Segment {
509 return { key, priority, parts, width: parts.reduce((w, p) => w + displayWidth(p.text), 0) + 2 }
510}
511
512// A checklist, epic or milestone gets a five-cell bar; a counter, usually a long tally, its figures alone
513function countParts(c: WpCount, p: Palette, labelMax: number, withBar = c.kind !== 'counter'): Part[] {
514 const isDone = c.total > 0 && c.done >= c.total
515 const b = withBar ? bar(c.done, c.total) : { filled: '', empty: '' }
516
517 return [
518 { text: isDone ? '✓ ' : '', color: p.green },
519 { text: clipWidth(c.label, labelMax), color: p.text },
520 { text: withBar ? ' ' : '', color: p.text },
521 { text: b.filled, color: isDone ? p.green : p.blue },
522 { text: b.empty, color: p.track },
523 { text: ` ${c.done}/${c.total}`, color: p.subtext },
524 ].filter(part => part.text !== '')
525}
526
527function teamTally(team: WpAgent[]) {
528 const by = (s: WpAgent['state']) => team.filter(a => a.state === s).length
529
530 return { busy: by('busy'), stuck: by('stuck'), approval: by('approval') }
531}
532
533// The band's segments, most important first in priority; the fit drops the lowest when the row is short
534export function bandSegments(s: WpSnapshot | null, g: WpGoal | null, list: WpTodo[], p: Palette, w: Words, showTeam: boolean): Segment[] {
535 const out: Segment[] = []
536 const done = list.filter(t => t.status === 'completed').length
537 const tally = list.length > 0 ? `${done}/${list.length}` : ''
538
539 if (g) {
540 out.push(
541 segment('goal', 100, [
542 { text: g.isMet ? '✓ ' : '◎ ', color: g.isMet ? p.green : p.indigo },
543 { text: clipWidth(g.condition, 26), color: p.text, bold: true },
544 ...(g.isMet ? [{ text: ` ${w.met}`, color: p.green }] : []),
545 ...(tally && !g.isMet ? [{ text: ` ${tally}`, color: p.subtext }] : []),
546 ]),
547 )
548 } else if (list.length > 0 && done < list.length) {
549 const current = list.find(t => t.status === 'in_progress')
550 out.push(
551 segment('todos', 95, [
552 { text: '▸ ', color: p.blue },
553 { text: tally, color: p.text, bold: true },
554 { text: ` ${clipWidth(current?.active || current?.text || w.todos, 22)}`, color: p.subtext },
555 ]),
556 )
557 }
558
559 for (const [i, c] of (s?.counts ?? []).entries()) {
560 const base = c.kind === 'checklist' ? 90 : c.kind === 'counter' ? 70 : 60
561 out.push(segment(`count-${i}`, base - i, countParts(c, p, 14)))
562 }
563
564 if (s && s.phases.length > 0) {
565 const dots = s.phases.map((ph, i) => ({
566 text: i === s.current ? '◉' : ph.status === 'done' ? '●' : '○',
567 color: i === s.current ? p.blue : ph.status === 'done' ? p.green : p.track,
568 }))
569 const now = s.phases[s.current]
570 out.push(segment('roadmap', 80, [...dots, ...(now ? [{ text: ` ${phaseShort(now.name)}`, color: p.text }] : [])]))
571 }
572
573 if (showTeam && s?.team && s.team.length > 0) {
574 const t = teamTally(s.team)
575 out.push(
576 segment('team', 50, [
577 { text: `${w.team} ${s.team.length}`, color: p.text },
578 ...(t.busy ? [{ text: ` ${w.busy}${t.busy}`, color: p.green }] : []),
579 ...(t.approval ? [{ text: ` ${w.approval}${t.approval}`, color: p.orange }] : []),
580 ...(t.stuck ? [{ text: ` ${w.stuck}${t.stuck}`, color: p.red, bold: true }] : []),
581 ]),
582 )
583 }
584
585 if (s && s.hasLocks && s.locks.length === 0) {
586 out.push(segment('locks', 30, [{ text: `${w.lock} ${w.free}`, color: p.overlay }]))
587 } else if (s && s.locks.length > 0) {
588 const first = s.locks[0]!
589 out.push(
590 segment('locks', 40, [
591 { text: `${w.lock} `, color: p.subtext },
592 { text: clipWidth(first.actor, 12), color: p.orange },
593 ...(s.locks.length > 1 ? [{ text: ` +${s.locks.length - 1}`, color: p.subtext }] : []),
594 ]),
595 )
596 }
597
598 return out
599}
600
601const STATE_ORDER: WpAgent['state'][] = ['stuck', 'approval', 'busy', 'idle', 'unknown']
602
603// `codex-M` and `Codex M` name the same holder
604function sameName(a: string, b: string): boolean {
605 const norm = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '')
606 return norm(a) !== '' && norm(a) === norm(b)
607}
608
609function ago(at: number, since: string | undefined): string {
610 const t = since ? Date.parse(since) : NaN
611 if (!Number.isFinite(t)) return ''
612 const m = Math.max(0, Math.round((at - t) / 60_000))
613
614 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
615}
616
617// Kept by the module, so a reload starts them over: who this session is, and its transcript file
618let isRefreshing = false
619let transcript: string | undefined
620let homeDir = ''
621let self: string | undefined
622let lang = ''
623
624async function refresh($: EngineInterface) {
625 if (isRefreshing) return
626 isRefreshing = true
627 try {
628 const s = await collect($, homeDir, self)
629 await update($, snapshot, () => s)
630 transcript ??= await transcriptPath($, homeDir)
631 const g = await readGoal($, transcript)
632 if (g !== undefined) await update($, goal, () => g)
633 } finally {
634 isRefreshing = false
635 }
636}
637
638// The todo list as the transcript left it, so a reload or a resume keeps it
639async function backfillTodos($: EngineInterface) {
640 let list: WpTodo[] = []
641 for (const m of await $.session.messages()) {
642 for (const call of m.toolUses ?? []) list = replayTodo(list, String(call.tool), call.input, call.result)
643 }
644 await update($, todos, old => (old.length === 0 ? list : old))
645}
646
647export const register: Register = (on, options) => {
648 const cfg = readConfig(options as Record<string, unknown>)
649
650 on('session.start', async ($, e, next) => {
651 const started = await next(e)
652 await $.command.register({
653 name: 'waypoint',
654 description: 'Project progress: open or close the detail pane; `refresh` to re-read now, `config` for the config file',
655 argumentHint: '[refresh|config]',
656 })
657 homeDir = (await $.env.get('HOME')) ?? ''
658 const pane = await $.env.get('OTTY_PANE_ID')
659 self = pane ? normalizePane(pane) : undefined
660 lang = (await $.env.get('LANG')) ?? ''
661 void backfillTodos($).catch(() => undefined)
662 void refresh($).catch(() => undefined)
663 $.clock.every(cfg.refreshSeconds * 1000, () => void refresh($).catch(() => undefined))
664
665 return started
666 })
667
668 on('turn.complete', async ($, e, next) => {
669 const ran = await next(e)
670 void refresh($).catch(() => undefined)
671
672 return ran
673 })
674
675 on('session.end', { reason: 'clear' }, async ($, e, next) => {
676 await update($, todos, () => [])
677 await update($, goal, () => null)
678
679 return next(e)
680 })
681
682 on('tool.call', { tool: ['TodoWrite', 'TaskCreate', 'TaskUpdate'] }, async ($, e, next) => {
683 const ran = await next(e)
684 if (ran.deny === undefined && !ran.isError) {
685 await update($, todos, list => replayTodo(list, String(e.tool), e as unknown as Record<string, unknown>, ran.result))
686 }
687
688 return ran
689 // A failed list update never holds up the tool itself
690 }).catch(($, e, next) => next(e))
691
692 on('command.run', { command: 'waypoint' }, async ($, e) => {
693 const arg = (e.args ?? '').trim().toLowerCase()
694 if (arg === 'refresh') {
695 await refresh($)
696 return { text: 'refreshed' }
697 }
698 if (arg === 'config') {
699 const s = await read($, snapshot)
700 return {
701 text: [
702 s?.configPath ? `waypoint reads ${s.configPath}` : 'waypoint found no config and shows what it detects.',
703 'Put one in the project as .claude/waypoint.json, or list projects in ~/.claude/waypoint.json as { "projects": [ { "paths": [...], ... } ] }.',
704 'See the README for every field: checklists, roadmap, counters, beads, github, team, locks, panes.',
705 ].join('\n'),
706 }
707 }
708 if (arg) return { text: 'Usage: /waypoint [refresh|config]' }
709 const open = (await $.ui.panes()).some(p => p.id === PANE)
710 if (open) {
711 await $.ui.close({ id: PANE })
712 return { text: 'pane closed' }
713 }
714 await $.ui.open({ id: PANE, title: 'Waypoint', rows: 36 })
715 void refresh($).catch(() => undefined)
716
717 return { text: 'pane opened' }
718 })
719
720 // One row above the prompt: capsules for the goal, each count, the roadmap, the team and the locks
721 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
722 if (e.props.hasSurvey) return next(e)
723 const s = await read($, snapshot)
724 if (s && !s.isShown) return next(e)
725 const g = await read($, goal)
726 const list = await read($, todos)
727 const p = await palette($)
728 const w = WORDS[language(cfg, s, g, list, lang)]
729 const all = bandSegments(s, g, list, p, w, cfg.showTeam)
730 if (all.length === 0) return next(e)
731
732 const { Box, Text } = $.ui.resolve(e)
733 const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 100
734 const isCapsule = cfg.style === 'capsule'
735 const kept = fitSegments(all, columns, isCapsule ? 1 : 3)
736
737 return (
738 <Box>
739 <Text wrap="truncate-end">
740 {kept.map((seg, i) => (
741 <Text>
742 {i > 0 && <Text color={p.track}>{isCapsule ? ' ' : ' · '}</Text>}
743 <Text backgroundColor={isCapsule ? p.fill : undefined}>
744 {isCapsule ? ' ' : ''}
745 {seg.parts.map(part => (
746 <Text color={part.color} bold={part.bold}>
747 {part.text}
748 </Text>
749 ))}
750 {isCapsule ? ' ' : ''}
751 </Text>
752 </Text>
753 ))}
754 </Text>
755 </Box>
756 )
757 })
758
759 // The detail pane: everything the band abbreviates
760 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
761 const { Box, Text } = $.ui.resolve(e)
762 const s = await read($, snapshot)
763 const g = await read($, goal)
764 const list = await read($, todos)
765 const p = await palette($)
766 const w = WORDS[language(cfg, s, g, list, lang)]
767 const width = Math.max(20, (e.props.bodyColumns ?? e.viewport?.columns ?? 60) - 2)
768 const title = (text: string) => (
769 <Text color={p.subtext} bold>
770 {text}
771 </Text>
772 )
773 const stateColor: Record<WpAgent['state'], string> = {
774 busy: p.green,
775 idle: p.overlay,
776 approval: p.orange,
777 stuck: p.red,
778 unknown: p.overlay,
779 }
780
781 const done = list.filter(t => t.status === 'completed').length
782 const goalBlock =
783 g || list.length > 0 ? (
784 <Box flexDirection="column">
785 {title(w.goal)}
786 {g && (
787 <Text>
788 <Text color={g.isMet ? p.green : p.indigo}>{g.isMet ? '✓ ' : '◎ '}</Text>
789 <Text color={p.text} bold>
790 {g.condition}
791 </Text>
792 </Text>
793 )}
794 {g?.reason && !g.isMet && <Text color={p.overlay}>{g.reason}</Text>}
795 {list.length > 0 && (
796 <Text color={p.subtext}>
797 {w.todos} {done}/{list.length}
798 </Text>
799 )}
800 {list.slice(0, 12).map(t => (
801 <Text wrap="truncate-end">
802 <Text color={t.status === 'completed' ? p.green : t.status === 'in_progress' ? p.blue : p.track}>
803 {t.status === 'completed' ? ' ✓ ' : t.status === 'in_progress' ? ' ▸ ' : ' ○ '}
804 </Text>
805 <Text color={t.status === 'completed' ? p.overlay : p.text}>{t.status === 'in_progress' ? t.active || t.text : t.text}</Text>
806 </Text>
807 ))}
808 </Box>
809 ) : null
810
811 const countBlock =
812 s && s.counts.length > 0 ? (
813 <Box flexDirection="column">
814 {title(w.checklists)}
815 {s.counts.map(c => (
816 <Box flexDirection="column">
817 <Text wrap="truncate-end">
818 {countParts(c, p, Math.min(40, width - 16), true).map(part => (
819 <Text color={part.color} bold={part.bold}>
820 {part.text}
821 </Text>
822 ))}
823 {c.detail && <Text color={p.overlay}> {clipWidth(c.detail, Math.max(10, width - 30))}</Text>}
824 </Text>
825 {(c.items ?? [])
826 .filter(it => !it.isDone)
827 .slice(0, 8)
828 .map(it => (
829 <Text wrap="truncate-end">
830 <Text color={p.track}>{' ○ '}</Text>
831 <Text color={p.text}>{clipWidth(it.text, width - 4)}</Text>
832 </Text>
833 ))}
834 </Box>
835 ))}
836 </Box>
837 ) : null
838
839 const roadBlock =
840 s && s.phases.length > 0 ? (
841 <Box flexDirection="column">
842 {title(w.roadmap)}
843 {s.phases.map((ph, i) => (
844 <Text wrap="truncate-end">
845 <Text color={i === s.current ? p.blue : ph.status === 'done' ? p.green : p.track}>
846 {i === s.current ? '◉ ' : ph.status === 'done' ? '● ' : '○ '}
847 </Text>
848 <Text color={ph.status === 'done' ? p.subtext : p.text} bold={i === s.current}>
849 {ph.name}
850 </Text>
851 </Text>
852 ))}
853 </Box>
854 ) : null
855
856 const teamBlock =
857 cfg.showTeam && s?.team && s.team.length > 0 ? (
858 <Box flexDirection="column">
859 {title(`${w.team} ${s.team.length}`)}
860 {[...s.team]
861 .sort((x, y) => STATE_ORDER.indexOf(x.state) - STATE_ORDER.indexOf(y.state))
862 .map(a => {
863 const held = s.locks.find(l => sameName(l.actor, a.name))
864 const where = a.name.toLowerCase().includes(a.agent.toLowerCase()) ? '' : `${a.agent} · `
865 return (
866 <Text wrap="truncate-end">
867 <Text color={stateColor[a.state]}>● </Text>
868 <Text color={p.text}>{a.name}</Text>
869 <Text color={stateColor[a.state]}> {w[a.state]}</Text>
870 {held && <Text color={p.orange}> {w.lock}{held.task ? ` ${held.task}` : ''}</Text>}
871 <Text color={p.overlay}>
872 {' '}
873 {where}
874 {a.cwd.split('/').pop()}
875 </Text>
876 </Text>
877 )
878 })}
879 </Box>
880 ) : null
881
882 const lockBlock =
883 s && s.locks.length > 0 ? (
884 <Box flexDirection="column">
885 {title(w.locks)}
886 {s.locks.map(l => (
887 <Text wrap="truncate-end">
888 <Text color={p.orange}>● </Text>
889 <Text color={p.text}>{l.actor}</Text>
890 {l.task && <Text color={p.subtext}> {l.task}</Text>}
891 {l.since && <Text color={p.overlay}> {ago(s.at, l.since)}</Text>}
892 <Text color={p.overlay}> {l.label}</Text>
893 </Text>
894 ))}
895 </Box>
896 ) : null
897
898 const notes = [...(s && !s.configPath ? [w.noConfig] : []), ...(s?.problems ?? [])]
899 const blocks = [goalBlock, countBlock, teamBlock, lockBlock, roadBlock].filter(b => b !== null)
900
901 return (
902 <Box flexDirection="column" rowGap={1}>
903 {s && (
904 <Text>
905 <Text color={p.text} bold>
906 {s.project}
907 </Text>
908 </Text>
909 )}
910 {blocks.length === 0 && <Text color={p.subtext}>{w.nothing}</Text>}
911 {blocks}
912 {notes.length > 0 && (
913 <Box flexDirection="column">
914 {notes.map(n => (
915 <Text color={p.overlay} wrap="truncate-end">
916 {n}
917 </Text>
918 ))}
919 </Box>
920 )}
921 </Box>
922 )
923 })
924}
925hooks/parse.ts 345 lines1// Pure parsers and layout helpers: no `$`, so the tests run them directly
2
3import type { WpAgentState, WpGoal, WpPhase, WpTodo } from '../types'
4
5export type Heading = { level: number; text: string; line: number }
6
7export type Section = { heading: string; label: string; done: number; total: number; items: { text: string; isDone: boolean }[] }
8
9const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
10// A checkbox at the left margin; indented ones are sub-steps of the item above
11const CHECKBOX = /^[-*+] \[( |x|X)\]\s+(.*)$/
12
13function stripMarkdown(text: string): string {
14 return text
15 .replace(/\*\*|__|`/g, '')
16 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
17 .trim()
18}
19
20// `一·三、第三批(2026-10-07 起,goal 范围)` → `第三批`; `2. Beta (in progress)` → `Beta`
21export function sectionLabel(heading: string): string {
22 const plain = stripMarkdown(heading)
23 .replace(/[((][^))]*[))]/g, '')
24 .replace(/^[\d一二三四五六七八九十百·.、::\s-]+(?=\S)/, '')
25 .trim()
26
27 return plain || stripMarkdown(heading)
28}
29
30export function headings(markdown: string): Heading[] {
31 const out: Heading[] = []
32 let isFenced = false
33 markdown.split('\n').forEach((line, i) => {
34 if (/^\s*(```|~~~)/.test(line)) isFenced = !isFenced
35 if (isFenced) return
36 const m = line.match(HEADING)
37 if (m) out.push({ level: m[1]!.length, text: m[2]!, line: i })
38 })
39
40 return out
41}
42
43// Every heading's own checklist: the top-level checkboxes between it and the next heading, so a title that
44// encloses the whole file never swallows its batches
45export function sections(markdown: string): Section[] {
46 const lines = markdown.split('\n')
47 const hs = headings(markdown)
48 const out: Section[] = []
49 hs.forEach((head, i) => {
50 const end = hs[i + 1]?.line ?? lines.length
51 const items: Section['items'] = []
52 let isFenced = false
53 for (const line of lines.slice(head.line + 1, end)) {
54 if (/^\s*(```|~~~)/.test(line)) isFenced = !isFenced
55 if (isFenced) continue
56 const m = line.match(CHECKBOX)
57 if (m) items.push({ text: stripMarkdown(m[2]!), isDone: m[1] !== ' ' })
58 }
59 out.push({
60 heading: stripMarkdown(head.text),
61 label: sectionLabel(head.text),
62 done: items.filter(it => it.isDone).length,
63 total: items.length,
64 items,
65 })
66 })
67
68 return out
69}
70
71// The whole file's top-level checkboxes, for a list with no headings
72export function wholeFile(markdown: string, label: string): Section {
73 const items = markdown
74 .split('\n')
75 .map(line => line.match(CHECKBOX))
76 .filter((m): m is RegExpMatchArray => m !== null)
77 .map(m => ({ text: stripMarkdown(m[2]!), isDone: m[1] !== ' ' }))
78
79 return { heading: label, label, done: items.filter(it => it.isDone).length, total: items.length, items }
80}
81
82export type SectionRule = { section?: string; marker?: string }
83
84/**
85 * Picks the checklist to follow: a heading containing `section`; else the LAST heading containing `marker`
86 * (a batch file where each new batch is appended and marked); else the first heading whose own items are not
87 * all done; else the whole file.
88 */
89export function pickSection(markdown: string, rule: SectionRule, fallbackLabel: string): Section | null {
90 const all = sections(markdown).filter(s => s.total > 0)
91 if (rule.section) return all.find(s => s.heading.includes(rule.section!)) ?? null
92 if (rule.marker) {
93 const marked = all.filter(s => s.heading.includes(rule.marker!))
94 if (marked.length > 0) return marked[marked.length - 1]!
95 }
96 const open = all.find(s => s.done < s.total)
97 if (open) return open
98 const whole = wholeFile(markdown, fallbackLabel)
99
100 return whole.total > 0 ? whole : null
101}
102
103const PHASE_HEAD = /阶段|里程碑|版本|phase|stage|milestone|version|release/i
104const STATUS_HEAD = /状态|进度|status|state|progress/i
105
106function cells(row: string): string[] {
107 return row
108 .trim()
109 .replace(/^\||\|$/g, '')
110 .split('|')
111 .map(c => stripMarkdown(c))
112}
113
114export function phaseStatus(status: string): WpPhase['status'] {
115 const s = status.trim()
116 if (/未开始|not started|planned|^todo$|^[—–-]?$/i.test(s)) return 'todo'
117 if (/未完成|incomplete/i.test(s)) return 'active'
118 if (/完成|已发布|已上线|done|complete|shipped|released|✅|✓|✔/i.test(s)) return 'done'
119 if (/进行|研究|开发中|in progress|doing|wip|active|current|ongoing|underway|🚧/i.test(s)) return 'active'
120
121 return 'todo'
122}
123
124// The first Markdown table with a phase column and a status column, one phase per row
125export function roadmapPhases(markdown: string): WpPhase[] {
126 const lines = markdown.split('\n')
127 for (let i = 0; i + 1 < lines.length; i++) {
128 const head = lines[i]!
129 if (!head.trim().startsWith('|') || !/^\s*\|?\s*:?-{2,}/.test(lines[i + 1]!)) continue
130 const names = cells(head)
131 const phaseCol = names.findIndex(n => PHASE_HEAD.test(n))
132 const statusCol = names.findIndex(n => STATUS_HEAD.test(n))
133 if (phaseCol < 0 || statusCol < 0) continue
134 const phases: WpPhase[] = []
135 for (const row of lines.slice(i + 2)) {
136 if (!row.trim().startsWith('|')) break
137 const c = cells(row)
138 const name = c[phaseCol] ?? ''
139 if (!name) continue
140 phases.push({ name, status: phaseStatus(c[statusCol] ?? '') })
141 }
142 if (phases.length > 0) return phases
143 }
144
145 return []
146}
147
148// The phase named by `current` (a prefix of its name), else the first under way, else the first not done
149export function currentPhase(phases: WpPhase[], current?: string): number {
150 if (current) {
151 const named = phases.findIndex(p => p.name.startsWith(current) || p.name.includes(current))
152 if (named >= 0) return named
153 }
154 const active = phases.findIndex(p => p.status === 'active')
155 if (active >= 0) return active
156
157 return phases.findIndex(p => p.status !== 'done')
158}
159
160// `0.2 自有化与品牌` → `0.2`; a phase with no version-like lead keeps its first word
161export function phaseShort(name: string): string {
162 const version = name.match(/^v?\d+(?:\.\d+)*/i)
163 if (version) return version[0]
164 const word = name.split(/[\s::((]/)[0] ?? name
165
166 return word.length > 12 ? `${word.slice(0, 11)}…` : word
167}
168
169// Columns a string takes in a terminal: East Asian wide and emoji count two
170export function displayWidth(text: string): number {
171 let width = 0
172 for (const ch of text) {
173 const c = ch.codePointAt(0)!
174 if (c === 0x200d || (c >= 0xfe00 && c <= 0xfe0f) || (c >= 0x300 && c <= 0x36f)) continue
175 const isWide =
176 (c >= 0x1100 && c <= 0x115f) ||
177 (c >= 0x2e80 && c <= 0x303e) ||
178 (c >= 0x3041 && c <= 0x33ff) ||
179 (c >= 0x3400 && c <= 0x4dbf) ||
180 (c >= 0x4e00 && c <= 0x9fff) ||
181 (c >= 0xa000 && c <= 0xa4cf) ||
182 (c >= 0xac00 && c <= 0xd7a3) ||
183 (c >= 0xf900 && c <= 0xfaff) ||
184 (c >= 0xfe30 && c <= 0xfe4f) ||
185 (c >= 0xff00 && c <= 0xff60) ||
186 (c >= 0xffe0 && c <= 0xffe6) ||
187 (c >= 0x1f300 && c <= 0x1faff) ||
188 (c >= 0x20000 && c <= 0x3fffd)
189 width += isWide ? 2 : 1
190 }
191
192 return width
193}
194
195// Cuts to `max` columns, an ellipsis in the last one
196export function clipWidth(text: string, max: number): string {
197 const line = text.replace(/\s+/g, ' ').trim()
198 if (displayWidth(line) <= max) return line
199 let out = ''
200 for (const ch of line) {
201 if (displayWidth(out + ch) > max - 1) break
202 out += ch
203 }
204
205 return `${out}…`
206}
207
208export type Fit = { key: string; width: number; priority: number }
209
210/**
211 * Keeps the segments that fit in `columns`, `gap` columns between each, dropping the lowest priority first
212 * (a higher number is dropped later); the order of what stays is the order given.
213 */
214export function fitSegments<T extends Fit>(segments: T[], columns: number, gap: number): T[] {
215 const kept = [...segments]
216 const total = () => kept.reduce((sum, s) => sum + s.width, 0) + Math.max(0, kept.length - 1) * gap
217 while (kept.length > 1 && total() > columns) {
218 let lowest = 0
219 kept.forEach((s, i) => {
220 if (s.priority < kept[lowest]!.priority) lowest = i
221 })
222 kept.splice(lowest, 1)
223 }
224
225 return kept
226}
227
228// A five-cell bar: ▰ per fifth done
229export function bar(done: number, total: number, cells = 5): { filled: string; empty: string } {
230 const n = total > 0 ? Math.min(cells, Math.round((done / total) * cells)) : 0
231
232 return { filled: '▰'.repeat(n), empty: '▱'.repeat(cells - n) }
233}
234
235// The goal a /goal command or ProposeGoal set, from the transcript's text; `null` once cleared
236export function goalFromText(text: string): string | null | undefined {
237 const command = text.match(/<command-name>\/goal<\/command-name>[\s\S]*?<command-args>([\s\S]*?)<\/command-args>/)
238 if (command) {
239 const args = command[1]!.trim()
240 if (args === '' || /^clear$/i.test(args)) return null
241 return args
242 }
243 const hook = text.match(/Stop hook is now active with condition: "([\s\S]*?)"\.\s/)
244 if (hook) return hook[1]!
245
246 return undefined
247}
248
249// Applies one TodoWrite, TaskCreate or TaskUpdate call to the todo list
250export function replayTodo(list: WpTodo[], tool: string, input: Record<string, unknown>, result: unknown): WpTodo[] {
251 const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
252 if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
253 return input.todos.map((t: Record<string, unknown>, i: number) => ({
254 id: String(i),
255 text: str(t.content) ?? '',
256 active: str(t.activeForm) ?? str(t.content) ?? '',
257 status: (t.status as WpTodo['status']) ?? 'pending',
258 }))
259 }
260 if (tool === 'TaskCreate') {
261 const id = str((result as { task?: { id?: unknown } } | undefined)?.task?.id)
262 const text = str(input.subject) ?? ''
263 return id ? [...list, { id, text, active: str(input.activeForm) ?? text, status: 'pending' }] : list
264 }
265 if (tool === 'TaskUpdate') {
266 const id = str(input.taskId)
267 const status = str(input.status)
268 if (status === 'deleted') return list.filter(t => t.id !== id)
269 return list.map(t =>
270 t.id === id
271 ? {
272 ...t,
273 text: str(input.subject) ?? t.text,
274 active: str(input.activeForm) ?? t.active,
275 status: (status as WpTodo['status'] | undefined) ?? t.status,
276 }
277 : t,
278 )
279 }
280
281 return list
282}
283
284export const STUCK_PATTERNS = ['at capacity', 'rate limit', 'usage limit', 'overloaded', 'quota exceeded', 'hit your limit']
285// Claude Code's permission prompt and Codex's approval prompt
286export const APPROVAL_PATTERNS = ['Do you want to proceed', 'Do you want to make this edit', 'Would you like to']
287
288// An agent pane's state from Otty's own and the last lines on its screen
289export function paneState(ottyState: string, screen: string, stuck: string[], approval: string[], agent = ''): WpAgentState {
290 const tail = screen.toLowerCase()
291 if (stuck.some(p => tail.includes(p.toLowerCase()))) return 'stuck'
292 if (approval.some(p => tail.includes(p.toLowerCase()))) return 'approval'
293 if (ottyState === 'processing') return 'busy'
294 // Claude Code reports `awaiting` on a permission prompt; Codex reports it at its idle prompt too
295 if (ottyState === 'awaiting' && agent === 'Claude Code') return 'approval'
296 if (ottyState === 'idle' || ottyState === 'awaiting') return 'idle'
297
298 return 'unknown'
299}
300
301// Whether `path` is `dir` or lies under it
302export function isUnder(path: string, dir: string): boolean {
303 const d = dir.replace(/\/+$/, '')
304
305 return path === d || path.startsWith(`${d}/`)
306}
307
308export function hasCjk(text: string): boolean {
309 return /[㐀-鿿가-힣-ヿ]/.test(text)
310}
311
312export function normalizePane(id: string): string {
313 return id.startsWith('p_') ? id : `p_${id}`
314}
315
316// The goal as the newest transcript line about it leaves it: a goal_status record, `/goal <condition>`, or a
317// clear (null). Lines are the transcript's JSONL rows that mention a goal, oldest first
318export function latestGoal(lines: string[]): WpGoal | null {
319 // Newest first; a line that merely quotes these strings (a tool result) is passed over
320 for (const line of [...lines].reverse()) {
321 let row: {
322 type?: string
323 content?: unknown
324 attachment?: { type?: string; condition?: string; met?: boolean; reason?: string }
325 message?: { content?: unknown }
326 }
327 try {
328 row = JSON.parse(line)
329 } catch {
330 continue
331 }
332 if (row.attachment?.type === 'goal_status' && row.attachment.condition) {
333 return { condition: row.attachment.condition, isMet: row.attachment.met === true, reason: row.attachment.reason }
334 }
335 // The command as typed is a user row; `/goal clear` is kept as a local-command notice, its text at the top
336 const raw = row.type === 'user' ? row.message?.content : row.type === 'system' ? row.content : undefined
337 const content = typeof raw === 'string' ? raw : ''
338 if (!content.trimStart().startsWith('<command-name>/goal</command-name>')) continue
339 const goal = goalFromText(content)
340 return goal ? { condition: goal, isMet: false } : null
341 }
342
343 return null
344}
345types/index.d.ts 43 lines1export type WpTodo = { id: string; text: string; active: string; status: 'pending' | 'in_progress' | 'completed' }
2
3// The session's /goal: its condition, and whether the evaluator found it met
4export type WpGoal = { condition: string; isMet: boolean; reason?: string }
5
6// One count of done out of total: a checklist section, a counter, an epic, a milestone
7export type WpCount = {
8 kind: 'checklist' | 'counter' | 'beads' | 'github'
9 label: string
10 done: number
11 total: number
12 detail?: string
13 items?: { text: string; isDone: boolean }[]
14}
15
16export type WpPhase = { name: string; status: 'done' | 'active' | 'todo' }
17
18export type WpAgentState = 'busy' | 'idle' | 'approval' | 'stuck' | 'unknown'
19
20export type WpAgent = { pane: string; name: string; agent: string; state: WpAgentState; cwd: string }
21
22export type WpLock = { label: string; actor: string; task?: string; since?: string; path: string }
23
24export type WpSnapshot = {
25 project: string
26 isShown: boolean
27 counts: WpCount[]
28 phases: WpPhase[]
29 current: number
30 team: WpAgent[] | null
31 locks: WpLock[]
32 hasLocks: boolean
33 problems: string[]
34 configPath?: string
35 at: number
36}
37
38declare module 'claude-code' {
39 interface PluginState {
40 waypoint: { goal: WpGoal | null; todos: WpTodo[]; snapshot: WpSnapshot | null }
41 }
42}
43