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

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 が作る場合も数えない。result.success:false は除外)。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 を直接発火)。plan 配列の status)は built-in 型表に無く、型定義未確認。safe 相当の guard を register 層で外した変異は、register のテストでは検出されない(例外を起こす入力を engine 越しに作れないため。guard 単体のテストで検出)。hooks/register.tsx 109 lines1import { 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}
109hooks/band.ts 183 lines1// 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}
183types/index.d.ts 10 lines1// 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