Forge Flow band above the prompt: prompt-cache countdown, next ready task, registry drift, and one-press /reflect and /run-epic buttons

A Claude Code mod (a plugin of function hooks, Claude Code v2.1.287+) that draws a bordered band above the prompt:
╭──────────────────────────────────────────────────────────────────────────────────────────╮
│ ◆ forge v4.8.0 ● 42m cache · 180k ctx ▸ T313 · E16 · 7 ready ✓ in sync [Status] [Resume] [Handoff] [Review PR #89] [Run E16] × │
╰──────────────────────────────────────────────────────────────────────────────────────────╯
forge version (hidden on pre-4.2 installs that have no VERSION file).forge task ls --ready --json.consistency-banner.py --json (check only, no --fix)./reflect status|resume|handoff, /create-pr review <PR#> (only when gh pr view finds an open PR for the current branch) and /run-epic <epic of next task>. Run needs a second press within 10s. A button whose skill is not installed is hidden./forge-band prints the same line as text; /forge-band show unhides the band.The band's own refresh only reads: it never writes a file or task state. Its buttons are different — a press runs that skill, and /run-epic or /reflect handoff can change files, task state and git history, which is why Run needs a confirming second press.
Python settings hooks stay the floor: mods can be switched off (disableAllHooks, --safe-mode, an organisation's allowManagedModsOnly).
Refresh copies this folder to <project>/.claude/mods/forge-band/; in the framework repo it lives at mods/forge-band/. Claude Code does not load it on its own.
Every session — add the folder to the env block of ~/.claude/settings.json (a project's settings are not read for this), then restart Claude:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/forge-band" }
The path must be absolute (~ allowed); separate several with :. One copy serves every project: the band finds Forge Flow under .claude/ or at the repo root, and outside a Forge Flow project it shows the cache alone.
One session only — the flag lasts until you exit:
claude --plugin-dir .claude/mods/forge-band # mods/forge-band in the framework repo
claude plugin validate mods/forge-band
cd mods/forge-band && claude plugin testhooks/register.tsx 143 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { actions, cacheState, CONFIRM_MS, LAYOUTS, parseForge, parsePr, summary, tokens, WARN_MS } from './band'
5import type { Forge } from '../types'
6
7const lastResponseAt = atom({ plugin: 'forge-band', key: 'lastResponseAt' } as const, 0)
8const contextTokens = atom({ plugin: 'forge-band', key: 'contextTokens' } as const, 0)
9const forgeState = atom({ plugin: 'forge-band', key: 'forge' } as const, null)
10const hidden = atom({ plugin: 'forge-band', key: 'hidden' } as const, false)
11const warned = atom({ plugin: 'forge-band', key: 'warned' } as const, false)
12const armed = atom({ plugin: 'forge-band', key: 'armed' } as const, null)
13const openPr = atom({ plugin: 'forge-band', key: 'openPr' } as const, null)
14
15// Slash commands this session can run; reset with the module, refilled in session.start.
16let available: ReadonlySet<string> = new Set()
17
18// Reads the task queue and registry drift through Forge Flow's own CLI.
19// Returns null outside a Forge Flow project, so the band shows the cache alone.
20async function loadForge($: EngineInterface): Promise<Forge | null> {
21 for (const root of LAYOUTS) {
22 const ready = await $.process.run(['python3', `${root}scripts/forge/forge.py`, 'task', 'ls', '--ready', '--json'])
23 if (ready.exitCode !== 0) continue
24 const drift = await $.process.run(['python3', `${root}hooks/forge/consistency-banner.py`, '--json'], { stdin: '' })
25 const version = await $.process.run(['python3', `${root}scripts/forge/forge.py`, 'version'])
26 return parseForge(ready.stdout, drift.stdout, version.stdout)
27 }
28 return null
29}
30
31// The current branch's open PR, or null: no PR, not a git repo, or gh missing or signed out.
32async function loadPr($: EngineInterface): Promise<number | null> {
33 const run = await $.process.run(['gh', 'pr', 'view', '--json', 'number,state'])
34 return parsePr(run.exitCode, run.stdout)
35}
36
37async function refresh($: EngineInterface) {
38 const [forge, pr] = await Promise.all([loadForge($).catch(() => null), loadPr($).catch(() => null)])
39 await update($, forgeState, () => forge)
40 await update($, openPr, () => pr)
41}
42
43async function stamp($: EngineInterface) {
44 const now = await $.clock.now()
45 await update($, lastResponseAt, () => now)
46 await update($, warned, () => false)
47}
48
49export const register: Register = on => {
50 on('session.start', async ($, e, next) => {
51 await $.command.register({ name: 'forge-band', description: 'Print the forge band as text; "show" unhides it' })
52 available = new Set((await $.command.list()).map(c => c.name))
53 void refresh($)
54 // Redraw the countdown, and warn once when the cache is about to lapse.
55 $.clock.every(30_000, async () => {
56 const cache = cacheState(await read($, lastResponseAt), await $.clock.now())
57 if (cache.leftMs > 0 && cache.leftMs <= WARN_MS && !(await read($, warned))) {
58 await update($, warned, () => true)
59 $.ui.toast(`Prompt cache expires in ${Math.ceil(cache.leftMs / 60000)}m. Handoff now, or the next turn re-sends the whole context.`)
60 }
61 $.ui.invalidate('ui.render')
62 })
63 return next(e)
64 })
65
66 // Fires when the main thread's context fill moves, i.e. after each model response.
67 on('session.measure', async ($, e, next) => {
68 if (e.context.tokens) {
69 await update($, contextTokens, () => e.context.tokens ?? 0)
70 await stamp($)
71 }
72 return next(e)
73 })
74
75 on('turn.complete', async ($, e, next) => {
76 const result = await next(e)
77 if (!e.agentId) void refresh($)
78 return result
79 })
80
81 on('command.run', { command: 'forge-band' }, async ($, e) => {
82 if (e.args.trim() === 'show') await update($, hidden, () => false)
83 await refresh($)
84 const forge = await read($, forgeState)
85 const line = summary(cacheState(await read($, lastResponseAt), await $.clock.now()), await read($, contextTokens), forge)
86 const buttons = actions(forge, available, null, await read($, openPr)).map(a => `/${a.command} ${a.args}`).join(', ')
87 return { text: `${line}\nbuttons: ${buttons}` }
88 })
89
90 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
91 if (e.props.hasSurvey || (await read($, hidden))) return next(e)
92
93 const { Box, Button, Text } = $.ui.resolve(e)
94 const forge = await read($, forgeState)
95 const cache = cacheState(await read($, lastResponseAt), await $.clock.now())
96 const ctx = tokens(await read($, contextTokens))
97
98 return (
99 <Box borderStyle="round" borderColor="cyan" paddingX={1} flexDirection="row" flexWrap="wrap" justifyContent="space-between" columnGap={3}>
100 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
101 <Text>
102 <Text bold color="cyan">◆ forge</Text>
103 {forge?.version && <Text dimColor> v{forge.version}</Text>}
104 </Text>
105 <Text>
106 <Text color={cache.color}>● </Text>
107 <Text>{cache.label.replace('cache ', '')}</Text>
108 <Text dimColor> cache · {ctx} ctx</Text>
109 </Text>
110 {forge && (
111 <Text>
112 <Text color="cyan">▸ </Text>
113 {forge.next ? <Text bold>{forge.next.id}</Text> : <Text dimColor>no ready tasks</Text>}
114 {forge.next && <Text dimColor> · {forge.next.epic} · {forge.ready} ready</Text>}
115 </Text>
116 )}
117 {forge && (forge.drift === 0
118 ? <Text color="green">✓ in sync</Text>
119 : <Text color="yellow">⚠ {forge.drift < 0 ? 'drift ?' : `${forge.drift} drift`}</Text>)}
120 </Box>
121 <Box flexDirection="row" columnGap={1}>
122 {actions(forge, available, await read($, armed), await read($, openPr)).map(a => (
123 <Button
124 key={a.key}
125 label={a.label}
126 onPress={async () => {
127 if (a.confirm && (await read($, armed)) !== a.args) {
128 await update($, armed, () => a.args)
129 $.clock.after(CONFIRM_MS, () => update($, armed, () => null))
130 return
131 }
132 await update($, armed, () => null)
133 await $.command.run({ command: a.command, args: a.args }).catch(err => $.ui.toast(`/${a.command} ${a.args}: ${err}`))
134 }}
135 />
136 ))}
137 <Button key="hide" label="×" plain dimColor onPress={() => update($, hidden, () => true)} />
138 </Box>
139 </Box>
140 )
141 })
142}
143hooks/band.ts 89 lines1// Pure logic for the band: no `$`, so tests can call it directly.
2import type { Forge, NextTask } from '../types'
3
4// ponytail: fixed 1h TTL. The API usage the mod sees carries no TTL, and under usage
5// overage the cache drops to 5m; make it a userConfig option if that bites.
6export const CACHE_TTL_MS = 60 * 60 * 1000
7export const WARN_MS = 5 * 60 * 1000
8const AMBER_MS = 15 * 60 * 1000
9
10// Forge Flow lays out the same files under `.claude/` in a consumer project
11// and at the root of the framework repo itself.
12export const LAYOUTS = ['.claude/', ''] as const
13
14export type Cache = { leftMs: number; color: 'green' | 'yellow' | 'red' | 'gray'; label: string }
15
16export function cacheState(lastResponseAt: number, now: number): Cache {
17 if (lastResponseAt === 0) return { leftMs: 0, color: 'gray', label: 'cache –' }
18 const leftMs = Math.max(0, lastResponseAt + CACHE_TTL_MS - now)
19 if (leftMs === 0) return { leftMs, color: 'red', label: 'cache cold' }
20 const color = leftMs <= WARN_MS ? 'red' : leftMs <= AMBER_MS ? 'yellow' : 'green'
21 return { leftMs, color, label: `cache ${Math.ceil(leftMs / 60000)}m left` }
22}
23
24export function tokens(n: number): string {
25 return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
26}
27
28// `forge task ls --ready --json` prints the queue in priority order.
29// `forge version` prints the VERSION file, or an "unknown (...)" placeholder on pre-4.2 installs.
30export function parseForge(readyJson: string, driftJson: string, versionOut = ''): Forge {
31 const ready: Array<{ id: string; epic: string }> = JSON.parse(readyJson)
32 const [first] = ready
33 const next: NextTask | null = first ? { id: first.id, epic: first.epic } : null
34 let drift = 0
35 try {
36 drift = (JSON.parse(driftJson).findings ?? []).length
37 } catch {
38 drift = -1 // banner unreadable: show "?" rather than a false 0
39 }
40 const version = /^\d/.test(versionOut.trim()) ? versionOut.trim() : null
41 return { next, ready: ready.length, drift, version }
42}
43
44// `gh pr view --json number,state` exits non-zero when the branch has no PR,
45// and still returns the PR after it is merged or closed.
46export function parsePr(exitCode: number, stdout: string): number | null {
47 if (exitCode !== 0) return null
48 try {
49 const pr = JSON.parse(stdout)
50 return pr.state === 'OPEN' ? pr.number : null
51 } catch {
52 return null
53 }
54}
55
56// `confirm`: the first press only arms the button; a second press within CONFIRM_MS runs it.
57export type Action = { key: string; label: string; command: string; args: string; confirm?: true }
58
59export const CONFIRM_MS = 10_000
60
61// The four commands run most across the last 30 Forge Flow sessions, plus the open PR's review loop.
62// `armed` is the epic whose Run button was pressed once and awaits confirmation;
63// `pr` is the current branch's open PR, without which there is nothing to review.
64export function actions(forge: Forge | null, available: ReadonlySet<string>, armed: string | null = null, pr: number | null = null): Action[] {
65 const list: Action[] = [
66 { key: 'status', label: 'Status', command: 'reflect', args: 'status' },
67 { key: 'resume', label: 'Resume', command: 'reflect', args: 'resume' },
68 { key: 'handoff', label: 'Handoff', command: 'reflect', args: 'handoff' },
69 ]
70 if (pr !== null) list.push({ key: 'review', label: `Review PR #${pr}`, command: 'create-pr', args: `review ${pr}` })
71 if (forge?.next) {
72 const epic = forge.next.epic
73 const label = armed === epic ? `Confirm Run ${epic}?` : `Run ${epic}`
74 list.push({ key: 'run', label, command: 'run-epic', args: epic, confirm: true })
75 }
76 // A button whose skill is not installed here would only fail on press.
77 return list.filter(a => available.has(a.command))
78}
79
80export function summary(cache: Cache, contextTokens: number, forge: Forge | null): string {
81 const parts = [`${cache.label} · ${tokens(contextTokens)} ctx`]
82 if (forge) {
83 if (forge.version) parts.unshift(`forge v${forge.version}`)
84 parts.push(forge.next ? `next ${forge.next.id} (${forge.next.epic}) · ${forge.ready} ready` : 'no ready tasks')
85 parts.push(`drift ${forge.drift < 0 ? '?' : forge.drift}`)
86 }
87 return parts.join(' │ ')
88}
89types/index.d.ts 28 lines1export type NextTask = { id: string; epic: string }
2
3export type Forge = {
4 // null when the session's directory is not a Forge Flow project
5 next: NextTask | null
6 ready: number
7 drift: number
8 // installed Forge Flow version, e.g. "4.8.0"; null when VERSION is missing
9 version: string | null
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'forge-band': {
15 // ms since epoch of the last main-thread model response; 0 before the first
16 lastResponseAt: number
17 contextTokens: number
18 forge: Forge | null
19 hidden: boolean
20 warned: boolean
21 // epic whose Run button awaits its confirming second press
22 armed: string | null
23 // number of the current branch's open PR; null when there is none
24 openPr: number | null
25 }
26 }
27}
28