SLOPSHOPPER

Pomegr

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

newbandspinnerguardcommandtoast
★ 3v0.8.3AGPL-3.0-onlyupdated 2026-10-05Lecarvalho/Pomegr/plugins/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pomegr
› 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 › /pomegr-hud ⎿ pomegr: Pomegr HUD: below. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ◆ app │ context ━━━━━━━━ 49% · 97k/200k │ 5h ━━━━━━━━ 31%

Draws

Prompt hint
? for shortcuts ◆ app │ context ━━━━━━━━ 49% · 97k/200k │ 5h ━━━━━━━━ 31%
README

Pomegr for Claude Code

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.

Get started

  1. Install and run Pomegr on the same computer as Claude Code.
  2. Install this plugin and reload plugins.
  3. Run /pomegr:init in a repository to review and create its reporting policy.
  4. Run /pomegr:doctor for a read-only setup check.

Session line

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.

Distribution notes

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.

Source 3 files
hooks/register.tsx 327 lines
1import { 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}
327
hooks/hud.ts 279 lines
1import 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}
279
types/index.d.ts 53 lines
1/** 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