SLOPSHOPPER

progress-band

タスク進捗(TaskCreate/TaskUpdate/TodoWrite/update_plan)と文脈使用量を、プロンプト上の帯 1 行で持続表示する。決定的コードのみ・fail-open。

newbandguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · progress-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 文脈 ▓▓▓▓░░░░ 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
文脈 ▓▓▓▓░░░░ 49%
README

progress-band

Claude Code の mod。プロンプト上の帯(AbovePrompt)に、作業進捗と文脈使用量を 1 行で出す。 決定的コードのみ(モデル・ネットワーク・ファイル書込・$.process なし)。観測は fail-open(deny も書き換えもしない・常に next の結果を返す)。

表示(テーマ色キー。ダーク/ライトに自動追従)

状態例色
タスクあり▓▓▓░░░░░ 3/7 ・進行1 | 文脈 24%バーと n/m=claude・・進行n | 文脈=dim
全完了✓ 7/7 完了 | 文脈 24%success
タスク無し文脈 ▓▓░░░░░░ 24%バーと %=70% 未満 claude/70% 以上 warning
85% 以上末尾に (要 compact 検討)warning+bold

何も無い時、e.props.hasSurvey の時は帯を出さない(next(e))。

観測するもの

  • tool.call: TaskCreate / TaskUpdate / TodoWrite / update_plan(正規表現 (^|__)update_plan$)。
  • メインエージェントの呼び出しのみ数える: e.agentId 付き(サブエージェント/チームメイト)は記録しない。共有タスクを別 agent が作る場合も数えない。
  • 通った呼び出しだけ数える(deny / isError / result.success:false は除外)。
  • 系統ごとにキーを分ける: TaskCreate/Update=t:<id>、TodoWrite/update_plan(全置換)=s:<n>。未知 id の TaskUpdate は記録しない。
  • 文脈使用量: session.measure の e.context(percent、無ければ tokens/window)。context は在るが値が無い(compact 直後)なら null=帯から消す。turn.complete では取らない(同値の重複のため)。
  • session.end: reason が clear / resume で件数と使用量をリセット。

信頼と中身の確認

mod は利用者の権限で動きます。claude plugin validate ./progress-band の出力で確認できる内容: hooks は tool.call(TaskCreate・TaskUpdate・TodoWrite・update_plan)・session.measure・session.end・ui.render{AbovePrompt}、calls は $.state.get/set・$.ui.resolve だけです($.process・$.fs.write・$.http は呼びません)。

導入と撤去

/plugin install progress-band@guard-and-band-for-claude-code
/plugin uninstall progress-band@guard-and-band-for-claude-code

単独で完結します(他の mod に依存しません)。

確かめ手順

# PATH の古い claude(2.1.287 未満)では validate が失敗する。Desktop 同梱版または 2.1.287 以上を使う(対応は 2.1.287 以上を前提)
CLAUDE=<claude 2.1.287 以上>
$CLAUDE plugin validate ./progress-band
$CLAUDE plugin test ./progress-band
tsc -p <型定義を置いた tsconfig>   # tsconfig.json は .claude-plugin/types を include(エンジンが読込時に置く)

実描画は Desktop で TodoWrite 等を走らせて確認する。

未確認点

  • session.measure が各応答の後に context 変化として届くこと(turn.complete の usage 取得は重複なので外した。届かない場合は復活させる)。
  • session.end の reason clear / resume の実セッションでの発火(型定義の記述のみ・テストは $.session.end を直接発火)。
  • update_plan の入力形(plan 配列の status)は built-in 型表に無く、型定義未確認。
  • safe 相当の guard を register 層で外した変異は、register のテストでは検出されない(例外を起こす入力を engine 越しに作れないため。guard 単体のテストで検出)。
