SLOPSHOPPER

forge-band

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

newbandcommandtoastprocesstimer
v0.1.0MITupdated 2026-10-03rynhardt81/forgeFlow/mods/forge-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · forge-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /forge-band ⎿ forge-band: cache 60m left · 97k ctx ⎿ forge-band: buttons: ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ forge ● 60m left cache · 97k ctx × │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ◆ forge ● 60m left cache · 97k ctx × │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

forge-band

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] × │
╰──────────────────────────────────────────────────────────────────────────────────────────╯
  • Cache — minutes left before the prompt cache lapses, counted 60 minutes from the last main-thread reply (an estimate: the mod cannot read the real TTL, which drops to 5 minutes under usage overage). Green > 15m, yellow ≤ 15m, red ≤ 5m; one toast at 5m.
  • Version — installed Forge Flow version from forge version (hidden on pre-4.2 installs that have no VERSION file).
  • Next task — first entry of forge task ls --ready --json.
  • Drift — finding count from consistency-banner.py --json (check only, no --fix).
  • Buttons — /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).

Load

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

Develop

claude plugin validate mods/forge-band
cd mods/forge-band && claude plugin test
Source 3 files
hooks/register.tsx 143 lines
1import { 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}
143
hooks/band.ts 89 lines
1// 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}
89
types/index.d.ts 28 lines
1export 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