SLOPSHOPPER

usage-band

Always-visible band above the prompt with the 5-hour and weekly usage limits, context fill and session cost

newbandguardcommand
v0.4.2MITupdated 2026-10-08nbfrodri/agent-tack/plugins/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-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 › /usage-band ⎿ usage-band: Usage band hidden. (tack chip: tack status exited 0: ) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<img src="docs/assets/tack-logo.png" alt="tack geometric t mark" width="144" height="144"> <h1 align="center">tack</h1> <a href="https://github.com/nbfrodri/agent-tack/actions/workflows/ci.yml"><img src="https://github.com/nbfrodri/agent-tack/actions/workflows/ci.yml/badge.svg" alt="CI status"></a> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-0d9488" alt="MIT license"></a> <a href="docs/editors.md"><img src="https://img.shields.io/badge/AI_tools-7-0d9488" alt="Seven supported AI tools"></a> <img src="https://img.shields.io/badge/runtime-Bash%20%2B%20Python-334155" alt="Bash and Python"> <a href="#quick-start">Quick start</a> &middot; <a href="docs/sharing.md">Team walkthrough</a> &middot; <a href="docs/README.md">Documentation</a> &middot; <a href="docs/results.md">Results</a>


Your next AI session should not need the same project briefing. tack turns your development preferences into reusable project guidance: how to make changes, where context belongs and what to check before calling the work done. Keep it personal, commit a shared setup for your team, or customize a fork across projects.

It works around your existing coding agent. It does not provide a model or replace your test framework.

Use tack when portable preferences and project coordination solve a real problem. If a short AGENTS.md and your existing CI already do the job, that simpler setup may be enough. Our benchmarks include cases where tack costs more without better code.

What you get

Agree onceCarry context forwardMake verification visible
Share conventions and preferences in Git instead of repeating them in every prompt.Give the next session the same architecture, plan and handoff locations.Run the project's real checks and see failures, timeouts and work that still needs review.
flowchart LR
  A[Project rules and settings] --> B[Your AI coding tool]
  B --> C[Focused change]
  C --> D[Project checks]
  D --> E[Reviewable result]

Ask for the change: "Fix the checkout bug" or "Add CSV export." Tack guides the assistant through work sized to the risk, meaningful tests, maintainable design and a reviewable Git history. TDD, pragmatic SOLID and documentation upkeep remain part of that guidance; executable checks provide narrower guarantees. Engineering practices.

The default catalog is just workflow and onboarding; specialist skills and roles are opt-in. Add procedures when they solve a real problem. Installation choices | Why this direction.

Quick start

Requires Git, Bash 3.2+ and Python 3.9+. Linux, macOS and Windows options are described in tool and platform support.

# Keep this checkout: installed files link to it.
git clone https://github.com/nbfrodri/agent-tack.git ~/Projects/agent-tack
~/Projects/agent-tack/install.sh --skip-plugins

cd ~/Projects/my-app
tack enable       # local to this clone; adds no project files
tack setup        # see clone settings, local differences and existing guidance

Start a new AI session and ask:

Configure tack for this project. Reuse the existing conventions and docs. Propose useful additions and let me choose what to create.

Existing guidance is enough to start; scaffold files are optional. tack setup --check checks the guidance you use without requiring extra templates. Review commands with tack verify --plan, then grant local execution trust with tack trust when you are ready to run them. Full setup guide.

Use it your way

Your situationStart here
Personal projectEnable your clone, choose preferences locally and work normally. No shared profile required.
Several developers on one projectCommit .tack, tack.json, project guidance and useful check maps. Each clone keeps its own trust and overrides.
Custom defaults across several projectsMaintain a personal or team fork and install from it. How forks work.
# Optional shared defaults for one repository
tack enable --shared
tack mode auto --shared
tack config reply-style brief --shared
tack config collaboration team --shared

Leave auto as the usual mode: a small fix and a risky migration need different levels of work. Say "use strict for this task" when needed; that need not change the saved default. Daily use | End-to-end team example.

Your project stays portable: the shared setup is ordinary files in Git, and you can disable tack or tidy old records without deleting useful project knowledge.

Working on backend and frontend in parallel? Share canonical contracts and run declared checks against a prospective merge with tack team --verify --against REF, before changing your branch. New collaborators can use a pinned setup command. Team workflow and limits.

Tools and checks

Claude Code, Codex, Gemini CLI, GitHub Copilot, OpenCode, Crush and Cursor receive the integrations they support. Git hooks are shared; native agent and runtime hook coverage varies. Support matrix and setup.

tack verify selects existing checks from changed paths; tack verify --all also checks a clean clone or completed integration. An optional checks-map.json maps code areas to commands. Results expose failures, timeouts and unmapped work. Verification guide.

Already happy with your project guidance? Run only the CLI checks, without installing global instructions, skills or hooks.

