SLOPSHOPPER

headway

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

newpanebandrowsguardcommand
★ 1v0.1.2MITupdated 2026-10-14modmyclaude/claude-code-mods/mods/headway
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · headway
│ ┃ Headway ✕ › fix the failing auth test and add an audit log call │ ┃ No plan right now. Headway follows Claude’s… │ ┃ ⏺ Read(src/auth.ts) │ ┃ [ Hide line ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /headway │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Headway
No plan right now. Headway follows Claude’s todo or task li… [ Hide line ]
README

<img src="../../brand/pixel-icons/svg/headway.svg" width="64" height="64" alt="">

Headway

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

Build · Wire the routes (2/6) ▰▰▱▱▱▱ ~3m left

Install

/plugin install headway --marketplace modmyclaude/claude-code-mods

Use it

Nothing to set up. Headway reads the plan Claude already keeps and draws one line above your prompt while that plan is in progress:

  • Where the plan comes from: Claude's own todo list (TodoWrite), its task tools (TaskCreate, TaskUpdate, TaskList) and the plan you approve in plan mode (ExitPlanMode). Plan-mode headings become stages; a flat todo list is one stage. A todo list that follows an approved plan is placed into the plan's stages by matching step titles.
  • The line: stage, current step, steps done out of total, a bar, and time left.
  • Time left appears once two steps have finished. It is the median time per step this session, blended with this project's pace from earlier sessions, times the steps left. When step times vary too much to be honest it says estimating... instead.
  • Running long: a step past three times its estimate turns amber and says how long it has run (7m 12s on this step).
  • Plans that change: when Claude rewrites its list, reworded steps that were done stay done, and the line flashes plan revised +2 −1 for a few seconds.
  • Done: the bar turns green for a few seconds, then a toast gives the receipt (Plan done in 4m 12s, 18s ahead of estimate. Build 2m 1s · Verify 2m 11s) and the line leaves.
  • Subagents: while any run, a small 3 agents chip sits at the end of the line. No agent details.
  • Other mods: Headway adds its line beneath what other mods draw above the prompt and passes theirs through, so when the band runs out of rows it is Headway's line that gets cut, never another mod's buttons. A mod that takes the whole band hides Headway's line until that mod is done. Headway steps aside while a survey holds that space.
CommandWhat it does
/headwayOpens 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 hideHides the line for this session.
/headway showShows it again.

For other mods: the "plan done" moment

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:

  • read it while drawing, await $.state.get({ plugin: 'headway', key: 'done' }), and your render hook redraws whenever a plan finishes, or
  • react the moment it is written:
on('state.set', { plugin: 'headway', key: 'done' }, async ($, e, next) => {
  if (e.value) $.ui.toast(`${e.value.steps} steps done`)
  return next(e)
})

Settings

  • On: on by default. Off, Headway draws nothing, lends no plan tool and carries out nothing (no receipt, no "plan done", no pace history); it still follows the plan in memory, so switched on again it shows where things stand. /headway says it is off. /config or the modmyclaude hub flips it, live.

What it can touch

  • Files: none directly. Per-project pace history goes in the mod's own store ($.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.
  • Processes, network, model calls: none. No tokens where Claude Code has its own plan tools: it adds nothing to the prompt or the system prompt. Where Headway lends its plan tool, that tool's short description sits in Claude's tool list, and Claude's calls to it cost what a todo update does. It never refuses a tool call and never holds up the end of a turn. /headway answers with no text for the model; the pane and toasts never reach it.
  • Timer: one, once a second, only while a plan is in progress and Claude (or a subagent) is working, on a surface that draws the band (the terminal or the desktop app). It also runs for the moment a finished plan's green bar shows (3 seconds) or "plan revised" shows (4 seconds), even while Claude is idle. It stops when Claude is idle, when you hide the line, when the plan is done and when nothing draws the band.
  • Sounds: none.

Limits

  • In the desktop app, a /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.
  • Time counts only while Claude or a subagent is working, so a pause between turns never makes a step look slow. A step's time is the span since the previous step finished; steps finished together share it.
  • Time left hides (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.
  • A plan-mode plan with no todo or task list after it has no progress to read. Headway shows Plan approved · 2 stages, 6 steps until that turn ends, then clears it.
  • A plan Claude stops updating (you interrupted, changed topic, or the last items were never marked done) leaves on its own once two of Claude's turns pass with no update to it: no receipt and no history for it. An approved plan-mode outline no list followed expires the same way. A /clear or resuming another conversation clears the plan at once.
  • Plan-mode parsing is a heuristic: the shallowest heading level used at least twice is the stage level, top-level list items are steps, and sections such as Context, Risks or Files to change are left out.
  • Matching a reworded step is fuzzy (letter-pair overlap of 75% or word overlap of 60%). Very different wording reads as one step removed and one added. A done step keeps its done only while the new wording keeps 60% of its words, so a sibling such as "Write tests for the UI" never inherits the done of "Write tests for the API".
  • Todo lists and plans kept by subagents are not shown; only the main conversation's plan is. Task updates from subagents do count for tasks Headway already knows.
  • The agents chip counts subagents from start to finish. Teammates are left out, since they idle between turns.
  • The "plan done" signal is a $.state value, not a telemetry mark: marks feed Claude Code's own analytics, which a display-only mod should not touch.
  • After a plugin reload the countdown resumes at Claude's next tool call or turn.
  • Drawn on the terminal and the desktop app, the two surfaces with a band above the prompt. The /headway pane opens on any surface that places panes.
  • Where Claude has no todo or task tool (the desktop app, and terminal builds without todo tools), Headway lends it a small plan tool, 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.
  • Titles are drawn without escape sequences, control characters or invisible marks (bidi overrides, zero-width characters), and the line is laid out in terminal cells, so wide (CJK) titles and emoji are cut to fit rather than wrapping.

Built from a prompt

prompts/headway.md

More mods: the full catalogue.

Source 9 files
hooks/register.tsx 353 lines
1// 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}
353
hooks/history.ts 73 lines
1// 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}
73
hooks/model.ts 633 lines
1// 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}
633
hooks/sources.ts 106 lines
1// 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}
106
hooks/pace.ts 105 lines
1// 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}
105
hooks/match.ts 132 lines
1// 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}
132
hooks/outline.ts 158 lines
1// 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}
158
hooks/clean.ts 25 lines
1// 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}
25
types/index.d.ts 99 lines
1// 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