See how far along Claude is, and when it will land.

<img src="../../brand/pixel-icons/svg/headway.svg" width="64" height="64" alt="">
See how far along Claude is, and when it will land.
Build · Wire the routes (2/6) ▰▰▱▱▱▱ ~3m left
/plugin install headway --marketplace modmyclaude/claude-code-mods
Nothing to set up. Headway reads the plan Claude already keeps and draws one line above your prompt while that plan is in progress:
estimating... instead.7m 12s on this step).plan revised +2 −1 for a few seconds.Plan done in 4m 12s, 18s ahead of estimate. Build 2m 1s · Verify 2m 11s) and the line leaves.3 agents chip sits at the end of the line. No agent details.| Command | What it does |
|---|---|
/headway | Opens a pane with every step, its status ([x] done, [>] current, [ ] to do) and its time, plus a button to hide or show the line. Works mid-turn. |
/headway hide | Hides the line for this session. |
/headway show | Shows it again. |
Each time a plan finishes, Headway writes the $.state value headway.done: { seq, at, totalMs, steps, stageMs, estimateMs } (counts and durations, never titles). Its type is in types/index.d.ts as HeadwayDone; list headway under dependencies in your plugin.json to get it typed. Then either:
await $.state.get({ plugin: 'headway', key: 'done' }), and your render hook redraws whenever a plan finishes, oron('state.set', { plugin: 'headway', key: 'done' }, async ($, e, next) => {
if (e.value) $.ui.toast(`${e.value.steps} steps done`)
return next(e)
})
/headway says it is off. /config or the modmyclaude hub flips it, live.$.store, a JSON file Claude Code keeps for the mod): step durations, plan counts and when each project was last updated (to keep the most recent), never step titles. Projects are keyed by a hash of the project root, not the path. Capped at the last 40 step times for each of the 30 most recent projects./headway answers with no text for the model; the pane and toasts never reach it./headway sent as the very first prompt of a new session may show "/headway isn't a command here." The app checks the command before mods have registered theirs; the command still runs, and later ones show no such line.estimating...) while the sample coefficient of variation of the last 8 step times is above 1.0, roughly when one step took six times another. One long step (a full test run among quick edits) stops hiding it once eight newer steps have finished; the per-step median still uses the whole session.Plan approved · 2 stages, 6 steps until that turn ends, then clears it./clear or resuming another conversation clears the plan at once.$.state value, not a telemetry mark: marks feed Claude Code's own analytics, which a display-only mod should not touch./headway pane opens on any surface that places panes.plan, shaped like TodoWrite and listed with Claude's other tools, so the line still follows Claude's plan. Claude decides whether to use it: a turn where it plans only in prose shows nothing. Where Claude Code has its own plan tools, Headway lends none.More mods: the full catalogue.
hooks/register.tsx 353 lines1// Headway: a one-line progress bar for the plan Claude is working through,
2// with an honest estimate of when it lands. It only watches: Claude's own
3// todo, task and plan-mode tools, its turns and its subagents. It never
4// refuses a call, never adds to the prompt and never calls a model.
5
6import { atom, read, update } from 'claude-code'
7import type { BoxProps, ElementConstructor, EngineInterface, Register, RenderElement, TextProps, Timer } from 'claude-code'
8
9import type { HeadwayRun } from '../types'
10import { HISTORY_KEY, priorFor, projectKey, readPace, recordPlan } from './history'
11import { EMPTY_RUN, TICK_MS, bandView, layoutBand, paneRows, reduce, wantsTimer, type Effect, type HeadwayEvent, type Part, type Tone } from './model'
12import { eventFromTool, planCount } from './sources'
13
14const PANE = 'headway'
15/** How long the receipt toast stays. */
16const RECEIPT_MS = 10_000
17
18const run = atom({ plugin: 'headway', key: 'run' } as const, EMPTY_RUN)
19const done = atom({ plugin: 'headway', key: 'done' } as const, null)
20
21// The switch (`enabled`, which /config and the modmyclaude hub flip): off, Headway draws
22// nothing, lends no tool, keeps no timer and carries out no effect (no receipt, no "plan
23// done", no pace history); it still follows the plan, so switched on it shows where things
24// stand. The engine reloads the mod with the new value; the config.set hook below is the
25// same switch at once.
26let isOn = true
27const OFF_TEXT = 'Headway is off. /mods or /config turns it on.'
28
29// The one timer. Module state on purpose: a reload drops the timer with the
30// old environment, and the next event starts a fresh one.
31let timer: Timer | null = null
32// Each syncTimer call takes a number; after its await only the newest acts, so two never start two timers.
33let timerSeq = 0
34
35function styleOf(tone: Tone): { dimColor?: boolean; color?: string } {
36 if (tone === 'dim') return { dimColor: true }
37 if (tone === 'long') return { color: 'warning' }
38 if (tone === 'done') return { color: 'success' }
39 if (tone === 'note') return { color: 'suggestion' }
40 return {}
41}
42
43/**
44 * The bar on the desktop. Its Text is proportional, where ▰ and ▱ draw thin and faint, so there
45 * each step is one code-font cell, █ done and ░ left (the collection's bar), apart from the words.
46 */
47function deskBar(Box: ElementConstructor<BoxProps>, Text: ElementConstructor<TextProps>, part: Part): RenderElement {
48 const glyph = part.key === 'bar-done' ? '█' : '░'
49 const steps = [...part.text.trim()].length
50 return (
51 <Box key={part.key} flexDirection="row" flexShrink={0}>
52 {part.text.startsWith(' ') ? <Text key="gap"> </Text> : null}
53 {Array.from({ length: steps }, (_, i) => (
54 <Box key={`cell-${i}`} width={1}>
55 <Text {...styleOf(part.tone)}>{glyph}</Text>
56 </Box>
57 ))}
58 </Box>
59 )
60}
61
62async function safely($: EngineInterface, work: () => Promise<unknown>): Promise<void> {
63 try {
64 await work()
65 } catch (error) {
66 $.ui.log(`headway: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
67 }
68}
69
70/** Runs one event through the reducer, carries out its effects and keeps the timer right. */
71async function dispatch($: EngineInterface, event: HeadwayEvent): Promise<HeadwayRun> {
72 const wall = await $.clock.now()
73 const current = await read($, run)
74 if (reduce(current, event, wall).run === current) {
75 await syncTimer($, current, wall)
76 return current
77 }
78 let effects: Effect[] = []
79 const next = await update($, run, value => {
80 const out = reduce(value, event, wall)
81 effects = out.effects
82 return out.run
83 })
84 // One effect failing (say the store) must not skip the others or leave the timer wrong.
85 if (isOn) for (const effect of effects) await safely($, () => carryOut($, effect, wall))
86 await syncTimer($, next, wall)
87 return next
88}
89
90async function carryOut($: EngineInterface, effect: Effect, wall: number): Promise<void> {
91 if (effect.kind === 'receipt') {
92 $.ui.toast(effect.text, { timeoutMs: RECEIPT_MS })
93 } else if (effect.kind === 'finished') {
94 // The public "plan done" moment, then this project's pace history.
95 await update($, done, () => effect.done)
96 if (effect.durations.length > 0) {
97 const key = effect.projectKey ?? projectKey(await $.session.root())
98 const file = readPace(await $.store.get(HISTORY_KEY))
99 await $.store.set(HISTORY_KEY, recordPlan(file, key, effect.durations, wall))
100 }
101 } else {
102 const key = projectKey(await $.session.root())
103 const prior = priorFor(readPace(await $.store.get(HISTORY_KEY)), key)
104 await dispatch($, { kind: 'attach', planId: effect.planId, projectKey: key, ...(prior ? { prior } : {}) })
105 }
106}
107
108/**
109 * True while a surface that draws the band (the terminal or the desktop app)
110 * is attached. Not isInteractive: that is false under the SDK, which is how
111 * the desktop app runs a session. VS Code, mobile and a `-p` run draw no band.
112 */
113async function isDrawn($: EngineInterface): Promise<boolean> {
114 const surfaces = await $.session.surfaces().catch(() => [])
115 return surfaces.some(surface => surface === 'terminal' || surface === 'desktop')
116}
117
118/** Starts or stops the one timer: a plan to keep current, on a surface that draws it. */
119async function syncTimer($: EngineInterface, value: HeadwayRun, wall: number): Promise<void> {
120 const seq = ++timerSeq
121 const wants = isOn && wantsTimer(value, wall) && (await isDrawn($))
122 if (seq !== timerSeq) return // a newer call decides
123 if (wants && timer === null) {
124 timer = $.clock.every(TICK_MS, () => void tick($))
125 } else if (!wants && timer !== null) {
126 timer.cancel()
127 timer = null
128 }
129}
130
131async function tick($: EngineInterface): Promise<void> {
132 await safely($, async () => {
133 await dispatch($, { kind: 'tick' })
134 // Time left and "running" counts move with the clock, not with state.
135 $.ui.invalidate('ui.render')
136 })
137}
138
139/** Claude Code's own plan tools: when any is present Headway lends none. */
140const NATIVE_PLAN_TOOLS: ReadonlySet<string> = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate'])
141const PLAN_TOOL_NAME = 'mcp__headway__plan'
142/** The plan tool Headway lends where Claude has none (the desktop app), in TodoWrite's shape. */
143const PLAN_TOOL = {
144 name: 'plan',
145 description:
146 'Keep a visible plan for multi-step work. When a task has three or more steps, call this first with every step ' +
147 '(the first "in_progress", the rest "pending"), then again each time a step starts or finishes, always with the ' +
148 'full list. Exactly one step is "in_progress" at a time; mark a step "completed" as soon as it is done. The ' +
149 'person sees the plan as a progress line above their prompt; it changes nothing else.',
150 inputSchema: {
151 type: 'object',
152 properties: {
153 todos: {
154 type: 'array',
155 items: {
156 type: 'object',
157 properties: {
158 content: { type: 'string', description: 'The step, in a few words' },
159 status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
160 },
161 required: ['content', 'status'],
162 },
163 },
164 },
165 required: ['todos'],
166 },
167 isDeferred: false,
168} as const
169
170export const register: Register = (on, options) => {
171 isOn = options.enabled !== false
172
173 on('config.set', { key: 'headway.enabled' }, async ($, e, next) => {
174 const set = await next(e)
175 if (set.deny === undefined && typeof set.value === 'boolean') {
176 isOn = set.value
177 // Off, the next tick stops the timer; on, the plan it kept following runs its clock again now.
178 if (isOn) await safely($, async () => syncTimer($, await read($, run), await $.clock.now()))
179 $.ui.invalidate('ui.render')
180 }
181 return set
182 })
183
184 on('session.start', async ($, e, next) => {
185 await $.command.register({
186 name: 'headway',
187 description: "Show Claude's plan with each step's status and time; /headway hide or show toggles the line",
188 argumentHint: '[hide|show]',
189 immediate: true,
190 })
191 // The desktop app gives Claude no todo tool. There Headway lends one shaped like
192 // TodoWrite, so Claude can share its plan and the line can follow it; never beside a native one.
193 // Off, it lends nothing.
194 if (isOn) {
195 try {
196 const names = (await $.tool.list()).map(tool => tool.name)
197 if (!names.some(name => NATIVE_PLAN_TOOLS.has(name))) await $.tool.register(PLAN_TOOL)
198 } catch (error) {
199 $.ui.log(`headway: no plan tool lent (${String(error)})`, { to: 'debug' })
200 }
201 // Reloaded (a switch flip does that) mid-plan: its clock runs again without waiting for Claude.
202 await safely($, async () => syncTimer($, await read($, run), await $.clock.now()))
203 }
204 return next(e)
205 })
206
207 // Headway's own plan tool, where Claude has no todo tool: answered here, read as TodoWrite is.
208 on('tool.call', { tool: PLAN_TOOL_NAME }, async ($, e) => {
209 // Lent before the switch went off: still answered, so Claude's call does not fail.
210 if (!isOn) return { result: OFF_TEXT, text: OFF_TEXT }
211 const event = eventFromTool('TodoWrite', e, null, e.agentId === undefined)
212 if (event !== null) await safely($, () => dispatch($, event))
213 // A lent (MCP) tool's result is text: Claude Code refuses an object there (seen live).
214 const text = event === null ? 'No plan read: send todos as [{ content, status }].' : 'Plan updated.'
215 return { result: text, text }
216 }).catch(() => ({ result: 'Plan not recorded.', text: 'Plan not recorded.' }))
217
218 // The plan call's row in the terminal: Claude Code prints a lent tool's whole input there, so the
219 // row would carry the plan's JSON. It shows the count instead; the band already shows the steps.
220 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
221 if (!isOn || e.surface !== 'terminal' || e.props.tool !== PLAN_TOOL_NAME) return next(e)
222 const count = planCount(e.props.input)
223 return next(count === null ? e : { ...e, props: { ...e.props, input: count } })
224 }).catch(($, e, next) => next(e))
225
226 // A /clear or a resumed conversation is a different conversation: its plan is not this one's.
227 on('session.end', async ($, e, next) => {
228 if (e.reason === 'clear' || e.reason === 'resume') await safely($, () => dispatch($, { kind: 'clear' }))
229 return next(e)
230 })
231
232 // The desktop app attaches after the session starts: the timer follows what draws the band.
233 // (A detach needs no hook: the next tick finds nothing drawing and stops it.)
234 on('session.attach', async ($, e, next) => {
235 const attached = await next(e)
236 await safely($, async () => syncTimer($, await read($, run), await $.clock.now()))
237 return attached
238 })
239
240 on('turn.start', async ($, e, next) => {
241 await safely($, () => dispatch($, { kind: 'turn', isRunning: true }))
242 return next(e)
243 })
244
245 on('turn.complete', async ($, e, next) => {
246 const agentId = e.agentId
247 const event: HeadwayEvent = agentId === undefined ? { kind: 'turn', isRunning: false } : { kind: 'agent-end', id: agentId }
248 await safely($, () => dispatch($, event))
249 return next(e)
250 })
251
252 // Counts running subagents for the chip; never refuses a spawn.
253 on('agent.spawn', async ($, e, next) => {
254 const started = await next(e)
255 const agentId = started.agentId
256 if (agentId !== undefined && e.isTeammate !== true) await safely($, () => dispatch($, { kind: 'agent-start', id: agentId }))
257 return started
258 }).catch(($, e, next) => next(e))
259
260 // Watches Claude's own plan tools after they ran; the result is handed back untouched.
261 on('tool.call', { tool: ['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'ExitPlanMode'] }, async ($, e, next) => {
262 const ran = await next(e)
263 if (ran.deny === undefined && ran.isError !== true) {
264 const event = eventFromTool(String(e.tool), e, ran.result, e.agentId === undefined)
265 if (event !== null) await safely($, () => dispatch($, event))
266 }
267 return ran
268 }).catch(($, e, next) => next(e))
269
270 on('command.run', { command: 'headway' }, async ($, e) => {
271 if (!isOn) {
272 $.ui.toast(OFF_TEXT)
273 return {}
274 }
275 const word = e.args.trim().toLowerCase()
276 if (word === 'hide' || word === 'show') {
277 await dispatch($, { kind: 'hidden', hidden: word === 'hide' })
278 $.ui.toast(word === 'hide' ? 'Headway line hidden. /headway show brings it back.' : 'Headway line shown.')
279 return {}
280 }
281 if (word !== '') {
282 $.ui.toast('try /headway, /headway hide or /headway show.')
283 return {}
284 }
285 const opened = await $.ui.open({ id: PANE, title: 'Headway', focus: true, closeOnEscape: true })
286 if (!opened.isPlaced) $.ui.toast(`the plan pane is waiting (${opened.reason}).`)
287 return {}
288 }).catch($ => {
289 $.ui.toast('that command failed.')
290 return {}
291 })
292
293 // The band: one line above the prompt, beneath whatever other mods draw there. Theirs comes
294 // first, so when the band overflows its rows the scroll cuts Headway's line, never their buttons.
295 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
296 if (!isOn || e.props.hasSurvey) return next(e)
297 const value = await read($, run)
298 const view = value.hidden ? null : bandView(value, await $.clock.now())
299 if (view === null) return next(e)
300 const { Box, Text } = $.ui.resolve(e)
301 const parts = layoutBand(view, e.props.bodyColumns)
302 const below = await next(e)
303 return (
304 <Box flexDirection="column">
305 {below}
306 <Box key="headway" flexDirection="row">
307 {parts.map(part =>
308 e.surface === 'desktop' && part.key.startsWith('bar-') ? (
309 deskBar(Box, Text, part)
310 ) : (
311 <Text {...styleOf(part.tone)}>{part.text}</Text>
312 ),
313 )}
314 </Box>
315 </Box>
316 )
317 })
318
319 // The /headway pane: every step with its status and time.
320 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
321 if (!isOn) {
322 const { Box, Text } = $.ui.resolve(e)
323 return (
324 <Box flexDirection="column">
325 <Text key="off" dimColor>
326 {OFF_TEXT}
327 </Text>
328 </Box>
329 )
330 }
331 const value = await read($, run)
332 const last = await read($, done)
333 const rows = paneRows(value, last, await $.clock.now())
334 const { Box, Button, Text } = $.ui.resolve(e)
335 return (
336 <Box flexDirection="column">
337 {rows.map(row => (
338 <Text wrap="truncate-end" {...styleOf(row.tone)}>
339 {row.text}
340 </Text>
341 ))}
342 <Box marginTop={1}>
343 <Button
344 key="toggle"
345 label={value.hidden ? 'Show line' : 'Hide line'}
346 onPress={() => void safely($, () => dispatch($, { kind: 'hidden', hidden: 'toggle' }))}
347 />
348 </Box>
349 </Box>
350 )
351 })
352}
353hooks/history.ts 73 lines1// Per-project pace history, kept in the mod's own store: step durations and
2// plan counts only, never titles. Projects are keyed by a hash of their root.
3
4import type { HeadwayPrior } from '../types'
5import { median } from './pace'
6
7export const HISTORY_KEY = 'history'
8/** Durations kept per project (the most recent). */
9export const MAX_DURATIONS = 40
10/** Projects kept (the most recently updated). */
11export const MAX_PROJECTS = 30
12/** History speaks for a project only once it holds this many step times. */
13export const MIN_PRIOR = 3
14const MAX_STEP_MS = 24 * 60 * 60 * 1000
15
16export type ProjectPace = { d: number[]; plans: number; at: number }
17export type PaceFile = { v: 1; projects: Record<string, ProjectPace> }
18
19/** FNV-1a over the root twice (two seeds): a stable 16-hex-digit key that does not spell the path. */
20export function projectKey(root: string): string {
21 const hash = (seed: number) => {
22 let h = seed >>> 0
23 for (let i = 0; i < root.length; i += 1) {
24 h ^= root.charCodeAt(i)
25 h = Math.imul(h, 0x01000193) >>> 0
26 }
27 return h.toString(16).padStart(8, '0')
28 }
29 return hash(0x811c9dc5) + hash(0x01234567)
30}
31
32function isDuration(value: unknown): value is number {
33 return typeof value === 'number' && Number.isFinite(value) && value > 0 && value < MAX_STEP_MS
34}
35
36/** Reads what the store holds, dropping anything malformed. */
37export function readPace(raw: unknown): PaceFile {
38 const file: PaceFile = { v: 1, projects: {} }
39 if (typeof raw !== 'object' || raw === null) return file
40 const projects = (raw as { projects?: unknown }).projects
41 if (typeof projects !== 'object' || projects === null) return file
42 for (const [key, value] of Object.entries(projects as Record<string, unknown>)) {
43 if (!/^[0-9a-f]{16}$/.test(key) || typeof value !== 'object' || value === null) continue
44 const { d, plans, at } = value as { d?: unknown; plans?: unknown; at?: unknown }
45 if (!Array.isArray(d)) continue
46 file.projects[key] = {
47 d: d.filter(isDuration).slice(-MAX_DURATIONS),
48 plans: typeof plans === 'number' && Number.isFinite(plans) && plans >= 0 ? Math.floor(plans) : 0,
49 at: typeof at === 'number' && Number.isFinite(at) ? at : 0,
50 }
51 }
52 return file
53}
54
55/** Adds one finished plan's step durations to its project, within the caps. */
56export function recordPlan(file: PaceFile, key: string, durations: readonly number[], wall: number): PaceFile {
57 const before = file.projects[key] ?? { d: [], plans: 0, at: 0 }
58 const projects = {
59 ...file.projects,
60 [key]: { d: [...before.d, ...durations.filter(isDuration).map(Math.round)].slice(-MAX_DURATIONS), plans: before.plans + 1, at: wall },
61 }
62 const kept = Object.entries(projects)
63 .sort(([, a], [, b]) => b.at - a.at)
64 .slice(0, MAX_PROJECTS)
65 return { v: 1, projects: Object.fromEntries(kept) }
66}
67
68/** The project's pace from earlier sessions, once it has enough to say. */
69export function priorFor(file: PaceFile, key: string): HeadwayPrior | undefined {
70 const durations = file.projects[key]?.d ?? []
71 return durations.length >= MIN_PRIOR ? { medianMs: median(durations), count: durations.length } : undefined
72}
73hooks/model.ts 633 lines1// Headway's model: one pure reducer over its session state, plus the views
2// the band and the pane draw. The hooks module feeds it events and carries
3// out the effects it returns; nothing here touches the engine.
4//
5// Times: `wall` is the wall clock. The plan clock counts working time only
6// (while Claude's turn or a subagent runs), so a lunch break never makes a
7// step look slow. A step's time is the span since the previous completion,
8// shared evenly when several finish together.
9
10import type { HeadwayDone, HeadwayOutline, HeadwayPlan, HeadwayPrior, HeadwayRun, HeadwayStep, HeadwayStepStatus } from '../types'
11import { WORDS_MIN, pairKeys, pairTitles, wordOverlap, type Pairing } from './match'
12import { stageList, stageOfTitle } from './outline'
13import { MIN_FINISHED, estimate, formatDuration, formatLeft, isRunningLong, receiptText, timeLeft } from './pace'
14
15export const TICK_MS = 1000
16/** How long the bar stays green before the receipt. */
17export const FLASH_MS = 3000
18/** How long "plan revised" shows. */
19export const REVISE_MS = 4000
20/** Completions this close together (plan-clock ms) share one span. */
21export const COALESCE_MS = 2000
22export const MAX_SESSION_DURATIONS = 200
23export const MAX_AGENT_IDS = 50
24/** The bar is one cell per step up to this many, then scaled. */
25export const BAR_CELLS = 10
26/** Claude's turns with no plan update after which an unfinished plan has been left behind. */
27export const QUIET_TURNS = 2
28
29export const EMPTY_RUN: HeadwayRun = {
30 plan: null,
31 outline: null,
32 clock: { activeMs: 0, since: null },
33 turn: false,
34 agents: [],
35 ended: [],
36 durations: [],
37 hidden: false,
38 seq: 0,
39 quiet: 0,
40}
41
42export type Item = { title: string; status: HeadwayStepStatus; key?: string }
43
44export type HeadwayEvent =
45 | { kind: 'turn'; isRunning: boolean }
46 | { kind: 'agent-start'; id: string }
47 | { kind: 'agent-end'; id: string }
48 | { kind: 'todos'; items: Item[] }
49 | { kind: 'task-create'; id: string; title: string }
50 | { kind: 'task-update'; id: string; isMain: boolean; title?: string; status?: HeadwayStepStatus | 'deleted' }
51 | { kind: 'task-list'; items: Item[] }
52 | { kind: 'outline'; outline: HeadwayOutline }
53 | { kind: 'hidden'; hidden: boolean | 'toggle' }
54 | { kind: 'attach'; planId: number; projectKey: string; prior?: HeadwayPrior }
55 | { kind: 'tick' }
56 | { kind: 'clear' }
57
58export type Effect =
59 | { kind: 'receipt'; text: string }
60 | { kind: 'finished'; done: HeadwayDone; durations: number[]; projectKey?: string }
61 | { kind: 'new-plan'; planId: number }
62
63export function isWorking(run: HeadwayRun): boolean {
64 return run.turn || run.agents.length > 0
65}
66
67/** Working ms on the plan clock at wall time `wall`. */
68export function planNow(clock: HeadwayRun['clock'], wall: number): number {
69 return clock.activeMs + (clock.since === null ? 0 : Math.max(0, wall - clock.since))
70}
71
72function syncClock(clock: HeadwayRun['clock'], working: boolean, wall: number): HeadwayRun['clock'] {
73 if (working && clock.since === null) return { activeMs: clock.activeMs, since: wall }
74 if (!working && clock.since !== null) return { activeMs: clock.activeMs + Math.max(0, wall - clock.since), since: null }
75 return clock
76}
77
78/** Whether the one timer should run: a plan is drawn and either work goes on or a flash is pending. */
79export function wantsTimer(run: HeadwayRun, wall: number): boolean {
80 const plan = run.plan
81 if (!plan || run.hidden || !plan.wasOpen) return false
82 if (plan.finishedAt !== undefined) return true
83 if (plan.revised && wall < plan.revised.until) return true
84 return plan.source !== 'outline' && isWorking(run)
85}
86
87type Ctx = { run: HeadwayRun; effects: Effect[]; wall: number; now: number }
88
89function patch(ctx: Ctx, fields: Partial<HeadwayRun>): void {
90 ctx.run = { ...ctx.run, ...fields }
91}
92
93// Every plan change goes through here, so a finished plan's receipt is
94// toasted exactly once, whatever takes it off the screen.
95function setPlan(ctx: Ctx, plan: HeadwayPlan | null): void {
96 const out = ctx.run.plan
97 if (out?.receipt !== undefined && out.id !== plan?.id) ctx.effects.push({ kind: 'receipt', text: out.receipt })
98 patch(ctx, { plan })
99}
100
101function without<T extends object, K extends keyof T>(value: T, key: K): Omit<T, K> {
102 const { [key]: _dropped, ...rest } = value
103 return rest
104}
105
106/** A plan tool ran in the main loop, so Claude's turn is running (covers a reload mid-turn). */
107function mainLoop(ctx: Ctx): void {
108 if (!ctx.run.turn) patch(ctx, { turn: true })
109}
110
111export function reduce(run: HeadwayRun, event: HeadwayEvent, wall: number): { run: HeadwayRun; effects: Effect[] } {
112 const ctx: Ctx = { run, effects: [], wall, now: planNow(run.clock, wall) }
113 switch (event.kind) {
114 case 'turn':
115 if (run.turn !== event.isRunning) patch(ctx, { turn: event.isRunning })
116 if (event.isRunning) {
117 // One more of Claude's turns since the plan last moved (`|| 0` covers state from before `quiet`).
118 if (canGoStale(run)) patch(ctx, { quiet: (run.quiet || 0) + 1 })
119 } else if (ctx.run.plan?.source === 'outline') {
120 // An approved plan with no todo or task list has nothing left to show once its turn ends.
121 setPlan(ctx, null)
122 } else if (run.quiet >= QUIET_TURNS) {
123 expire(ctx)
124 }
125 break
126 case 'agent-start':
127 agentStart(ctx, event.id)
128 break
129 case 'agent-end':
130 agentEnd(ctx, event.id)
131 break
132 case 'todos':
133 mainLoop(ctx)
134 heard(ctx)
135 applyList(ctx, event.items, 'todo')
136 break
137 case 'task-list':
138 mainLoop(ctx)
139 heard(ctx)
140 applyList(ctx, event.items, 'task')
141 break
142 case 'task-create':
143 mainLoop(ctx)
144 heard(ctx)
145 taskCreate(ctx, event.id, event.title)
146 break
147 case 'task-update':
148 if (event.isMain) mainLoop(ctx)
149 taskUpdate(ctx, event)
150 break
151 case 'outline':
152 mainLoop(ctx)
153 heard(ctx)
154 applyOutline(ctx, event.outline)
155 break
156 case 'hidden': {
157 const hidden = event.hidden === 'toggle' ? !run.hidden : event.hidden
158 if (hidden !== run.hidden) patch(ctx, { hidden })
159 // Hidden mid-flash: skip the flash, the receipt still comes.
160 if (hidden && ctx.run.plan?.finishedAt !== undefined) setPlan(ctx, null)
161 break
162 }
163 case 'attach': {
164 const plan = ctx.run.plan
165 if (plan?.id === event.planId) setPlan(ctx, { ...plan, projectKey: event.projectKey, ...(event.prior ? { prior: event.prior } : {}) })
166 break
167 }
168 case 'tick':
169 tick(ctx)
170 break
171 case 'clear':
172 setPlan(ctx, null)
173 patch(ctx, { ...EMPTY_RUN, hidden: run.hidden, seq: run.seq })
174 break
175 }
176 const clock = syncClock(ctx.run.clock, isWorking(ctx.run), wall)
177 if (clock !== ctx.run.clock) patch(ctx, { clock })
178 return { run: ctx.run, effects: ctx.effects }
179}
180
181/** Claude updated its plan: whatever it shows is live again. */
182function heard(ctx: Ctx): void {
183 if (ctx.run.quiet !== 0) patch(ctx, { quiet: 0 })
184}
185
186/** An unfinished plan, or an approved outline waiting for its list: what a quiet spell can leave behind. */
187function canGoStale(run: HeadwayRun): boolean {
188 const plan = run.plan
189 return (plan !== null && plan.finishedAt === undefined && plan.source !== 'outline') || run.outline !== null
190}
191
192/**
193 * Claude has gone QUIET_TURNS turns without touching its plan (the person
194 * interrupted, changed topic, or the last items were never marked): the plan
195 * leaves quietly, with no receipt and no history, and its outline with it.
196 */
197function expire(ctx: Ctx): void {
198 const plan = ctx.run.plan
199 if (plan && plan.finishedAt === undefined && plan.source !== 'outline') setPlan(ctx, null)
200 patch(ctx, { outline: null, quiet: 0 })
201}
202
203function agentStart(ctx: Ctx, id: string): void {
204 const { agents, ended } = ctx.run
205 if (ended.includes(id)) patch(ctx, { ended: ended.filter(other => other !== id) })
206 else if (!agents.includes(id)) patch(ctx, { agents: [...agents, id].slice(-MAX_AGENT_IDS) })
207}
208
209function agentEnd(ctx: Ctx, id: string): void {
210 const { agents, ended } = ctx.run
211 if (agents.includes(id)) patch(ctx, { agents: agents.filter(other => other !== id) })
212 else if (!ended.includes(id)) patch(ctx, { ended: [...ended, id].slice(-MAX_AGENT_IDS) })
213}
214
215function tick(ctx: Ctx): void {
216 const plan = ctx.run.plan
217 if (!plan) return
218 if (plan.finishedAt !== undefined) {
219 if (ctx.wall >= plan.finishedAt + FLASH_MS) setPlan(ctx, null)
220 return
221 }
222 if (plan.revised && ctx.wall >= plan.revised.until) setPlan(ctx, without(plan, 'revised'))
223}
224
225function itemsOf(plan: HeadwayPlan): Item[] {
226 return plan.steps.map(step => ({ key: step.key, title: step.title, status: step.status }))
227}
228
229function applyList(ctx: Ctx, items: readonly Item[], source: 'todo' | 'task'): void {
230 const plan = ctx.run.plan
231 if (items.length === 0) {
232 // Claude emptied its list: an unfinished plan of that list is over.
233 if (plan && plan.finishedAt === undefined && plan.source === source) setPlan(ctx, null)
234 return
235 }
236 if (plan && plan.finishedAt === undefined && plan.source === source) {
237 const pairing =
238 source === 'task'
239 ? pairKeys(plan.steps.map(step => step.key), items.map(item => item.key ?? ''))
240 : pairTitles(plan.steps.map(step => step.title), items.map(item => item.title))
241 // Nothing in common means a new plan, not a revision.
242 if (pairing.matched > 0) {
243 revise(ctx, plan, items, pairing)
244 return
245 }
246 }
247 startPlan(ctx, items, source)
248}
249
250function startPlan(ctx: Ctx, items: readonly Item[], source: 'todo' | 'task'): void {
251 // A list that follows an approved plan keeps the plan's start and project.
252 const carry = ctx.run.plan?.source === 'outline' ? ctx.run.plan : undefined
253 const id = ctx.run.seq + 1
254 const { stages, stageOf } = stageList(items.map(item => item.title), ctx.run.outline)
255 let nextKey = 1
256 const steps: HeadwayStep[] = items.map((item, index) => ({
257 key: item.key ?? `t${nextKey++}`,
258 title: item.title,
259 stage: stageOf[index] ?? 0,
260 status: item.status,
261 }))
262 const startedAt = carry ? carry.startedAt : ctx.now
263 const plan: HeadwayPlan = {
264 id,
265 source,
266 stages,
267 steps,
268 startedAt,
269 lastMarkAt: startedAt,
270 wasOpen: steps.some(step => step.status !== 'done'),
271 nextKey,
272 ...(carry?.projectKey === undefined ? {} : { projectKey: carry.projectKey }),
273 ...(carry?.prior === undefined ? {} : { prior: carry.prior }),
274 }
275 patch(ctx, { seq: id })
276 setPlan(ctx, plan)
277 if (plan.projectKey === undefined) ctx.effects.push({ kind: 'new-plan', planId: id })
278}
279
280function revise(ctx: Ctx, old: HeadwayPlan, items: readonly Item[], pairing: Pairing): void {
281 const outline = ctx.run.outline
282 const isStaged = outline !== null && old.stages.length > 1
283 let nextKey = old.nextKey
284 let previousStage = 0
285 const wasDone = new Set<string>()
286
287 const steps: HeadwayStep[] = items.map((item, j) => {
288 const before = old.steps[pairing.oldOf[j] ?? -1]
289 if (!before) {
290 const stage = (isStaged && outline ? stageOfTitle(item.title, outline) : null) ?? previousStage
291 previousStage = stage
292 return { key: item.key ?? `t${nextKey++}`, title: item.title, stage, status: item.status }
293 }
294 previousStage = before.stage
295 // A reworded step that was done stays done while it keeps its words ("Wire up the routes");
296 // a sibling that only reads alike ("...for the UI" after "...for the API") and an exact
297 // title (or task id) trust the new status.
298 const keepsDone = before.status === 'done' && !pairing.isExact[j] && wordOverlap(before.title, item.title) >= WORDS_MIN
299 const status = keepsDone ? 'done' : item.status
300 const step: HeadwayStep = { key: before.key, title: item.title, stage: before.stage, status }
301 if (before.status === 'done' && status === 'done') {
302 wasDone.add(before.key)
303 if (before.ms !== undefined) step.ms = before.ms
304 }
305 return step
306 })
307
308 const newlyDone = steps.filter(step => step.status === 'done' && !wasDone.has(step.key))
309 let { lastMarkAt, batch } = old
310 let durations = ctx.run.durations
311 if (newlyDone.length > 0) {
312 // Steps marked done a moment apart (parallel calls) share the span with the batch before them.
313 const joins = batch !== undefined && ctx.now - old.lastMarkAt <= COALESCE_MS
314 const prior = joins && batch ? batch.keys.filter(key => steps.some(step => step.key === key && step.status === 'done')) : []
315 const start = joins && batch ? batch.start : old.lastMarkAt
316 const keys = [...prior, ...newlyDone.map(step => step.key)]
317 const each = Math.max(0, Math.round((ctx.now - start) / keys.length))
318 for (const step of steps) if (keys.includes(step.key)) step.ms = each
319 const kept = joins && batch ? durations.slice(0, Math.max(0, durations.length - batch.keys.length)) : durations
320 durations = [...kept, ...keys.map(() => each)].slice(-MAX_SESSION_DURATIONS)
321 lastMarkAt = ctx.now
322 batch = { start, keys }
323 }
324
325 let plan: HeadwayPlan = {
326 ...without(old, 'batch'),
327 steps,
328 lastMarkAt,
329 nextKey,
330 wasOpen: old.wasOpen || steps.some(step => step.status !== 'done'),
331 ...(batch === undefined ? {} : { batch }),
332 }
333 // Flash the change instead of letting the bar jump without a reason, once work has begun.
334 const hasBegun = old.steps.some(step => step.status !== 'pending')
335 if ((pairing.added > 0 || pairing.removed > 0) && hasBegun) {
336 plan = { ...plan, revised: { added: pairing.added, removed: pairing.removed, until: ctx.wall + REVISE_MS } }
337 }
338 patch(ctx, { durations })
339 setPlan(ctx, withFirstEstimate(plan, durations, ctx.now))
340 finishIfDone(ctx)
341}
342
343/** Records the total the first honest estimate predicts, for the receipt. */
344export function withFirstEstimate(plan: HeadwayPlan, durations: readonly number[], now: number): HeadwayPlan {
345 if (plan.firstEstimateMs !== undefined) return plan
346 const timed = plan.steps.filter(step => step.ms !== undefined).length
347 const remaining = plan.steps.filter(step => step.status !== 'done').length
348 if (timed < MIN_FINISHED || remaining === 0) return plan
349 const guess = estimate(durations, plan.prior)
350 if (!guess || !guess.isSteady) return plan
351 return { ...plan, firstEstimateMs: Math.round(now - plan.startedAt + guess.perStepMs * remaining) }
352}
353
354function finishIfDone(ctx: Ctx): void {
355 const plan = ctx.run.plan
356 if (!plan || plan.finishedAt !== undefined || !plan.wasOpen || plan.steps.length === 0) return
357 if (plan.steps.some(step => step.status !== 'done')) return
358 const totalMs = Math.max(0, Math.round(ctx.now - plan.startedAt))
359 const stageMs = plan.stages.map((_, index) =>
360 plan.steps.filter(step => step.stage === index).reduce((sum, step) => sum + (step.ms ?? 0), 0),
361 )
362 const used = plan.stages
363 .map((name, index) => ({ name: name === '' ? `Stage ${index + 1}` : name, ms: stageMs[index] ?? 0, index }))
364 .filter(stage => plan.steps.some(step => step.stage === stage.index))
365 const receipt = receiptText(totalMs, plan.firstEstimateMs, used, plan.steps.length)
366 ctx.effects.push({
367 kind: 'finished',
368 done: { seq: plan.id, at: ctx.wall, totalMs, steps: plan.steps.length, stageMs, estimateMs: plan.firstEstimateMs ?? null },
369 durations: plan.steps.flatMap(step => (step.ms === undefined ? [] : [step.ms])),
370 ...(plan.projectKey === undefined ? {} : { projectKey: plan.projectKey }),
371 })
372 setPlan(ctx, { ...without(plan, 'revised'), finishedAt: ctx.wall, totalMs, receipt })
373 // The approved plan has been worked through; the next list starts flat.
374 patch(ctx, { outline: null })
375 if (ctx.run.hidden) setPlan(ctx, null)
376}
377
378function taskCreate(ctx: Ctx, id: string, title: string): void {
379 const plan = ctx.run.plan
380 const item: Item = { key: `task:${id}`, title, status: 'pending' }
381 if (!plan || plan.source !== 'task' || plan.finishedAt !== undefined) {
382 startPlan(ctx, [item], 'task')
383 return
384 }
385 if (plan.steps.some(step => step.key === item.key)) return
386 applyList(ctx, [...itemsOf(plan), item], 'task')
387}
388
389function taskUpdate(ctx: Ctx, event: Extract<HeadwayEvent, { kind: 'task-update' }>): void {
390 const plan = ctx.run.plan
391 if (!plan || plan.source !== 'task' || plan.finishedAt !== undefined) return
392 const key = `task:${event.id}`
393 if (!plan.steps.some(step => step.key === key)) return
394 heard(ctx)
395 const items = itemsOf(plan).flatMap(item => {
396 if (item.key !== key) return [item]
397 if (event.status === 'deleted') return []
398 return [{ key, title: event.title ?? item.title, status: event.status ?? item.status }]
399 })
400 if (items.length === 0) setPlan(ctx, null)
401 else applyList(ctx, items, 'task')
402}
403
404function applyOutline(ctx: Ctx, outline: HeadwayOutline): void {
405 patch(ctx, { outline })
406 const plan = ctx.run.plan
407 if (plan && plan.finishedAt === undefined && plan.source !== 'outline') {
408 // A list is already running: give its steps the approved plan's stages.
409 const { stages, stageOf } = stageList(plan.steps.map(step => step.title), outline)
410 setPlan(ctx, { ...plan, stages, steps: plan.steps.map((step, index) => ({ ...step, stage: stageOf[index] ?? 0 })) })
411 return
412 }
413 // No list yet: show the approved plan until a list appears or its turn ends.
414 const id = ctx.run.seq + 1
415 const startedAt = plan?.source === 'outline' ? plan.startedAt : ctx.now
416 const steps: HeadwayStep[] = outline.steps.map((step, index) => ({ key: `o${index + 1}`, title: step.title, stage: step.stage, status: 'pending' }))
417 patch(ctx, { seq: id })
418 setPlan(ctx, { id, source: 'outline', stages: outline.stages, steps, startedAt, lastMarkAt: startedAt, wasOpen: true, nextKey: steps.length + 1 })
419 ctx.effects.push({ kind: 'new-plan', planId: id })
420}
421
422// Views: what the band and the pane draw, as plain data.
423
424export type Tone = 'plain' | 'dim' | 'long' | 'done' | 'note'
425export type Part = { key: string; text: string; tone: Tone }
426
427export type BandView =
428 | { kind: 'summary'; text: string; agents: number }
429 | {
430 kind: 'progress'
431 stage?: string
432 title: string
433 done: number
434 total: number
435 tone: 'normal' | 'long' | 'done'
436 right?: string
437 note?: string
438 agents: number
439 }
440
441function plural(count: number, word: string): string {
442 return `${count} ${word}${count === 1 ? '' : 's'}`
443}
444
445export function revisedText(revised: { added: number; removed: number }): string {
446 const changes = [revised.added > 0 ? `+${revised.added}` : '', revised.removed > 0 ? `−${revised.removed}` : ''].filter(Boolean)
447 return `plan revised ${changes.join(' ')}`
448}
449
450function currentStep(plan: HeadwayPlan): HeadwayStep | undefined {
451 return plan.steps.find(step => step.status === 'active') ?? plan.steps.find(step => step.status !== 'done')
452}
453
454type Pace = { elapsed: number; right?: string; isLong: boolean }
455
456function paceOf(run: HeadwayRun, plan: HeadwayPlan, wall: number): Pace {
457 const elapsed = Math.max(0, planNow(run.clock, wall) - plan.lastMarkAt)
458 const timed = plan.steps.filter(step => step.ms !== undefined).length
459 const guess = timed >= MIN_FINISHED ? estimate(run.durations, plan.prior) : null
460 if (!guess) return { elapsed, isLong: false }
461 if (isRunningLong(elapsed, guess.perStepMs)) return { elapsed, isLong: true, right: `${formatDuration(elapsed)} on this step` }
462 if (!guess.isSteady) return { elapsed, isLong: false, right: 'estimating...' }
463 const remaining = plan.steps.filter(step => step.status !== 'done').length
464 const left = timeLeft(guess.perStepMs, remaining, elapsed)
465 return { elapsed, isLong: false, right: left < 60_000 ? '<1m left' : `~${formatLeft(left)} left` }
466}
467
468/** The band's content, or null when there is nothing to show. */
469export function bandView(run: HeadwayRun, wall: number): BandView | null {
470 const plan = run.plan
471 if (!plan || !plan.wasOpen || plan.steps.length === 0) return null
472 const agents = run.agents.length
473 if (plan.source === 'outline') {
474 const stages = plan.stages.length > 1 ? `${plural(plan.stages.length, 'stage')}, ` : ''
475 return { kind: 'summary', text: `Plan approved · ${stages}${plural(plan.steps.length, 'step')}`, agents }
476 }
477 const total = plan.steps.length
478 const done = plan.steps.filter(step => step.status === 'done').length
479 if (plan.finishedAt !== undefined) {
480 return { kind: 'progress', title: 'Plan done', done: total, total, tone: 'done', right: `in ${formatDuration(plan.totalMs ?? 0)}`, agents }
481 }
482 const current = currentStep(plan)
483 const pace = paceOf(run, plan, wall)
484 const stage = plan.stages.length > 1 && current ? plan.stages[current.stage] : undefined
485 return {
486 kind: 'progress',
487 ...(stage ? { stage } : {}),
488 title: current?.title ?? '',
489 done,
490 total,
491 tone: pace.isLong ? 'long' : 'normal',
492 ...(pace.right === undefined ? {} : { right: pace.right }),
493 ...(plan.revised && wall < plan.revised.until ? { note: revisedText(plan.revised) } : {}),
494 agents,
495 }
496}
497
498// Two cells: East Asian wide and fullwidth characters (Hangul, CJK, kana,
499// fullwidth forms) and emoji shown as emoji. None: combining marks and
500// variation selectors. Everything else, one.
501const WIDE =
502 /[\u1100-\u115f\u2329\u232a\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\ua960-\ua97f\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6\u{16fe0}-\u{18aff}\u{1b000}-\u{1b2ff}\u{1f200}-\u{1f2ff}\u{20000}-\u{3fffd}]|\p{Emoji_Presentation}/u
503const NO_WIDTH = /[\p{Mn}\p{Me}]/u
504
505function cells(char: string): number {
506 return NO_WIDTH.test(char) ? 0 : WIDE.test(char) ? 2 : 1
507}
508
509/** How many terminal cells text takes. */
510export function width(text: string): number {
511 let total = 0
512 for (const char of text) total += cells(char)
513 return total
514}
515
516/** Cuts text to `room` cells, ending with an ellipsis when cut. */
517export function fit(text: string, room: number): string {
518 if (width(text) <= room) return text
519 const limit = room <= 1 ? Math.max(0, room) : room - 1
520 let out = ''
521 let used = 0
522 for (const char of text) {
523 if (used + cells(char) > limit) break
524 out += char
525 used += cells(char)
526 }
527 return room <= 1 ? out : out + '…'
528}
529
530export function barCells(done: number, total: number): { on: number; off: number } {
531 const cells = Math.min(Math.max(total, 1), BAR_CELLS)
532 const on = total <= BAR_CELLS ? done : Math.round((done / total) * BAR_CELLS)
533 return { on: Math.min(cells, Math.max(0, on)), off: cells - Math.min(cells, Math.max(0, on)) }
534}
535
536const MIN_TITLE = 12
537/** In a very narrow band, time left and then the count give way before the title drops under this. */
538const MIN_TITLE_NARROW = 6
539
540/**
541 * The band as one line of parts within `columns` terminal cells. When room is
542 * short it drops, in order, the stage, the extras (revision note, agents
543 * chip) and the bar, and cuts the title; only in a very narrow band do the
544 * time left and then the count give way too.
545 */
546export function layoutBand(view: BandView, columns: number): Part[] {
547 const chip = view.agents > 0 ? plural(view.agents, 'agent') : ''
548 if (view.kind === 'summary') {
549 const tail = chip === '' || columns - width(` · ${chip}`) < MIN_TITLE ? '' : ` · ${chip}`
550 const parts: Part[] = [{ key: 'summary', text: fit(view.text, Math.max(1, columns - width(tail))), tone: 'dim' }]
551 if (tail !== '') parts.push({ key: 'agents', text: tail, tone: 'dim' })
552 return parts
553 }
554 const accent: Tone = view.tone === 'done' ? 'done' : view.tone === 'long' ? 'long' : 'plain'
555 const { on, off } = barCells(view.done, view.total)
556 let count = ` (${view.done}/${view.total})`
557 let right = view.right === undefined ? '' : ` ${view.right}`
558 const note = view.note === undefined ? '' : ` · ${view.note}`
559 const agents = chip === '' ? '' : ` · ${chip}`
560 let stage = view.stage === undefined ? '' : `${view.stage} · `
561 let extras = note + agents
562 let bar = ` ${'▰'.repeat(on)}${'▱'.repeat(off)}`
563 const room = () => columns - width(stage) - width(count) - width(bar) - width(right) - width(extras)
564 if (room() < MIN_TITLE) stage = ''
565 if (room() < MIN_TITLE) extras = ''
566 if (room() < MIN_TITLE) bar = ''
567 if (room() < MIN_TITLE_NARROW) right = ''
568 if (room() < MIN_TITLE_NARROW) count = ''
569 const title = fit(view.title, Math.max(1, room()))
570 const parts: Part[] = []
571 if (stage !== '') parts.push({ key: 'stage', text: stage, tone: 'dim' })
572 parts.push({ key: 'title', text: title, tone: accent })
573 if (count !== '') parts.push({ key: 'count', text: count, tone: 'dim' })
574 if (bar !== '') {
575 parts.push({ key: 'bar-done', text: ` ${'▰'.repeat(on)}`, tone: accent })
576 if (off > 0) parts.push({ key: 'bar-left', text: '▱'.repeat(off), tone: 'dim' })
577 }
578 if (right !== '') parts.push({ key: 'right', text: right, tone: accent === 'plain' ? 'dim' : accent })
579 if (extras !== '') {
580 if (note !== '') parts.push({ key: 'note', text: note, tone: 'note' })
581 if (agents !== '') parts.push({ key: 'agents', text: agents, tone: 'dim' })
582 }
583 return parts
584}
585
586export type Row = { key: string; text: string; tone: Tone }
587
588/** The pane's rows: every step with its status and time, by stage. */
589export function paneRows(run: HeadwayRun, last: HeadwayDone | null, wall: number): Row[] {
590 const plan = run.plan
591 if (!plan || !plan.wasOpen || plan.steps.length === 0) {
592 const rows: Row[] = [
593 { key: 'empty', text: 'No plan right now. Headway follows Claude’s todo or task list and plans approved in plan mode.', tone: 'dim' },
594 ]
595 if (last) rows.push({ key: 'last', text: `Last plan: ${plural(last.steps, 'step')}, done in ${formatDuration(last.totalMs)}.`, tone: 'dim' })
596 return rows
597 }
598 const rows: Row[] = []
599 const done = plan.steps.filter(step => step.status === 'done').length
600 // An approved plan with no list has no step under way yet.
601 const current = plan.finishedAt === undefined && plan.source !== 'outline' ? currentStep(plan) : undefined
602 const pace = paceOf(run, plan, wall)
603 if (plan.source === 'outline') {
604 rows.push({ key: 'summary', text: 'Plan approved. Progress shows once Claude starts a todo or task list.', tone: 'dim' })
605 } else if (plan.finishedAt !== undefined) {
606 rows.push({ key: 'summary', text: plan.receipt ?? `Plan done in ${formatDuration(plan.totalMs ?? 0)}.`, tone: 'done' })
607 } else {
608 const so = formatDuration(Math.max(0, planNow(run.clock, wall) - plan.startedAt))
609 rows.push({ key: 'summary', text: `${done}/${plan.steps.length} steps done · ${so} so far${pace.right ? ` · ${pace.right}` : ''}`, tone: pace.isLong ? 'long' : 'plain' })
610 }
611 plan.stages.forEach((name, index) => {
612 const steps = plan.steps.filter(step => step.stage === index)
613 if (steps.length === 0) return
614 if (plan.stages.length > 1) rows.push({ key: `stage-${index}`, text: name === '' ? `Stage ${index + 1}` : name, tone: 'plain' })
615 const indent = plan.stages.length > 1 ? ' ' : ''
616 for (const step of steps) {
617 const isCurrent = step === current
618 const mark = step.status === 'done' ? '[x]' : isCurrent ? '[>]' : '[ ]'
619 const time =
620 step.status === 'done'
621 ? step.ms === undefined
622 ? ' · done before Headway saw it'
623 : ` · ${formatDuration(step.ms)}`
624 : isCurrent
625 ? ` · running ${formatDuration(pace.elapsed)}`
626 : ''
627 const tone: Tone = step.status === 'done' ? 'dim' : isCurrent && pace.isLong ? 'long' : 'plain'
628 rows.push({ key: `step-${step.key}`, text: `${indent}${mark} ${step.title}${time}`, tone })
629 }
630 })
631 return rows
632}
633hooks/sources.ts 106 lines1// Reads Claude's own plan tools into Headway events. Inputs and results are
2// parsed defensively: a tool's schema can change between builds, and a
3// shape Headway does not recognise is ignored, never an error.
4
5import type { HeadwayStepStatus } from '../types'
6import type { HeadwayEvent, Item } from './model'
7import { cleanText, parseOutline } from './outline'
8
9function record(value: unknown): Record<string, unknown> | null {
10 return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : null
11}
12
13/** A title from a tool, made safe to draw (cleanText, as the plan-mode outline uses too), or null. */
14function text(value: unknown): string | null {
15 if (typeof value !== 'string') return null
16 const clean = cleanText(value)
17 return clean === '' ? null : clean
18}
19
20function id(value: unknown): string | null {
21 if (typeof value === 'string' && value.trim() !== '') return value.trim()
22 if (typeof value === 'number' && Number.isFinite(value)) return String(value)
23 return null
24}
25
26export function statusOf(value: unknown): HeadwayStepStatus {
27 if (value === 'completed') return 'done'
28 if (value === 'in_progress') return 'active'
29 return 'pending'
30}
31
32function todoItems(list: unknown): Item[] | null {
33 if (!Array.isArray(list)) return null
34 return list.flatMap(entry => {
35 const todo = record(entry)
36 const title = text(todo?.content)
37 return title === null ? [] : [{ title, status: statusOf(todo?.status) }]
38 })
39}
40
41function taskItems(list: unknown): Item[] | null {
42 if (!Array.isArray(list)) return null
43 const seen = new Set<string>()
44 return list.flatMap(entry => {
45 const task = record(entry)
46 const key = id(task?.id)
47 const title = text(task?.subject)
48 if (key === null || title === null || seen.has(key)) return []
49 seen.add(key)
50 return [{ key: `task:${key}`, title, status: statusOf(task?.status) }]
51 })
52}
53
54/** A plan call's input as a count, for its transcript row: how many steps and how many are done, or null. */
55export function planCount(input: unknown): { steps: number; done: number } | null {
56 const items = todoItems(record(input)?.todos)
57 if (items === null || items.length === 0) return null
58 return { steps: items.length, done: items.filter(item => item.status === 'done').length }
59}
60
61/**
62 * The event one finished, successful call of a watched tool means, or null.
63 * TodoWrite, TaskCreate, TaskList and ExitPlanMode count only from the main
64 * loop (a subagent keeps lists of its own); TaskUpdate counts from any loop,
65 * since tasks are shared and Headway only applies it to a task it knows.
66 */
67export function eventFromTool(tool: string, input: unknown, result: unknown, isMain: boolean): HeadwayEvent | null {
68 const args = record(input)
69 const out = record(result)
70 switch (tool) {
71 case 'TodoWrite': {
72 if (!isMain) return null
73 const items = todoItems(args?.todos) ?? todoItems(out?.newTodos)
74 return items === null ? null : { kind: 'todos', items }
75 }
76 case 'TaskCreate': {
77 if (!isMain) return null
78 const task = record(out?.task)
79 const key = id(task?.id)
80 const title = text(args?.subject) ?? text(task?.subject)
81 return key === null || title === null ? null : { kind: 'task-create', id: key, title }
82 }
83 case 'TaskUpdate': {
84 if (out?.success === false) return null
85 const key = id(args?.taskId)
86 const title = text(args?.subject)
87 const raw = args?.status
88 const status = raw === 'deleted' ? 'deleted' : raw === undefined ? undefined : statusOf(raw)
89 if (key === null || (title === null && status === undefined)) return null
90 return { kind: 'task-update', id: key, isMain, ...(title === null ? {} : { title }), ...(status === undefined ? {} : { status }) }
91 }
92 case 'TaskList': {
93 if (!isMain) return null
94 const items = taskItems(out?.tasks)
95 return items === null ? null : { kind: 'task-list', items }
96 }
97 case 'ExitPlanMode': {
98 if (!isMain || out?.awaitingLeaderApproval === true || typeof out?.plan !== 'string') return null
99 const outline = parseOutline(out.plan)
100 return outline === null ? null : { kind: 'outline', outline }
101 }
102 default:
103 return null
104 }
105}
106hooks/pace.ts 105 lines1// Pace math: the per-step estimate, time left, the "running long" rule and
2// the duration words. Pure functions, no engine calls.
3
4import type { HeadwayPrior } from '../types'
5
6/** Time left shows only once this many steps of the plan have finished. */
7export const MIN_FINISHED = 2
8/**
9 * Step times vary too much to be honest when their coefficient of variation
10 * (sample standard deviation / mean) is above this. At 1.0, two steps hide the
11 * estimate when one took roughly six times the other (3 + 2·√2, about 5.8).
12 */
13export const SPREAD_MAX = 1.0
14/**
15 * Steadiness reads only this many of the latest step times, so one long step
16 * (a full test run among quick edits) stops hiding time left once it ages out.
17 * The per-step median still uses the whole session.
18 */
19export const STEADY_WINDOW = 8
20/** Project history weighs at most as much as this many steps of this session. */
21export const PRIOR_WEIGHT_MAX = 3
22/** A step running past this many times its estimate turns amber. */
23export const LONG_FACTOR = 3
24
25export function median(values: readonly number[]): number {
26 if (values.length === 0) return 0
27 const sorted = [...values].sort((a, b) => a - b)
28 const mid = Math.floor(sorted.length / 2)
29 return sorted.length % 2 === 1 ? (sorted[mid] ?? 0) : ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2
30}
31
32/** Sample coefficient of variation; 0 for fewer than two values or a zero mean. */
33export function spread(values: readonly number[]): number {
34 if (values.length < 2) return 0
35 const mean = values.reduce((sum, v) => sum + v, 0) / values.length
36 if (mean <= 0) return 0
37 const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / (values.length - 1)
38 return Math.sqrt(variance) / mean
39}
40
41export type Estimate = {
42 /** The expected time of one step, ms. */
43 perStepMs: number
44 /** False when step times vary too much to show a time left. */
45 isSteady: boolean
46}
47
48/**
49 * The per-step estimate: the median of this session's step times, blended
50 * with the project's history (which counts as up to PRIOR_WEIGHT_MAX steps),
51 * steady when the last STEADY_WINDOW times agree well enough.
52 * Null with fewer than MIN_FINISHED session times.
53 */
54export function estimate(session: readonly number[], prior?: HeadwayPrior): Estimate | null {
55 if (session.length < MIN_FINISHED) return null
56 const own = median(session)
57 const weight = prior && prior.medianMs > 0 ? Math.min(prior.count, PRIOR_WEIGHT_MAX) : 0
58 const perStepMs = weight > 0 && prior ? (session.length * own + weight * prior.medianMs) / (session.length + weight) : own
59 return { perStepMs, isSteady: spread(session.slice(-STEADY_WINDOW)) <= SPREAD_MAX }
60}
61
62/** Time left: the rest of the current step (none once past its estimate) plus every step after it. */
63export function timeLeft(perStepMs: number, remaining: number, currentElapsedMs: number): number {
64 if (remaining <= 0) return 0
65 return Math.max(0, perStepMs - currentElapsedMs) + perStepMs * (remaining - 1)
66}
67
68export function isRunningLong(currentElapsedMs: number, perStepMs: number): boolean {
69 return perStepMs > 0 && currentElapsedMs > LONG_FACTOR * perStepMs
70}
71
72/** "45s", "4m 12s", "4m", "1h 5m": a duration to the second (minute past an hour). */
73export function formatDuration(ms: number): string {
74 const seconds = Math.max(0, Math.round(ms / 1000))
75 if (seconds < 60) return `${seconds}s`
76 const minutes = Math.floor(seconds / 60)
77 if (minutes < 60) return seconds % 60 === 0 ? `${minutes}m` : `${minutes}m ${seconds % 60}s`
78 const hours = Math.floor(minutes / 60)
79 return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`
80}
81
82/** "<1m", "3m", "1h 20m": a time left, rounded up to the minute. */
83export function formatLeft(ms: number): string {
84 if (ms < 60_000) return '<1m'
85 const minutes = Math.ceil(ms / 60_000)
86 if (minutes < 60) return `${minutes}m`
87 return minutes % 60 === 0 ? `${minutes / 60}h` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
88}
89
90/** How the actual total compares with the first estimate, or '' without one. */
91export function accuracy(totalMs: number, estimateMs: number | undefined): string {
92 if (estimateMs === undefined || estimateMs <= 0) return ''
93 const delta = totalMs - estimateMs
94 if (Math.abs(delta) <= Math.max(5_000, estimateMs * 0.05)) return 'right on estimate'
95 return delta < 0 ? `${formatDuration(-delta)} ahead of estimate` : `${formatDuration(delta)} behind estimate`
96}
97
98/** The receipt: total time, how the first estimate did, and time per stage. */
99export function receiptText(totalMs: number, estimateMs: number | undefined, stages: readonly { name: string; ms: number }[], steps: number): string {
100 const how = accuracy(totalMs, estimateMs)
101 const head = `Plan done in ${formatDuration(totalMs)}${how ? `, ${how}` : ''}.`
102 if (stages.length < 2) return `${head} ${steps} ${steps === 1 ? 'step' : 'steps'}.`
103 return `${head} ${stages.map(stage => `${stage.name} ${formatDuration(stage.ms)}`).join(' · ')}`
104}
105hooks/match.ts 132 lines1// Fuzzy step matching: tells a renamed step from a new one when Claude
2// rewrites its plan. Pure functions, no engine calls.
3
4/** Letter-pair (Dice) overlap at or above this counts as the same step. */
5export const LETTERS_MIN = 0.75
6/** Word (Jaccard) overlap at or above this counts as the same step. */
7export const WORDS_MIN = 0.6
8
9const STOPWORDS = new Set(['a', 'an', 'the', 'to', 'for', 'of', 'and', 'in', 'on', 'with', 'into', 'from', 'by', 'at', 'it', 'its', 'up'])
10
11/** Lower case, punctuation to spaces, one space between words. */
12export function normalize(title: string): string {
13 return title
14 .toLowerCase()
15 .replace(/[^\p{L}\p{N}\s]+/gu, ' ')
16 .replace(/\s+/g, ' ')
17 .trim()
18}
19
20/** The words that carry meaning: no stopwords, a plural `s` dropped. */
21export function words(title: string): string[] {
22 return normalize(title)
23 .split(' ')
24 .filter(word => word !== '' && !STOPWORDS.has(word))
25 .map(word => (word.length > 3 && word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word))
26}
27
28function pairs(text: string): Map<string, number> {
29 const counts = new Map<string, number>()
30 for (let i = 0; i < text.length - 1; i += 1) {
31 const pair = text.slice(i, i + 2)
32 counts.set(pair, (counts.get(pair) ?? 0) + 1)
33 }
34 return counts
35}
36
37/** Dice coefficient over letter pairs of the normalized titles, 0 to 1. */
38export function letterOverlap(a: string, b: string): number {
39 const left = normalize(a)
40 const right = normalize(b)
41 if (left === right) return left === '' ? 0 : 1
42 if (left.length < 2 || right.length < 2) return 0
43 const leftPairs = pairs(left)
44 const rightPairs = pairs(right)
45 let shared = 0
46 for (const [pair, count] of leftPairs) shared += Math.min(count, rightPairs.get(pair) ?? 0)
47 return (2 * shared) / (left.length - 1 + (right.length - 1))
48}
49
50/** Jaccard overlap of the meaningful words, 0 to 1. */
51export function wordOverlap(a: string, b: string): number {
52 const left = new Set(words(a))
53 const right = new Set(words(b))
54 if (left.size === 0 || right.size === 0) return 0
55 let shared = 0
56 for (const word of left) if (right.has(word)) shared += 1
57 return shared / (left.size + right.size - shared)
58}
59
60/** How alike two titles are; 1 or more means "the same step". */
61export function sameness(a: string, b: string): number {
62 return Math.max(letterOverlap(a, b) / LETTERS_MIN, wordOverlap(a, b) / WORDS_MIN)
63}
64
65export function isSameStep(a: string, b: string): boolean {
66 return sameness(a, b) >= 1
67}
68
69export type Pairing = {
70 /** Each new index's old index, or -1 for a step that is new. */
71 oldOf: number[]
72 /** Whether the pair matched on the exact (normalized) title. */
73 isExact: boolean[]
74 added: number
75 removed: number
76 matched: number
77}
78
79/**
80 * Pairs a plan's old step titles with its new ones, one to one: exact
81 * (normalized) titles first, in order, then the most alike pairs above the
82 * threshold. What is left is added (new side) or removed (old side).
83 */
84export function pairTitles(oldTitles: readonly string[], newTitles: readonly string[]): Pairing {
85 const oldOf = newTitles.map(() => -1)
86 const isExact = newTitles.map(() => false)
87 const taken = oldTitles.map(() => false)
88
89 const oldNorm = oldTitles.map(normalize)
90 newTitles.forEach((title, j) => {
91 const norm = normalize(title)
92 const i = oldNorm.findIndex((candidate, k) => !taken[k] && candidate === norm && norm !== '')
93 if (i >= 0) {
94 taken[i] = true
95 oldOf[j] = i
96 isExact[j] = true
97 }
98 })
99
100 const candidates: { i: number; j: number; score: number }[] = []
101 newTitles.forEach((title, j) => {
102 if (oldOf[j] !== -1) return
103 oldTitles.forEach((old, i) => {
104 if (taken[i]) return
105 const score = sameness(old, title)
106 if (score >= 1) candidates.push({ i, j, score })
107 })
108 })
109 candidates.sort((x, y) => y.score - x.score || x.j - y.j || x.i - y.i)
110 for (const { i, j } of candidates) {
111 if (taken[i] || oldOf[j] !== -1) continue
112 taken[i] = true
113 oldOf[j] = i
114 }
115
116 const matched = oldOf.filter(i => i !== -1).length
117 return { oldOf, isExact, added: newTitles.length - matched, removed: oldTitles.length - matched, matched }
118}
119
120/** Pairs by exact identity (task ids): no fuzziness needed. */
121export function pairKeys(oldKeys: readonly string[], newKeys: readonly string[]): Pairing {
122 const oldOf = newKeys.map(key => oldKeys.indexOf(key))
123 const matched = oldOf.filter(i => i !== -1).length
124 return {
125 oldOf,
126 isExact: newKeys.map(() => true),
127 added: newKeys.length - matched,
128 removed: oldKeys.length - matched,
129 matched,
130 }
131}
132hooks/outline.ts 158 lines1// Turns an approved plan-mode plan (markdown) into stages and steps:
2// headings become stages, top-level list items become steps.
3
4import type { HeadwayOutline } from '../types'
5import { clean } from './clean'
6import { isSameStep, words } from './match'
7
8export const MAX_STAGES = 20
9export const MAX_STEPS = 100
10export const MAX_TITLE = 120
11
12const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
13const ITEM = /^( *)(?:[-*+]|\d{1,3}[.)])\s+(?:\[[ xX]\]\s+)?(.*\S)\s*$/
14const FENCE = /^\s*(```|~~~)/
15
16// Sections that describe the plan rather than steps of it.
17const NOT_WORK =
18 /^(context|background|overview|summary|goals?|non[- ]?goals?|notes?|risks?|open questions|questions|assumptions|references|out of scope|alternatives( considered)?|(critical |key |relevant )?files( to (change|modify|touch|edit))?|dependencies)\b/i
19
20/**
21 * Every title and stage name Headway draws passes through here, from every
22 * source: controls and escapes go first (clean), then markdown emphasis,
23 * code ticks and link targets; the length is capped.
24 */
25export function cleanText(text: string): string {
26 const plain = clean(text)
27 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
28 .replace(/[*_`~]+/g, '')
29 .replace(/\s+/g, ' ')
30 .trim()
31 return [...plain].length > MAX_TITLE ? [...plain].slice(0, MAX_TITLE - 1).join('') + '…' : plain
32}
33
34/** A heading as a stage name: "Phase 1: Build the API" reads "Build the API". */
35export function stageName(heading: string): string {
36 const text = cleanText(heading)
37 const bare = text
38 .replace(/^(phase|stage|step|part|milestone)\s+[\w.]+\s*[:.)-]\s*/i, '')
39 .replace(/^\d+[.):]\s*/, '')
40 .replace(/[:.]$/, '')
41 .trim()
42 return bare === '' ? text : bare
43}
44
45/** A list item as a step title: a bold lead-in wins, else the whole item. */
46export function stepTitle(item: string): string {
47 const lead = /^\*\*(.+?)\*\*/.exec(item) ?? /^__(.+?)__/.exec(item)
48 const text = cleanText(lead?.[1] ?? item)
49 return text.replace(/[:.]$/, '').trim()
50}
51
52/**
53 * Reads the plan's outline. The stage level is the shallowest heading level
54 * used at least twice; sections such as Context or Risks are left out, and
55 * only top-level items count (sub-bullets are detail). Fewer than two stages
56 * with steps reads as one flat stage. Null when the plan lists no steps.
57 */
58export function parseOutline(markdown: string): HeadwayOutline | null {
59 const lines = markdown.split(/\r?\n/)
60 const isCode: boolean[] = []
61 let inFence = false
62 for (const line of lines) {
63 const isFence = FENCE.test(line)
64 isCode.push(inFence || isFence)
65 if (isFence) inFence = !inFence
66 }
67
68 const levels = new Map<number, number>()
69 lines.forEach((line, n) => {
70 const heading = isCode[n] ? null : HEADING.exec(line)
71 if (heading?.[1]) levels.set(heading[1].length, (levels.get(heading[1].length) ?? 0) + 1)
72 })
73 const stageLevel = [...levels.entries()]
74 .filter(([, count]) => count >= 2)
75 .map(([level]) => level)
76 .sort((a, b) => a - b)[0]
77
78 const stages: { name: string; steps: string[] }[] = []
79 const loose: string[] = []
80 let current: { name: string; steps: string[] } | null = null
81 // A non-work section hides its items: at the stage level, or as a sub-heading inside a stage.
82 let isStageSkipped = false
83 let isSubSkipped = false
84
85 lines.forEach((line, n) => {
86 if (isCode[n]) return
87 const heading = HEADING.exec(line)
88 if (heading?.[1] && heading[2] !== undefined) {
89 const level = heading[1].length
90 const skip = NOT_WORK.test(cleanText(heading[2]))
91 if (stageLevel !== undefined && level === stageLevel) {
92 current = skip ? null : { name: stageName(heading[2]), steps: [] }
93 if (current) stages.push(current)
94 isStageSkipped = skip
95 isSubSkipped = false
96 } else if (stageLevel !== undefined && level < stageLevel) {
97 // A heading above the stage level (the plan's title) ends any stage.
98 current = null
99 isStageSkipped = skip
100 isSubSkipped = false
101 } else {
102 isSubSkipped = skip
103 }
104 return
105 }
106 const item = ITEM.exec(line)
107 if (!item || item[2] === undefined || (item[1] ?? '').length > 1 || isStageSkipped || isSubSkipped) return
108 const title = stepTitle(item[2])
109 if (title === '') return
110 if (current) current.steps.push(title)
111 else loose.push(title)
112 })
113
114 const staged = stages.filter(stage => stage.steps.length > 0).slice(0, MAX_STAGES)
115 if (staged.length >= 2) {
116 const steps = staged.flatMap((stage, index) => stage.steps.map(title => ({ title, stage: index })))
117 return { stages: staged.map(stage => stage.name), steps: steps.slice(0, MAX_STEPS) }
118 }
119 const flat = [...staged.flatMap(stage => stage.steps), ...loose].slice(0, MAX_STEPS)
120 return flat.length === 0 ? null : { stages: [''], steps: flat.map(title => ({ title, stage: 0 })) }
121}
122
123/**
124 * Which outline stage a tracked step belongs to: the stage of the outline
125 * step it matches, else a stage whose name's words all appear in it; null
126 * when neither.
127 */
128export function stageOfTitle(title: string, outline: HeadwayOutline): number | null {
129 const step = outline.steps.find(candidate => isSameStep(candidate.title, title))
130 if (step) return step.stage
131 const titleWords = new Set(words(title))
132 const index = outline.stages.findIndex(name => {
133 const nameWords = words(name)
134 return nameWords.length > 0 && nameWords.every(word => titleWords.has(word))
135 })
136 return index >= 0 ? index : null
137}
138
139/**
140 * Stages for a tracked list: an outline's stages when at least one step
141 * matches them (an unmatched step takes the stage of the step before it),
142 * else one flat stage.
143 */
144export function stageList(titles: readonly string[], outline: HeadwayOutline | null): { stages: string[]; stageOf: number[] } {
145 const flat = { stages: [''], stageOf: titles.map(() => 0) }
146 if (!outline || outline.stages.length < 2) return flat
147 let hits = 0
148 let previous = 0
149 const stageOf = titles.map(title => {
150 const stage = stageOfTitle(title, outline)
151 if (stage === null) return previous
152 hits += 1
153 previous = stage
154 return stage
155 })
156 return hits === 0 ? flat : { stages: outline.stages, stageOf }
157}
158hooks/clean.ts 25 lines1// The one cleaner for text Headway draws that came from outside it: step
2// titles and stage names out of Claude's todo, task and plan-mode tools.
3// The engine refuses a drawn tree whose text holds a control character and
4// draws its own instead, and since Headway wraps the band mods beneath it,
5// one stray escape in a title would take their lines (and buttons) down too.
6
7// ANSI escape sequences: CSI (ESC [ or its one-byte form U+009B) with its
8// parameters, a terminated string (OSC, DCS, SOS, PM, APC; a hyperlink keeps
9// its text), then any other ESC with the bytes that belong to it (a space
10// after a stray ESC is kept as text).
11const ANSI =
12 /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]|(?:\u001b[\]PX^_]|[\u0090\u0098\u009d-\u009f])[^\u0007\u001b\u009c]*(?:\u0007|\u001b\\|\u009c)|\u001b[!-/]*[0-~]?/g
13// Line breaks and tabs read as a space (titles are one line).
14const BREAKS = /[\t\n\v\f\r\u0085\u2028\u2029]/g
15// Every other C0 control, DEL and every C1 control.
16const CONTROLS = /[\u0000-\u001f\u007f-\u009f]/g
17// Invisible marks that can reorder or hide text: bidi embeddings, overrides
18// and isolates, zero-width characters, word joiners, the BOM, soft hyphens.
19const INVISIBLE = /[\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g
20
21/** Text safe to draw: escape sequences gone, breaks as spaces, controls and invisible marks removed. */
22export function clean(text: string): string {
23 return text.replace(ANSI, '').replace(BREAKS, ' ').replace(CONTROLS, '').replace(INVISIBLE, '')
24}
25types/index.d.ts 99 lines1// Headway's state contract. `done` is the public "plan done" signal other
2// mods may read or react to; `run` is Headway's own working state and may
3// change shape between versions.
4
5/** Where the plan came from: a TodoWrite list, the Task tools, or an approved plan-mode plan alone. */
6export type HeadwaySource = 'todo' | 'task' | 'outline'
7
8export type HeadwayStepStatus = 'pending' | 'active' | 'done'
9
10export type HeadwayStep = {
11 /** Identity across rewrites: `task:<id>` for a task, a minted `t<n>` for a todo item. */
12 key: string
13 title: string
14 /** Index into the plan's `stages`. */
15 stage: number
16 status: HeadwayStepStatus
17 /** Working time the step took, in ms; absent when it was already done when Headway first saw it. */
18 ms?: number
19}
20
21/** This project's pace from earlier sessions: the median step time and how many steps it rests on. */
22export type HeadwayPrior = { medianMs: number; count: number }
23
24export type HeadwayPlan = {
25 id: number
26 source: HeadwaySource
27 /** Stage names; a single '' for a flat list. */
28 stages: string[]
29 steps: HeadwayStep[]
30 /** Plan-clock ms (working time only) when the plan started. */
31 startedAt: number
32 /** Plan-clock ms of the last step completion, or the start. */
33 lastMarkAt: number
34 /** The last completions that share one span (several steps marked done together). */
35 batch?: { start: number; keys: string[] }
36 /** True once the plan was seen with a step still to do. */
37 wasOpen: boolean
38 nextKey: number
39 projectKey?: string
40 prior?: HeadwayPrior
41 /** The total the first honest estimate predicted, in ms. */
42 firstEstimateMs?: number
43 /** A replan's flash: steps added and removed, shown until `until` (wall ms). */
44 revised?: { added: number; removed: number; until: number }
45 /** Wall ms when the last step finished; the green flash runs from here. */
46 finishedAt?: number
47 totalMs?: number
48 receipt?: string
49}
50
51/** An approved plan-mode plan, reduced to stage names and step titles. */
52export type HeadwayOutline = { stages: string[]; steps: { title: string; stage: number }[] }
53
54export type HeadwayRun = {
55 plan: HeadwayPlan | null
56 outline: HeadwayOutline | null
57 /** The plan clock: working ms banked, and the wall ms the current working span began (null while idle). */
58 clock: { activeMs: number; since: number | null }
59 /** True while the main loop's turn runs. */
60 turn: boolean
61 /** Ids of the subagents running now. */
62 agents: string[]
63 /** Ids that ended before their start was seen (kept briefly so the count never goes wrong). */
64 ended: string[]
65 /** This session's finished step times, in ms. */
66 durations: number[]
67 hidden: boolean
68 /** The last plan id handed out. */
69 seq: number
70 /** Claude's turns begun since it last updated its plan; at QUIET_TURNS an unfinished plan leaves. */
71 quiet: number
72}
73
74/**
75 * Written once each time a plan finishes. Counts and durations only, never titles.
76 * Read it with `$.state.get({ plugin: 'headway', key: 'done' })` (a render hook
77 * that reads it redraws when it changes), or hook `state.set` with the matcher
78 * `{ plugin: 'headway', key: 'done' }` to react the moment it is written.
79 */
80export type HeadwayDone = {
81 /** Which plan of this session finished; grows by one or more per plan. */
82 seq: number
83 /** Wall-clock ms when it finished. */
84 at: number
85 /** Working time from the plan's start to its last step, in ms. */
86 totalMs: number
87 steps: number
88 /** Working ms per stage, in stage order (one entry for a flat list). */
89 stageMs: number[]
90 /** The total the first estimate predicted, or null when none was shown. */
91 estimateMs: number | null
92}
93
94declare module 'claude-code' {
95 interface PluginState {
96 headway: { run: HeadwayRun; done: HeadwayDone | null }
97 }
98}
99