What the evidence says

The small-core comparison completed 24 development sessions and eight blind code reviews. The short guide and small core each passed acceptance in 8/8 deliveries, but both still had defects outside those checks. Mean code-quality grades did not improve. Compared with the guide, the core cost 61.9% more time / 67.6% more input tokens with Luna, and 5.0% more time / 25.4% more input with Sol. Complete results.

The earlier full lifecycle study also found higher cost without a general quality advantage. Tack's concrete checks can detect configuration drift and incompatible prospective merges; broader savings remain unproven. Enable what earns its cost in your project. All results | Direction.

Learn more

I want to...Guide
Install and enable tackSetup
Work with it every dayUsage
Share it or customize a forkSharing
Change settings and context locationsConfiguration
Add selected external skillsExternal skills
Understand the implementationArchitecture

All documentation includes optional features, engineering practices, installation details and benchmark history.

Contributing

Useful contributions include reproducible bugs, better adapters, checks that catch real failures and honest benchmarks. Read AGENTS.md and Development; use the issue forms and PR template.

tests/validate.sh
tests/lint.sh
tests/run-all.sh -j 4

Tests use temporary homes and repositories. Code and original project assets use the MIT license.

Source 2 files
hooks/register.tsx 175 lines
1import { atom, read, update } from 'claude-code'
2import type { Engine, Register, SessionContextUsage, SessionCost, SessionRateLimit } from 'claude-code'
3
4import type { Usage, UsageWindow } from '../types'
5
6const usage = atom({ plugin: 'usage-band', key: 'usage' } as const, { windows: [] } as Usage)
7const isHidden = atom({ plugin: 'usage-band', key: 'isHidden' } as const, false)
8// An atom, not a local, so setting it re-renders the band as soon as the mode is known.
9const chip = atom({ plugin: 'usage-band', key: 'chip' } as const, 'tack · …')
10
11const LABELS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
12const WARN_AT = [80, 90]
13const BAR_CELLS = 10
14
15export function toUsage(context: SessionContextUsage, rateLimits: SessionRateLimit[], cost?: SessionCost): Usage {
16  return {
17    windows: rateLimits.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt })),
18    contextPercent: context.percent,
19    costUsd: cost?.usd,
20  }
21}
22
23export function bar(percent: number): string {
24  const filled = Math.min(BAR_CELLS, Math.max(0, Math.round((percent / 100) * BAR_CELLS)))
25  return '█'.repeat(filled) + '░'.repeat(BAR_CELLS - filled)
26}
27
28export function colorFor(percent: number): string {
29  if (percent >= 90) return 'red'
30  if (percent >= 70) return 'yellow'
31  return 'green'
32}
33
34// Same-day resets show only the time; later ones add the weekday, which is what the weekly window needs.
35export function resetLabel(resetsAt: string | undefined, now: number): string {
36  if (!resetsAt) return ''
37  const at = new Date(resetsAt)
38  if (Number.isNaN(at.getTime())) return ''
39  const time = `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`
40  const sameDay = new Date(now).toDateString() === at.toDateString()
41  if (sameDay) return `resets ${time}`
42  const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][at.getDay()]
43  return `resets ${day} ${time}`
44}
45
46export function crossedWarning(before: UsageWindow[], after: UsageWindow[]): string | undefined {
47  for (const window of after) {
48    const previous = before.find(one => one.kind === window.kind)?.percentUsed ?? 0
49    const threshold = WARN_AT.filter(at => previous < at && window.percentUsed >= at).pop()
50    if (threshold !== undefined) {
51      return `${LABELS[window.kind] ?? window.kind} usage passed ${threshold}%`
52    }
53  }
54  return undefined
55}
56
57// A cost limit applies only in a project-only mode (tack prints a WARNING line for those)
58// and only when `tack config unleash-max-cost` holds a positive amount.
59export function costLimit(modeShow: string, configValue: string): number | undefined {
60  if (!modeShow.startsWith('WARNING:')) return undefined
61  const amount = Number(configValue.trim().split(' ')[0])
62  return Number.isFinite(amount) && amount > 0 ? amount : undefined
63}
64
65const CHIP_PENDING = 'tack · …'
66const CHIP_UNKNOWN = 'tack · ?'
67const CHIP_TRIES = 3
68
69// `tack mode` prints "<mode> (<source>)". `tack status --quiet` exits 1 when the project is not
70// enabled; any other failure (tack not found, an error) means the state is unknown, not off.
71export function modeChip(statusExit: number, modeOutput: string): string {
72  if (statusExit === 1) return 'tack · off'
73  const mode = modeOutput.trim().split(' ')[0]
74  return statusExit === 0 && mode ? `tack · ${mode}` : CHIP_UNKNOWN
75}
76
77export function chipNeedsRetry(label: string): boolean {
78  return label === CHIP_PENDING || label === CHIP_UNKNOWN
79}
80
81export function overBudget(costUsd: number | undefined, limitUsd: number | undefined): boolean {
82  return limitUsd !== undefined && costUsd !== undefined && costUsd >= limitUsd
83}
84
85export const register: Register = on => {
86  let limitUsd: number | undefined
87  let chipTries = 0
88  let chipError = ''
89
90  // Asks tack for the project's state; at session start the call can fail before the session is
91  // ready, so an unknown answer is retried with the next measurement instead of reading as off.
92  const refreshChip = async ($: Engine) => {
93    chipTries += 1
94    try {
95      const status = await $.process.run(['tack', 'status', '--quiet'], { timeoutMs: 5000 })
96      const mode = await $.process.run(['tack', 'mode'], { timeoutMs: 5000 })
97      const label = modeChip(status.exitCode, mode.exitCode === 0 ? mode.stdout : '')
98      chipError = label === CHIP_UNKNOWN ? `tack status exited ${status.exitCode}: ${status.stderr.trim()}` : ''
99      await update($, chip, () => label)
100    } catch (error) {
101      chipError = `could not run tack: ${error instanceof Error ? error.message : String(error)}`
102      await update($, chip, () => CHIP_UNKNOWN)
103    }
104  }
105
106  on('session.start', async ($, e, next) => {
107    const { context, rateLimits, cost } = await $.session.usage()
108    await update($, usage, () => toUsage(context, rateLimits, cost))
109    await $.command.register({ name: 'usage-band', description: 'Show or hide the usage band above the prompt' })
110    try {
111      const mode = await $.process.run(['tack', 'mode', 'show'], { timeoutMs: 5000 })
112      const config = await $.process.run(['tack', 'config', 'unleash-max-cost', '--get'], { timeoutMs: 5000 })
113      limitUsd = mode.exitCode === 0 && config.exitCode === 0 ? costLimit(mode.stdout, config.stdout) : undefined
114    } catch {
115      limitUsd = undefined
116    }
117    await refreshChip($)
118    return next(e)
119  })
120
121  on('tool.call', async ($, e, next) => {
122    const current = await read($, usage)
123    if (overBudget(current.costUsd, limitUsd)) {
124      return {
125        deny: `This autonomous session reached its cost limit of $${limitUsd} (tack config unleash-max-cost). Stop, update the handoff and summarise what is done, what is pending and the assumptions made.`,
126      }
127    }
128    return next(e)
129  })
130
131  on('session.measure', async ($, e, next) => {
132    const before = (await read($, usage)).windows
133    const after = toUsage(e.context, e.rateLimits, e.cost)
134    await update($, usage, () => after)
135    const warning = crossedWarning(before, after.windows)
136    if (warning) $.ui.toast(warning)
137    if (chipTries < CHIP_TRIES && chipNeedsRetry(await read($, chip))) await refreshChip($)
138    return next(e)
139  })
140
141  on('command.run', { command: 'usage-band' }, async $ => {
142    const hidden = await update($, isHidden, value => !value)
143    const why = chipError ? ` (tack chip: ${chipError})` : ''
144    return { text: (hidden ? 'Usage band hidden.' : 'Usage band shown.') + why }
145  })
146
147  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
148    const current = await read($, usage)
149    if (e.props.hasSurvey || (await read($, isHidden))) return next(e)
150
151    const { Box, Text } = $.ui.resolve(e)
152    const now = await $.clock.now()
153    const hasData = current.windows.length > 0 || current.contextPercent !== undefined
154
155    return (
156      <Box flexDirection="row" gap={3}>
157        <Text color="cyan">{await read($, chip)}</Text>
158        {!hasData && <Text dimColor>usage: waiting for the first response</Text>}
159        {current.windows.map(window => (
160          <Box key={window.kind} flexDirection="row" gap={1}>
161            <Text bold>{LABELS[window.kind] ?? window.kind}</Text>
162            <Text color={colorFor(window.percentUsed)}>{bar(window.percentUsed)}</Text>
163            <Text>{`${Math.round(window.percentUsed)}%`}</Text>
164            <Text dimColor>{resetLabel(window.resetsAt, now)}</Text>
165          </Box>
166        ))}
167        {current.contextPercent !== undefined && (
168          <Text dimColor>{`ctx ${current.contextPercent}%`}</Text>
169        )}
170        {current.costUsd !== undefined && <Text dimColor>{`$${current.costUsd.toFixed(2)}`}</Text>}
171      </Box>
172    )
173  })
174}
175
types/index.d.ts 14 lines
1export type UsageWindow = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type Usage = {
4  windows: UsageWindow[]
5  contextPercent?: number
6  costUsd?: number
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'usage-band': { usage: Usage; isHidden: boolean; chip: string }
12  }
13}
14