Configure and report bounded, repository-specific coding-agent signals to the local Pomegr observer.

Pomegr is a local-first, read-only observer for coding-agent sessions. This plugin loads a repository-owned reporting policy, lets Claude Code report bounded project-specific signals, and exposes read-only queries over normalized observations already committed by the local Pomegr monitor.
The plugin does not send transcript contents, source code, prompts, responses, commands, tool output, or provider credentials to Pomegr. Reporting remains opt-in per repository through .pomegr/signals.md, and query tools fail closed when the local observer is unavailable. Read-only queries connect only to the loopback Pomegr process and use its short-lived local capability; that capability is not an account credential and never leaves the computer.
/pomegr:init in a repository to review and create its reporting policy./pomegr:doctor for a read-only setup check.On Claude Code 2.1.288 or newer the plugin draws one quiet line above the prompt, led by the repository's folder name: the main agent's context, its last 5 requests, the agent's own progress estimate, account usage windows, and the subagents of the current prompt. It reads only the figures Claude Code already has for the session, works without the Pomegr app, and never blocks or changes a prompt or tool call. Run /pomegr-hud to move or hide it.
Documentation and source are available in the Pomegr repository. Report problems through GitHub Issues.
The included icon is Pomegr's first-party product mark, also used by the app and favicon. The generated JavaScript bundles let the plugin run without installing node_modules; their canonical sources, pinned dependency versions, build script, and preserved third-party legal notices are available in the Pomegr repository.
hooks/register.tsx 327 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, Place, Reading, Recorded, Usage } from '../types'
5import { REQUESTS_KEPT, SECONDS_BELOW_MS, agentLabel, agentsLine, cacheState, detectRefill, line, repoName, shouldProbe, span, tokens, toolName } from './hud'
6import type { Link, Style } from './hud'
7
8const main = atom({ plugin: 'pomegr', key: 'main' } as const, null)
9const usage = atom({ plugin: 'pomegr', key: 'usage' } as const, null)
10const recorded = atom({ plugin: 'pomegr', key: 'recorded' } as const, null)
11const now = atom({ plugin: 'pomegr', key: 'now' } as const, 0)
12const agents = atom({ plugin: 'pomegr', key: 'agents' } as const, [])
13const progress = atom({ plugin: 'pomegr', key: 'progress' } as const, null)
14const requests = atom({ plugin: 'pomegr', key: 'requests' } as const, [])
15const place = atom({ plugin: 'pomegr', key: 'place' } as const, 'above')
16const repo = atom({ plugin: 'pomegr', key: 'repo' } as const, null)
17
18const NEXT_PLACE: Record<Place, Place> = { above: 'below', below: 'hidden', hidden: 'above' }
19
20// Mostly the terminal's own text colors; only the brand mark, bars and marks carry color.
21const COLOR: Partial<Record<Style, string>> = {
22 brand: '#d9614f',
23 accent: '#5b9df0',
24 ok: '#3fa66b',
25 warning: 'warning',
26 error: 'error',
27}
28
29// The Pomegr MCP server's key in this plugin's manifest.
30const POMEGR_SERVER = 'pomegr'
31const LIMIT_STEPS = [80, 95]
32const RECORDED: readonly string[] = ['5m', '1h', 'mixed', '30m+']
33const OWN_PROMPTS: readonly string[] = ['composer', 'bridge', 'sdk']
34
35function limitStep(percentUsed: number): number {
36 return LIMIT_STEPS.filter(step => percentUsed >= step).length
37}
38
39function recordedLifetime(result: unknown): Recorded | null | undefined {
40 if (typeof result !== 'object' || result === null) return undefined
41 const data = result as { readiness?: unknown; context?: { cacheLifetime?: unknown } | null }
42 if (data.readiness !== 'ready') return undefined
43 const lifetime = data.context?.cacheLifetime
44 return typeof lifetime === 'string' && RECORDED.includes(lifetime) ? (lifetime as Recorded) : null
45}
46
47let isTurnRunning = false
48const link: Link = { isLinked: false, isRefused: false, probedAt: 0 }
49let shownBucket = 0
50let ticks = 0
51// Subagents seen running since the last prompt, and each loop's latest prompt size.
52const tracked = new Map<string, AgentRow>()
53const agentTokens = new Map<string, number>()
54const limitSteps = new Map<string, number>()
55// A `/clear` or a resume ends the session and starts another in this process: no
56// `session.start` fires for it, and the host's named values start over.
57let endedSession: string | null = null
58let keptPlace: Place = 'above'
59
60// Optional: a running Pomegr monitor knows the lifetime the provider recorded.
61// One server, under the name the engine runs it; a refusal is never asked twice.
62// The call names no session: the plugin's PreToolUse hook binds it to the current one
63// and grants that one read, so it needs no rule of the person's. A rule that denies
64// the tool is honored without calling.
65async function probe($: EngineInterface): Promise<void> {
66 const at = await $.clock.now()
67 if (!shouldProbe(link, at)) return
68 link.probedAt = at
69 let lifetime: Recorded | null | undefined
70 try {
71 const found = await $.mcp.connect(POMEGR_SERVER)
72 if (found.isConnected) {
73 const { decision } = await $.tool.check({ tool: toolName(found.server, 'get_agent_context'), input: {} })
74 const result = decision === 'deny' ? null : await $.mcp.call(found.server, 'get_agent_context')
75 // A monitor that is not running answers without an error: worth asking again
76 // later. An error is the engine refusing the call.
77 if (result === null || result.isError) link.isRefused = true
78 else lifetime = recordedLifetime(result.structuredContent)
79 } else {
80 link.isRefused = true
81 }
82 } catch {
83 link.isRefused = true
84 }
85 link.isLinked = lifetime !== undefined
86 await update($, recorded, () => lifetime ?? null)
87}
88
89// The line leads with the repository's folder name: the main working tree's for a
90// worktree, the project root's outside a repository.
91async function nameRepo($: EngineInterface): Promise<void> {
92 const found = await $.session.repo()
93 const name = repoName(found?.root ?? (await $.session.root()))
94 if (name !== (await read($, repo))) await update($, repo, () => name)
95}
96
97async function takeUsage($: EngineInterface, next: Usage, isFirst: boolean): Promise<void> {
98 await update($, usage, () => next)
99 for (const limit of next.limits) {
100 const step = limitStep(limit.percentUsed)
101 const before = limitSteps.get(limit.kind)
102 limitSteps.set(limit.kind, step)
103 if (!isFirst && before !== undefined && step > before) {
104 $.ui.toast(`Usage limit ${limit.kind.replace(/_/g, ' ')} reached ${Math.round(limit.percentUsed)}%`, { timeoutMs: 8000 })
105 }
106 }
107}
108
109async function refreshAgents($: EngineInterface): Promise<void> {
110 for (const agent of await $.agent.list()) {
111 if (agent.status !== 'running' && !tracked.has(agent.id)) continue
112 tracked.set(agent.id, { id: agent.id, label: agentLabel(agent.type), status: agent.status, tokens: agentTokens.get(agent.id) ?? null })
113 }
114 const rows = [...tracked.values()]
115 if (JSON.stringify(rows) !== JSON.stringify(await read($, agents))) await update($, agents, () => rows)
116}
117
118// What the line shows before the first turn: the repository and the account's limits.
119async function seed($: EngineInterface): Promise<void> {
120 await nameRepo($).catch(() => undefined)
121 try {
122 const measured = await $.session.usage()
123 await takeUsage($, { ...measured.context, limits: measured.rateLimits }, true)
124 } catch {
125 // No figures yet; session.measure brings them.
126 }
127}
128
129// The session that took an ended one's place: nothing of the old one's work carries
130// over, the line's place does.
131async function reseed($: EngineInterface): Promise<void> {
132 if (endedSession === null || (await $.session.id()) === endedSession) return
133 endedSession = null
134 isTurnRunning = false
135 tracked.clear()
136 agentTokens.clear()
137 if (keptPlace !== (await read($, place))) await update($, place, () => keptPlace)
138 await seed($)
139}
140
141async function tick($: EngineInterface): Promise<void> {
142 await reseed($)
143 const at = await $.clock.now()
144 ticks += 1
145 const hasRunning = [...tracked.values()].some(agent => agent.status === 'running')
146 if ((isTurnRunning || hasRunning) && ticks % 2 === 0) await refreshAgents($).catch(() => undefined)
147 const limits = (await read($, usage))?.limits ?? []
148 const state = cacheState(await read($, main), at, await read($, recorded), limits.length > 0)
149 const isCounting = !isTurnRunning && state.kind === 'left' && state.leftMs < SECONDS_BELOW_MS
150 const bucket = Math.floor(at / 30_000)
151 if (isCounting || bucket !== shownBucket) {
152 shownBucket = bucket
153 await update($, now, () => at)
154 }
155}
156
157// The line, and under it the agents row while there is one; `hint` leads when the line stands in the hint's place.
158async function draw(
159 $: EngineInterface,
160 e: Parameters<EngineInterface['ui']['resolve']>[0],
161 columns: number,
162 isWorking: boolean,
163 hint: string | null,
164) {
165 const reading = await read($, main)
166 const view = {
167 main: reading,
168 usage: await read($, usage),
169 recorded: await read($, recorded),
170 progress: await read($, progress),
171 requests: await read($, requests),
172 repo: await read($, repo),
173 now: Math.max(await read($, now), reading?.at ?? 0),
174 isWorking,
175 columns,
176 }
177 const rows = [line(view), agentsLine(await read($, agents), columns)].filter(row => row.length > 0)
178 const { Box, Text } = $.ui.resolve(e)
179
180 // Above the prompt the line stands clear of the transcript and lines up with its text.
181 const isBand = hint === null
182
183 return (
184 <Box flexDirection="column" paddingLeft={isBand ? 2 : 0} paddingTop={isBand ? 1 : 0}>
185 {hint === null ? null : (
186 <Text dimColor wrap="truncate-end">
187 {hint}
188 </Text>
189 )}
190 {rows.map(row => (
191 <Text wrap="truncate-end">
192 {row.map((section, index) => (
193 <Text>
194 {index > 0 ? <Text dimColor> │ </Text> : null}
195 {section.map(part => (
196 <Text color={COLOR[part.style]} dimColor={part.style === 'dim'} bold={part.style === 'strong'}>
197 {part.text}
198 </Text>
199 ))}
200 </Text>
201 ))}
202 </Text>
203 ))}
204 </Box>
205 )
206}
207
208export const register: Register = on => {
209 on('session.start', async ($, e, next) => {
210 await $.command.register({ name: 'pomegr-hud', description: 'Move the Pomegr line: above the prompt, below it, hidden' })
211 await seed($)
212 $.clock.every(1000, () => void tick($).catch(() => undefined))
213
214 return next(e)
215 })
216
217 on('session.end', async ($, e, next) => {
218 keptPlace = await read($, place).catch(() => keptPlace)
219 endedSession = e.sessionId
220
221 return next(e)
222 })
223
224 on('command.run', { command: 'pomegr-hud' }, async $ => {
225 const chosen = NEXT_PLACE[await read($, place)] ?? 'above'
226 await update($, place, () => chosen)
227
228 return { text: `Pomegr HUD: ${chosen}.` }
229 })
230
231 on('prompt.submit', ($, e, next) => {
232 // Only the person's own prompt starts new work: a task notification or a scheduled
233 // prompt belongs to the work under way and leaves its agents and estimate on the line.
234 if (OWN_PROMPTS.includes(e.origin.kind)) {
235 for (const [id, agent] of tracked) {
236 if (agent.status !== 'running') tracked.delete(id)
237 }
238 void update($, agents, () => [...tracked.values()])
239 void update($, progress, current => (current?.phase === 'complete' ? null : current))
240 }
241
242 return next(e)
243 })
244
245 // Agent-reported progress, read off the Pomegr plugin's own MCP tool calls.
246 on('tool.call', async ($, e, next) => {
247 const tool = String(e.tool)
248 if (e.agentId === undefined && tool.includes('pomegr')) {
249 const reported = e as unknown as { percent?: unknown; phase?: unknown; remaining_minutes_min?: unknown; remaining_minutes_max?: unknown }
250 if (tool.endsWith('__clear_session_progress')) {
251 await update($, progress, () => null)
252 } else if (tool.endsWith('__report_session_progress') && typeof reported.percent === 'number' && typeof reported.phase === 'string') {
253 const { percent, phase, remaining_minutes_min: low, remaining_minutes_max: high } = reported
254 const hasEta = typeof low === 'number' && typeof high === 'number'
255 await update($, progress, () => ({
256 percent: Math.min(100, Math.max(0, Math.round(percent))),
257 phase: phase.slice(0, 16),
258 etaMin: hasEta ? Math.round(low) : null,
259 etaMax: hasEta ? Math.round(high) : null,
260 }))
261 }
262 }
263
264 return next(e)
265 })
266
267 on('session.measure', async ($, e, next) => {
268 await takeUsage($, { tokens: e.context.tokens, window: e.context.window, percent: e.context.percent, limits: e.rateLimits }, false)
269
270 return next(e)
271 })
272
273 on('turn.start', ($, e, next) => {
274 isTurnRunning = true
275
276 return next(e)
277 })
278
279 on('turn.step', async function* ($, e, next) {
280 const result = yield* next(e)
281 if (result.usage === null) return result
282 try {
283 const { input_tokens, output_tokens, cache_read_input_tokens: cacheRead, cache_creation_input_tokens: written, model } = result.usage
284 const prompt = input_tokens + cacheRead + written
285 if (e.agentId !== undefined) {
286 agentTokens.set(e.agentId, prompt)
287 await refreshAgents($)
288 } else {
289 const reading: Reading = { at: await $.clock.now(), prompt, read: cacheRead, written, model }
290 const found = detectRefill(await read($, main), reading)
291 await update($, main, () => reading)
292 await update($, requests, list => [...list, input_tokens + written + output_tokens].slice(-REQUESTS_KEPT))
293 if (found !== null) {
294 $.ui.toast(`Cache rewritten: ${tokens(found.written)} tokens written after ${span(found.idleMs)} idle`, { timeoutMs: 8000 })
295 }
296 }
297 } catch {
298 // The line keeps its last figures.
299 }
300
301 return result
302 })
303
304 on('turn.complete', async ($, e, next) => {
305 if (e.agentId === undefined) {
306 isTurnRunning = false
307 void probe($).catch(() => undefined)
308 // A `/cd` or a worktree move may have changed the project.
309 void nameRepo($).catch(() => undefined)
310 }
311
312 return next(e)
313 })
314
315 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
316 if (e.props.hasSurvey || (await read($, place)) !== 'above') return next(e)
317
318 return draw($, e, e.props.bodyColumns - 2, e.props.isWorking, null)
319 })
320
321 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
322 if ((await read($, place)) !== 'below') return next(e)
323
324 return draw($, e, (e.viewport?.columns ?? 80) - 2, e.props.isWorking, `${e.props.hint}${e.props.tail ?? ''}`)
325 })
326}
327hooks/hud.ts 279 lines1import type { AgentRow, Limit, Progress, Reading, Recorded, Refill, Usage } from '../types'
2
3export type CacheState =
4 | { kind: 'none' }
5 | { kind: 'minimum'; idleMs: number }
6 | { kind: 'left'; label: string; leftMs: number; isAssumed: boolean }
7 | { kind: 'elapsed'; label: string; sinceMs: number; isAssumed: boolean }
8
9export type View = {
10 main: Reading | null
11 usage: Usage | null
12 recorded: Recorded | null
13 progress: Progress | null
14 requests: number[]
15 /** The repository folder's name, or null outside one. */
16 repo: string | null
17 now: number
18 isWorking: boolean
19 columns: number
20}
21
22const MINUTE = 60_000
23const LIFETIME_MS = { '5m': 5 * MINUTE, '1h': 60 * MINUTE } as const
24
25/** Below this prompt size a cold cache is not worth a warning. */
26export const NOTABLE_PROMPT = 20_000
27/** Under this much lifetime left the countdown shows seconds. */
28export const SECONDS_BELOW_MS = 10 * MINUTE
29/** How many recent requests the bars keep: a miniature of Pomegr's Requests chart. */
30export const REQUESTS_KEPT = 5
31/** How long a probe that found no running monitor waits before the next. */
32export const PROBE_RETRY_MS = 5 * MINUTE
33
34/** Where the probes of Pomegr's own MCP server stand. */
35export type Link = { isLinked: boolean; isRefused: boolean; probedAt: number }
36
37/**
38 * Whether to probe now. A refusal ends probing: asking again only repeats it.
39 * A monitor that was not running is asked again after a while, a linked one
40 * every time, since the lifetime it records can change.
41 */
42export function shouldProbe(link: Link, now: number): boolean {
43 if (link.isRefused) return false
44 return link.isLinked || link.probedAt === 0 || now - link.probedAt >= PROBE_RETRY_MS
45}
46
47/** An MCP tool's name as permission rules spell it: `plugin:pomegr:pomegr` as `mcp__plugin_pomegr_pomegr__<tool>`. */
48export function toolName(server: string, tool: string): string {
49 return `mcp__${server.replace(/[^a-zA-Z0-9_-]/g, '_')}__${tool}`
50}
51
52/**
53 * Where the main agent's cache lifetime stands. A lifetime Pomegr recorded is
54 * used as is; without one the lifetime is assumed (1h on a subscription, 5m
55 * otherwise) and marked so. Elapsed is an inference, never proof of a drop.
56 */
57export function cacheState(
58 main: Reading | null,
59 now: number,
60 recorded: Recorded | null,
61 hasSubscription: boolean,
62): CacheState {
63 if (main === null) return { kind: 'none' }
64 const idleMs = Math.max(0, now - main.at)
65 if (recorded === '30m+') return { kind: 'minimum', idleMs }
66 const lifetime = recorded === '5m' || recorded === '1h' ? recorded : recorded === 'mixed' ? '5m' : hasSubscription ? '1h' : '5m'
67 const isAssumed = recorded === null
68 const label = recorded === 'mixed' ? 'mixed' : isAssumed ? `~${lifetime}` : lifetime
69 const leftMs = LIFETIME_MS[lifetime] - idleMs
70 return leftMs > 0
71 ? { kind: 'left', label, leftMs, isAssumed }
72 : { kind: 'elapsed', label, sinceMs: -leftMs, isAssumed }
73}
74
75/** How long before the lifetime ends it counts as nearing. */
76export function warnBeforeMs(state: CacheState): number {
77 return state.kind === 'left' && state.label.endsWith('1h') ? 5 * MINUTE : MINUTE
78}
79
80/**
81 * A cautious refill: the same model read most of its prompt from cache before,
82 * and now wrote most of a prompt that did not shrink. Anything else is not one.
83 */
84export function detectRefill(previous: Reading | null, current: Reading): Refill | null {
85 if (previous === null || previous.model !== current.model) return null
86 if (current.prompt < NOTABLE_PROMPT || previous.prompt <= 0) return null
87 if (current.prompt < previous.prompt * 0.7) return null
88 const wasRead = previous.read / previous.prompt >= 0.5
89 const isRewritten = current.read / current.prompt < 0.2 && current.written / current.prompt >= 0.5
90 return wasRead && isRewritten
91 ? { at: current.at, written: current.written, idleMs: Math.max(0, current.at - previous.at) }
92 : null
93}
94
95export function tokens(count: number): string {
96 if (count >= 1_000_000) return `${Number((count / 1_000_000).toFixed(1))}M`
97 if (count >= 1_000) return `${Math.round(count / 1_000)}k`
98 return String(count)
99}
100
101/** `4:12` under ten minutes, then `52m`, `2h30`, and whole days alone: `3d`. */
102export function span(ms: number): string {
103 const minutes = Math.floor(ms / MINUTE)
104 const pad = (count: number) => String(count).padStart(2, '0')
105 if (minutes >= 24 * 60) return `${Math.floor(minutes / 1440)}d`
106 if (minutes >= 60) return `${Math.floor(minutes / 60)}h${pad(minutes % 60)}`
107 if (ms >= SECONDS_BELOW_MS) return `${minutes}m`
108 return `${minutes}:${pad(Math.floor(ms / 1000) % 60)}`
109}
110
111/** Minutes only: for a figure that redraws every half minute. */
112export function coarse(ms: number): string {
113 return ms < MINUTE ? '<1m' : ms < 60 * MINUTE ? `${Math.floor(ms / MINUTE)}m` : span(ms)
114}
115
116export type Style = 'plain' | 'strong' | 'dim' | 'brand' | 'accent' | 'ok' | 'warning' | 'error'
117export type Part = { text: string; style: Style }
118/** One stretch of the line between two dividers. */
119export type Section = Part[]
120
121const BAR_CELLS = 8
122// Braille, both columns: a bar the cell's full width, so only the cell spacing parts it from the next, in four heights.
123const SPARKS = '⣀⣤⣶⣿'
124
125function level(percent: number, warnAt: number, errorAt: number, calm: Style): Style {
126 return percent >= errorAt ? 'error' : percent >= warnAt ? 'warning' : calm
127}
128
129/** A thin bar: the filled cells in `style`, the rest dim. */
130export function meter(fraction: number, style: Style): Part[] {
131 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * BAR_CELLS)
132 const parts: Part[] = [
133 { text: '━'.repeat(filled), style },
134 { text: '━'.repeat(BAR_CELLS - filled), style: 'dim' },
135 ]
136 return parts.filter(part => part.text !== '')
137}
138
139/**
140 * One bar per recent request, its height that request's own fresh tokens
141 * against the largest shown, the newest in accent. Each bar stands alone:
142 * nothing is summed or carried between requests.
143 */
144export function requestsSection(requests: number[]): Section | null {
145 if (requests.length < 2) return null
146 const top = Math.max(...requests, 1)
147 const bars = requests.map(value => SPARKS[Math.min(SPARKS.length - 1, Math.floor((value / top) * (SPARKS.length - 1)))] ?? '⣀')
148 return [
149 { text: 'requests ', style: 'plain' },
150 { text: bars.slice(0, -1).join(''), style: 'dim' },
151 { text: bars.at(-1) ?? '', style: 'accent' },
152 ]
153}
154
155// The same shape as a limit: label, bar, percent, then the size against the window.
156function contextSection(view: View): Section | null {
157 const filled = view.usage?.tokens ?? view.main?.prompt
158 if (filled === undefined || filled <= 0) return null
159 const window = view.usage?.window ?? 0
160 if (window <= 0) return [{ text: 'context ', style: 'plain' }, { text: tokens(filled), style: 'strong' }]
161 const percent = view.usage?.percent ?? Math.round((filled / window) * 100)
162 return [
163 { text: 'context ', style: 'plain' },
164 ...meter(filled / window, level(percent, 75, 90, 'ok')),
165 { text: ` ${percent}%`, style: 'strong' },
166 { text: ` · ${tokens(filled)}/${tokens(window)}`, style: 'dim' },
167 ]
168}
169
170function eta(progress: Progress): string {
171 const { etaMin, etaMax } = progress
172 if (etaMin == null || etaMax == null) return ''
173 const minutes = (count: number) => (count >= 60 ? span(count * MINUTE) : `${count}m`)
174 return etaMin === etaMax ? ` · ~${minutes(etaMax)}` : ` · ${minutes(etaMin)}–${minutes(etaMax)}`
175}
176
177// The agent's own estimate: the phase, a bar, the percent and the time it expects is left.
178function progressSection(progress: Progress): Section {
179 const style: Style = progress.phase === 'blocked' ? 'warning' : progress.phase === 'complete' ? 'ok' : 'accent'
180 return [
181 { text: `${progress.phase} `, style: 'plain' },
182 ...meter(progress.percent / 100, style),
183 { text: ` ${progress.percent}%`, style: 'strong' },
184 { text: eta(progress), style: 'dim' },
185 ].filter(part => part.text !== '') as Section
186}
187
188// Silent while the cache is warm: a section only as the lifetime runs out, and after.
189function cacheSection(view: View, hasSubscription: boolean): Section | null {
190 if (view.isWorking) return null
191 const state = cacheState(view.main, view.now, view.recorded, hasSubscription)
192 if (state.kind === 'elapsed') return [{ text: `cache ${state.label} elapsed ${coarse(state.sinceMs)} ago`, style: 'error' }]
193 if (state.kind === 'left' && state.leftMs < warnBeforeMs(state)) {
194 return [{ text: `cache ${state.label} ends in ${span(state.leftMs)}`, style: 'warning' }]
195 }
196 return null
197}
198
199function limitSection(limit: Limit, now: number): Section {
200 const label = limit.kind === 'five_hour' ? '5h' : limit.kind === 'seven_day' ? '7d' : limit.kind.replace(/_/g, ' ')
201 const resetsIn = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt) - now
202 return [
203 { text: `${label} `, style: 'plain' },
204 ...meter(limit.percentUsed / 100, level(limit.percentUsed, 80, 95, 'ok')),
205 { text: ` ${Math.round(limit.percentUsed)}%`, style: 'strong' },
206 ...(resetsIn > 0 ? [{ text: ` · ${span(resetsIn)}`, style: 'dim' as const }] : []),
207 ]
208}
209
210function widthOf(section: Section): number {
211 return section.reduce((sum, part) => sum + part.text.length, 0)
212}
213
214// Each section spends its text and a three-cell divider.
215function fit(list: Section[], columns: number): { shown: Section[]; dropped: number } {
216 let width = 0
217 const shown: Section[] = []
218 for (const section of list) {
219 width += widthOf(section) + 3
220 if (width > columns && shown.length > 0) break
221 shown.push(section)
222 }
223 return { shown, dropped: list.length - shown.length }
224}
225
226const REPO_NAME_CELLS = 24
227
228/** `C:\repos\shop-api` as `shop-api`: the folder's own name, kept short; null when the path has none. */
229export function repoName(path: string): string | null {
230 const name = path.split(/[\\/]/).filter(segment => segment !== '').at(-1)
231 if (name === undefined || /^[a-zA-Z]:$/.test(name)) return null
232 return name.length > REPO_NAME_CELLS ? `${name.slice(0, REPO_NAME_CELLS - 1)}…` : name
233}
234
235/** The line's sections in order; a narrow line drops the tail. */
236export function line(view: View): Section[] {
237 // The mark alone outside a repository: the line never spends cells on its own name.
238 const head: Section = view.repo === null ? [{ text: '◆', style: 'brand' }] : [{ text: '◆ ', style: 'brand' }, { text: view.repo, style: 'plain' }]
239 const list: Section[] = [head]
240 const context = contextSection(view)
241 if (context !== null) list.push(context)
242 const requests = requestsSection(view.requests)
243 if (requests !== null) list.push(requests)
244 if (view.progress !== null) list.push(progressSection(view.progress))
245
246 const limits = view.usage?.limits ?? []
247 const cache = cacheSection(view, limits.length > 0)
248 if (cache !== null) list.push(cache)
249 for (const limit of limits) list.push(limitSection(limit, view.now))
250
251 return fit(list, view.columns).shown
252}
253
254/** `caveman:cavecrew-investigator` as `cavecrew-invest…`: the type's own name, kept short. */
255export function agentLabel(type: string): string {
256 const name = type.slice(type.lastIndexOf(':') + 1)
257 return name.length > 16 ? `${name.slice(0, 15)}…` : name
258}
259
260function agentSection(agent: AgentRow): Section {
261 const size: Part[] = agent.tokens === null ? [] : [{ text: ` ${tokens(agent.tokens)}`, style: 'dim' }]
262 if (agent.status === 'completed') return [{ text: agent.label, style: 'dim' }, ...size, { text: ' ✓', style: 'ok' }]
263 if (agent.status === 'failed' || agent.status === 'killed') return [{ text: agent.label, style: 'dim' }, ...size, { text: ' ✗', style: 'error' }]
264 return [{ text: '● ', style: 'accent' }, { text: agent.label, style: 'plain' }, ...size]
265}
266
267/**
268 * The row under the line while subagents run: a count, then one section per
269 * agent with its latest prompt size. Empty when there is none to show.
270 */
271export function agentsLine(agents: AgentRow[], columns: number): Section[] {
272 if (agents.length === 0) return []
273 const running = agents.filter(agent => agent.status === 'running').length
274 const count = running === 0 ? 'agents done' : `${running} agent${running === 1 ? '' : 's'} running`
275 const head: Section = [{ text: '╰ ', style: 'dim' }, { text: count, style: running === 0 ? 'dim' : 'plain' }]
276 const { shown, dropped } = fit([head, ...agents.map(agentSection)], columns - 6)
277 return dropped === 0 ? shown : [...shown, [{ text: `+${dropped}`, style: 'dim' }]]
278}
279types/index.d.ts 53 lines1/** The main agent's latest model request, as the engine reported its usage. */
2export type Reading = {
3 /** When the response arrived, in `$.clock.now()` milliseconds. */
4 at: number
5 /** Uncached input + cache read + cache write tokens of that request. */
6 prompt: number
7 read: number
8 written: number
9 model: string
10}
11
12export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
13
14export type Usage = {
15 tokens?: number
16 window: number
17 percent?: number
18 limits: Limit[]
19}
20
21/** The cache lifetime Pomegr recorded for the main agent, when it is running. */
22export type Recorded = '5m' | '1h' | 'mixed' | '30m+'
23
24/** A request that rewrote most of the prompt cache after reading it before. */
25export type Refill = { at: number; written: number; idleMs: number }
26
27/** A subagent of this session: its short label, the engine's status, its latest prompt size. */
28export type AgentRow = { id: string; label: string; status: string; tokens: number | null }
29
30/** The session progress the agent last reported through Pomegr's MCP tool. */
31export type Progress = { percent: number; phase: string; etaMin: number | null; etaMax: number | null }
32
33/** Where the line draws: its own row above the prompt, under the hint below it, or nowhere. */
34export type Place = 'above' | 'below' | 'hidden'
35
36declare module 'claude-code' {
37 interface PluginState {
38 pomegr: {
39 main: Reading | null
40 usage: Usage | null
41 recorded: Recorded | null
42 now: number
43 agents: AgentRow[]
44 progress: Progress | null
45 /** Fresh tokens of each recent main-agent request (cache write + uncached input + output), oldest first. */
46 requests: number[]
47 place: Place
48 /** The repository folder's name the line leads with, or null when there is none. */
49 repo: string | null
50 }
51 }
52}
53