Source 3 files
hooks/register.tsx 109 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { BandTasks } from '../types'
5import {
6  EMPTY,
7  applyCreate,
8  applySnapshot,
9  applyUpdate,
10  createdIdOf,
11  guard,
12  hasContext,
13  percentOf,
14  segments,
15  snapshotToMap,
16  succeeded,
17} from './band'
18
19const tasks = atom({ plugin: 'progress-band', key: 'tasks' } as const, EMPTY)
20const percent = atom({ plugin: 'progress-band', key: 'percent' } as const, null)
21
22// 観測は fail-open: 例外は guard が握り、常に next の結果をそのまま返す(deny も書き換えもしない)。
23// メインエージェントの呼び出しのみ数える: agentId 付き(サブエージェント/チームメイト)は記録しない。
24export const register: Register = on => {
25  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
26    const ran = await next(e)
27    await guard(async () => {
28      if (e.agentId === undefined && succeeded(ran)) {
29        const id = createdIdOf(ran)
30        await update($, tasks, (s: BandTasks) => applyCreate(s, id))
31      }
32    })
33    return ran
34  }).catch(($, e, next) => next(e))
35
36  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
37    const ran = await next(e)
38    await guard(async () => {
39      if (e.agentId === undefined && succeeded(ran)) {
40        await update($, tasks, (s: BandTasks) => applyUpdate(s, e.taskId, e.status))
41      }
42    })
43    return ran
44  }).catch(($, e, next) => next(e))
45
46  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
47    const ran = await next(e)
48    await guard(async () => {
49      const snap = e.agentId === undefined && succeeded(ran) ? snapshotToMap(e.todos) : null
50      if (snap !== null) await update($, tasks, (s: BandTasks) => applySnapshot(s, snap))
51    })
52    return ran
53  }).catch(($, e, next) => next(e))
54
55  on('tool.call', { tool: /(^|__)update_plan$/ }, async ($, e, next) => {
56    const ran = await next(e)
57    await guard(async () => {
58      const snap = e.agentId === undefined && succeeded(ran) ? snapshotToMap((e as { plan?: unknown }).plan) : null
59      if (snap !== null) await update($, tasks, (s: BandTasks) => applySnapshot(s, snap))
60    })
61    return ran
62  }).catch(($, e, next) => next(e))
63
64  // 文脈使用量はこの 1 本だけで取る(turn.complete での $.session.usage() は同じ値の重複なので置かない)。
65  // compact 直後は percent/tokens が無い: context は在るので null(未取得)にして古い % を残さない。
66  on('session.measure', async ($, e, next) => {
67    await guard(async () => {
68      if (hasContext(e)) {
69        const p = percentOf(e)
70        await update($, percent, () => p)
71      }
72    })
73    return next(e)
74  }).catch(($, e, next) => next(e))
75
76  // /clear(と resume)で会話が替わる: 件数と使用量をリセットする。
77  on('session.end', async ($, e, next) => {
78    await guard(async () => {
79      if (e.reason === 'clear' || e.reason === 'resume') {
80        await update($, tasks, () => EMPTY)
81        await update($, percent, () => null)
82      }
83    })
84    return next(e)
85  }).catch(($, e, next) => next(e))
86
87  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
88    try {
89      if (e.props.hasSurvey) return next(e)
90      const state = await read($, tasks)
91      const pct = await read($, percent)
92      const segs = segments(state.map, pct)
93      if (segs.length === 0) return next(e)
94      const { Box, Text } = $.ui.resolve(e)
95      return (
96        <Box>
97          {segs.map((g, i) => (
98            <Text key={String(i)} color={g.color} dimColor={g.dim} bold={g.bold}>
99              {g.text}
100            </Text>
101          ))}
102        </Box>
103      )
104    } catch {
105      return next(e)
106    }
107  }).catch(($, e, next) => next(e))
108}
109
hooks/band.ts 183 lines
1// progress-band の純関数群(モデル・ネットワーク・ファイル書込なし)。
2import type { BandTasks, TaskStatus } from '../types'
3
4const VALID = new Set<string>(['pending', 'in_progress', 'completed'])
5export const BAR_WIDTH = 8
6export const COMPACT_AT = 85
7
8export const EMPTY: BandTasks = { map: {}, seq: 0 }
9
10const isFiniteNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
11const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
12
13const clampPct = (n: number): number => Math.max(0, Math.min(100, Math.round(n)))
14
15export function summarize(map: unknown): { done: number; doing: number; total: number; text: string } {
16  let done = 0
17  let doing = 0
18  let total = 0
19  if (isRecord(map)) {
20    for (const status of Object.values(map)) {
21      if (typeof status !== 'string' || !VALID.has(status)) continue
22      total += 1
23      if (status === 'completed') done += 1
24      else if (status === 'in_progress') doing += 1
25    }
26  }
27  if (total === 0) return { done, doing, total, text: '' }
28  if (done === total) return { done, doing, total, text: `✓ ${done}/${total} 完了` }
29  const filled = Math.min(Math.round((done / total) * BAR_WIDTH), BAR_WIDTH - 1) // 未完了のうちは満タンにしない
30  const bar = '▓'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
31  const tail = doing > 0 ? ` ・進行${doing}` : ''
32  return { done, doing, total, text: `${bar} ${done}/${total}${tail}` }
33}
34
35export function renderContext(percent: unknown): string {
36  if (!isFiniteNumber(percent)) return ''
37  const p = clampPct(percent)
38  const filled = Math.min(BAR_WIDTH, Math.round((p / 100) * BAR_WIDTH))
39  const bar = '▓'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
40  return `文脈 ${bar} ${p}%${p >= COMPACT_AT ? '(要 compact 検討)' : ''}`
41}
42
43/** usage() / session.measure の入力(どちらも `.context` を持つ)から 0〜100 を取る。取れなければ null。 */
44export function percentOf(usage: unknown): number | null {
45  const c = isRecord(usage) ? usage.context : undefined
46  if (!isRecord(c)) return null
47  if (isFiniteNumber(c.percent)) return clampPct(c.percent)
48  if (isFiniteNumber(c.tokens) && isFiniteNumber(c.window) && c.window > 0) {
49    return clampPct((c.tokens / c.window) * 100)
50  }
51  return null
52}
53
54/** 1 行の合成。表示するものが無ければ undefined。 */
55export function compose(map: unknown, percent: unknown): string | undefined {
56  const t = summarize(map)
57  const hasPct = isFiniteNumber(percent)
58  if (t.total > 0) {
59    if (!hasPct) return t.text
60    const p = clampPct(percent)
61    return `${t.text} | 文脈 ${p}%${p >= COMPACT_AT ? '(要 compact 検討)' : ''}`
62  }
63  return hasPct ? renderContext(percent) : undefined
64}
65
66export type Seg = { text: string; color?: 'claude' | 'success' | 'warning'; dim?: boolean; bold?: boolean }
67
68/** 文脈使用量の色キー: 70% 未満 claude / 70% 以上 warning。 */
69export function contextColor(p: number): 'claude' | 'warning' {
70  return p >= 70 ? 'warning' : 'claude'
71}
72
73/** 帯の部品列。text を連結すると compose と同じ 1 行になる。無ければ []。 */
74export function segments(map: unknown, percent: unknown): Seg[] {
75  const t = summarize(map)
76  const hasPct = isFiniteNumber(percent)
77  const out: Seg[] = []
78  if (t.total > 0) {
79    if (t.done === t.total) {
80      out.push({ text: t.text, color: 'success' })
81    } else {
82      out.push({ text: `${t.text.split(' ・')[0]}`, color: 'claude' })
83      if (t.doing > 0) out.push({ text: ` ・進行${t.doing}`, dim: true })
84    }
85    if (hasPct) {
86      const p = clampPct(percent)
87      out.push({ text: ' | ', dim: true }, { text: '文脈 ', dim: true })
88      out.push({ text: `${p}%`, color: contextColor(p) })
89      if (p >= COMPACT_AT) out.push({ text: '(要 compact 検討)', color: 'warning', bold: true })
90    }
91    return out
92  }
93  if (!hasPct) return out
94  const p = clampPct(percent)
95  const filled = Math.min(BAR_WIDTH, Math.round((p / 100) * BAR_WIDTH))
96  out.push({ text: '文脈 ', dim: true })
97  out.push({ text: `${'▓'.repeat(filled)}${'░'.repeat(BAR_WIDTH - filled)} ${p}%`, color: contextColor(p) })
98  if (p >= COMPACT_AT) out.push({ text: '(要 compact 検討)', color: 'warning', bold: true })
99  return out
100}
101
102/** 系統ごとにキーを分ける: TaskCreate/Update は `t:<id>`、スナップショット(TodoWrite/update_plan)は `s:<n>`。 */
103const T = 't:'
104const S = 's:'
105
106export function applyCreate(state: BandTasks | null | undefined, createdId: unknown): BandTasks {
107  const map = { ...(state?.map ?? {}) }
108  const seq = (state?.seq ?? 0) + 1
109  const hasId = (typeof createdId === 'string' && createdId !== '') || isFiniteNumber(createdId)
110  map[hasId ? `${T}${String(createdId)}` : `${T}seq-${seq}`] = 'pending'
111  return { map, seq }
112}
113
114/** 既知のタスクにだけ適用する(未知 id は記録しない)。 */
115export function applyUpdate(state: BandTasks | null | undefined, taskId: unknown, status: unknown): BandTasks {
116  const base: BandTasks = state ?? EMPTY
117  if (!((typeof taskId === 'string' && taskId !== '') || isFiniteNumber(taskId))) return base
118  const key = `${T}${String(taskId)}`
119  if (!(key in base.map)) return base
120  const map = { ...base.map }
121  if (status === 'deleted') {
122    delete map[key]
123  } else if (typeof status === 'string' && VALID.has(status)) {
124    map[key] = status as TaskStatus
125  } else {
126    return base
127  }
128  return { map, seq: base.seq }
129}
130
131/** スナップショットで `s:` 系統だけを置換する(`t:` 系統は残す)。 */
132export function applySnapshot(state: BandTasks | null | undefined, snap: BandTasks['map']): BandTasks {
133  const base: BandTasks = state ?? EMPTY
134  const map: BandTasks['map'] = {}
135  for (const [k, v] of Object.entries(base.map)) if (!k.startsWith(S)) map[k] = v
136  for (const [k, v] of Object.entries(snap)) map[`${S}${k}`] = v
137  return { map, seq: base.seq }
138}
139
140/** スナップショット(TodoWrite.todos / update_plan.plan)→ {n: status}。配列でなければ null(無視)。 */
141export function snapshotToMap(items: unknown): BandTasks['map'] | null {
142  if (!Array.isArray(items)) return null
143  const map: BandTasks['map'] = {}
144  let n = 0
145  for (const it of items) {
146    n += 1
147    const status = isRecord(it) ? it.status : undefined
148    if (typeof status === 'string' && VALID.has(status)) map[String(n)] = status as TaskStatus
149  }
150  return map
151}
152
153/** 通った呼び出しだけ数える(deny / isError / result.success:false は数えない)。 */
154export function succeeded(ran: unknown): boolean {
155  if (!isRecord(ran)) return false
156  if (ran.deny !== undefined || ran.isError === true) return false
157  const r = ran.result
158  if (isRecord(r) && r.success === false) return false
159  return true
160}
161
162/** TaskCreate の戻りから作成 id を取る。 */
163export function createdIdOf(ran: unknown): unknown {
164  const r = isRecord(ran) ? ran.result : undefined
165  const task = isRecord(r) ? r.task : undefined
166  return isRecord(task) ? task.id : undefined
167}
168
169/** context が在るか(compact 直後は percent/tokens が無いが context は在る)。 */
170export function hasContext(x: unknown): boolean {
171  return isRecord(x) && isRecord(x.context)
172}
173
174/** 例外を握って false を返す(観測は fail-open: 帯が古くなるだけ)。 */
175export async function guard(work: () => Promise<unknown>): Promise<boolean> {
176  try {
177    await work()
178    return true
179  } catch {
180    return false
181  }
182}
183
types/index.d.ts 10 lines
1// progress-band の $.state 契約: タスク(id → status と作成順の連番)と、文脈使用量(0〜100・未取得は null)。
2export type TaskStatus = 'pending' | 'in_progress' | 'completed'
3export type BandTasks = { map: { [id: string]: TaskStatus }; seq: number }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'progress-band': { tasks: BandTasks; percent: number | null }
8  }
9}
10