SLOPSHOPPER

cross-review-status

Band above the prompt showing the state of every cross-review review and consult this session has started

newbandguardstatustimer
A shopper browsing a rack in a slop shop
README

cross-review-status

A Claude Code mod that draws a band above the prompt showing the cross-review reviews and consults the session has started, one row each, and where each one stands:

cross-review  (running)   fix-autocrlf   t2  running 4m12s                    (x)
              (changes)   feat-x         t1  changes requested . 3 open
              (ok)        docs           t3  converged
              (failed)    other              RATE_LIMITED

Running jobs come first with their elapsed time (redrawn every 5s while one runs), then the three most recently finished. The close button at the right drops the finished rows. The band sits above whatever else is drawn there (another mod's band, the engine's own) rather than replacing it.

Glyph (drawn as)Meaning
U+27F3 cyanrunning
U+2713 greenconverged, or a consult that was answered
U+270E yellowchanges_requested, with the open-finding count
U+26A0 redescalate or rebaseline
U+2717 redthe job failed, with its code (RATE_LIMITED, SESSION_BUSY, ...)
U+2298 graycancelled

The glyphs are written as \uXXXX escapes in the source, so every file in this folder is plain ASCII.

It is not part of the cross-review binary and the server knows nothing about it. It only watches the calls the agent makes to the cross_model_* tools and reads their responses: the review_id: and session: lines of a start, and the machine envelope of a result (structuredContent, or the CROSS_REVIEW_ENVELOPE_OUT block in the text). It never changes a call or its result. Any MCP server name works; it matches on the tool name's cross_model_* suffix.

Using it

In this repository it loads by itself: Claude Code loads a plugin from a project's .claude/skills/<name>/ folder. To use it in another repository, copy this folder into that repository's .claude/skills/, or into ~/.claude/skills/ to have it everywhere. Nothing is built or installed.

The mod API is early access and may change between Claude Code releases. If the band stops appearing after an update, run claude plugin validate .claude/skills/cross-review-status.

Developing it

claude plugin validate .claude/skills/cross-review-status
claude plugin test .claude/skills/cross-review-status

The tests feed the hooks canned server responses through the engine; they call no model. Claude Code writes the API's type declarations into .claude-plugin/types/ when it loads the mod (gitignored), which tsconfig.json extends for an editor.

Source 2 files
hooks/register.tsx 303 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Review, ReviewKind } from '../types'
5
6const reviews = atom({ plugin: 'cross-review-status', key: 'reviews' } as const, {})
7// The time the band draws elapsed times against, advanced by a timer while a job runs.
8const clock = atom({ plugin: 'cross-review-status', key: 'now' } as const, 0)
9
10// Any server name: `.mcp.json` calls it `cross-review`, but a user may not.
11const TOOL = /^mcp__.+__cross_model_(review|consult)(_result|_cancel)?$/
12const ENVELOPE = /<<<CROSS_REVIEW_ENVELOPE_OUT:([^>\n]+)>>>\n([\s\S]*?)\n<<<CROSS_REVIEW_ENVELOPE_OUT_END:\1>>>/
13// Finished jobs kept in the band beside every running one.
14const FINISHED_SHOWN = 3
15const TICK_MS = 5_000
16
17type Record_ = Record<string, unknown>
18
19const field = (text: string, name: string): string | null =>
20  new RegExp(`^${name}:\\s+(.+)$`, 'm').exec(text)?.[1]?.trim() ?? null
21
22const asRecord = (v: unknown): Record_ | null =>
23  typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record_) : null
24
25/**
26 * The machine envelope: `structuredContent` when core hands it over, else the `_OUT` text block,
27 * else the text itself. When a tool declares an output schema, core gives the model (and so `text`)
28 * the structured result serialised as JSON in place of the text body, with no `_OUT` block in it.
29 */
30const envelopeOf = (result: unknown, text: string): Record_ | null => {
31  const structured = asRecord(asRecord(result)?.structuredContent)
32  if (structured) return structured
33  const json = ENVELOPE.exec(text)?.[2] ?? (text.trimStart().startsWith('{') ? text : undefined)
34  if (json === undefined) return null
35  try {
36    return asRecord(JSON.parse(json))
37  } catch {
38    return null
39  }
40}
41
42const num = (v: unknown): number | null => (typeof v === 'number' ? v : null)
43const str = (v: unknown): string | null => (typeof v === 'string' ? v : null)
44
45const elapsed = (ms: number): string => {
46  const s = Math.max(0, Math.round(ms / 1000))
47  if (s < 60) return `${s}s`
48  const m = Math.floor(s / 60)
49  if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
50  return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
51}
52
53type Look = { glyph: string; color: string; label: string }
54
55const look = (r: Review, now: number): Look => {
56  switch (r.status) {
57    case 'running':
58      return { glyph: '\u27F3', color: 'cyan', label: `running ${elapsed(now - r.startedAt)}` }
59    case 'cancelled':
60      return { glyph: '\u2298', color: 'gray', label: 'cancelled' }
61    case 'failed':
62      return { glyph: '\u2717', color: 'red', label: r.code ?? 'failed' }
63    case 'completed':
64      if (r.kind === 'consult' || r.outcome === null) return { glyph: '\u2713', color: 'green', label: 'answered' }
65      if (r.outcome === 'converged') return { glyph: '\u2713', color: 'green', label: 'converged' }
66      if (r.outcome === 'changes_requested') {
67        const open = r.openCount === null ? '' : ` \u00B7 ${r.openCount} open`
68        return { glyph: '\u270E', color: 'yellow', label: `changes requested${open}` }
69      }
70      return { glyph: '\u26A0', color: 'red', label: r.outcome.replace(/_/g, ' ') }
71  }
72}
73
74/** Running jobs, then the most recently finished ones. */
75const shown = (all: Review[]): Review[] => {
76  const running = all.filter(r => r.status === 'running').sort((a, b) => a.startedAt - b.startedAt)
77  const finished = all
78    .filter(r => r.status !== 'running')
79    .sort((a, b) => (b.finishedAt ?? b.startedAt) - (a.finishedAt ?? a.startedAt))
80  return [...running, ...finished.slice(0, FINISHED_SHOWN)]
81}
82
83const tick = async ($: EngineInterface) => {
84  const now = await $.clock.now()
85  await update($, clock, () => now)
86}
87
88// Advances `now` while a job runs, and only then: armed when one starts, cancelled once none is
89// left. A reload drops it with the old module; `session.start` arms it again if a job still runs.
90let ticker: Timer | null = null
91
92const pace = async ($: EngineInterface) => {
93  const isRunning = Object.values((await read($, reviews)) as Record<string, Review>).some(
94    r => r.status === 'running',
95  )
96  if (isRunning && ticker === null) {
97    ticker = $.clock.every(TICK_MS, () => void tick($).then(() => pace($)))
98  } else if (!isRunning && ticker !== null) {
99    ticker.cancel()
100    ticker = null
101  }
102}
103
104const put = async ($: EngineInterface, r: Review) => {
105  await update($, reviews, prev => ({ ...(prev as Record<string, Review>), [r.session]: r }))
106  await tick($)
107  await pace($)
108}
109
110const clearFinished = ($: EngineInterface) =>
111  update($, reviews, prev =>
112    Object.fromEntries(Object.entries(prev as Record<string, Review>).filter(([, r]) => r.status === 'running')),
113  )
114
115/**
116 * The job a call names, looked up as the server does (src/tools.rs): by `review_id` when one is
117 * given, and only by `session` when it is not.
118 */
119const target = async (
120  $: EngineInterface,
121  reviewId: string | null,
122  session: string | null,
123): Promise<Review | null> => {
124  const all = (await read($, reviews)) as Record<string, Review>
125  if (reviewId !== null) return Object.values(all).find(r => r.reviewId === reviewId) ?? null
126  return session === null ? null : (all[session] ?? null)
127}
128
129// The suffix the server prints after a session name on a result: `(turn 2)`, `(turn 1, new review)`.
130const RESULT_SESSION_SUFFIX = / \(turn \d+(?:, [a-z ]+)?\)$/
131
132export const register: Register = on => {
133  on('session.start', async ($, e, next) => {
134    // 0.1.0 pinned a status line, which outlives a reload of the module; take it down.
135    $.ui.status(undefined)
136    await tick($)
137    await pace($)
138    return next(e)
139  })
140
141  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
142    // Whatever the plugins beneath and the engine draw stays, below this plugin's rows.
143    const below = await next(e)
144    const rows = shown(Object.values((await read($, reviews)) as Record<string, Review>))
145    if (e.props.hasSurvey || rows.length === 0) return below
146
147    const now = await read($, clock)
148    const { Box, Button, Text } = $.ui.resolve(e)
149    const hasFinished = rows.some(r => r.status !== 'running')
150
151    return (
152      <Box flexDirection="column">
153        <Box flexDirection="row" gap={2}>
154          <Box flexShrink={0}>
155            <Text bold>cross-review</Text>
156          </Box>
157          <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0}>
158            {rows.map(r => {
159              const { glyph, color, label } = look(r, now)
160              return (
161                <Box key={`row:${r.session}`} flexDirection="row" gap={1}>
162                  <Box flexShrink={0}>
163                    <Text color={color}>{glyph}</Text>
164                  </Box>
165                  <Box flexShrink={1} minWidth={0}>
166                    <Text wrap="truncate-end">{r.kind === 'consult' ? `consult ${r.session}` : r.session}</Text>
167                  </Box>
168                  {r.turn === null ? null : (
169                    <Box flexShrink={0}>
170                      <Text dimColor>{`t${r.turn}`}</Text>
171                    </Box>
172                  )}
173                  <Box flexShrink={0}>
174                    <Text color={color}>{label}</Text>
175                  </Box>
176                </Box>
177              )
178            })}
179          </Box>
180          {hasFinished ? (
181            <Box flexShrink={0}>
182              {/* Drawn as the X other bands close with: the desktop does not yet draw role="dismiss" itself. */}
183              <Button key="clear" label={'\u2715'} plain dimColor role="dismiss" onPress={() => clearFinished($)} />
184            </Box>
185          ) : null}
186        </Box>
187        {below}
188      </Box>
189    )
190  })
191
192  on('tool.call', async ($, e, next) => {
193    const match = TOOL.exec(e.tool)
194    if (!match) return next(e)
195
196    const kind = match[1] as ReviewKind
197    const verb = match[2] ?? 'start'
198    const args = e as unknown as Record_
199    const ran = await next(e)
200    const text = ran.text ?? ''
201    const now = await $.clock.now()
202
203    if (verb === 'start') {
204      const session = field(text, 'session')
205      const reviewId = field(text, 'review_id')
206      const asked = str(args.session) ?? 'review'
207      if (ran.deny !== undefined || ran.isError || !reviewId || !session) {
208        // A refused start (SESSION_BUSY, most often) leaves the job already running untouched.
209        if ((await target($, null, asked))?.status === 'running') return ran
210        await put($, {
211          session: asked,
212          reviewId: reviewId ?? '',
213          kind,
214          turn: null,
215          status: 'failed',
216          outcome: null,
217          openCount: null,
218          code: field(text, 'code') ?? (ran.deny !== undefined ? 'denied' : null),
219          startedAt: now,
220          finishedAt: now,
221        })
222        return ran
223      }
224      // `session:   name (new)` or `session:   name (resumed, turn 3)`
225      const parsed = /^(.*) \((?:new|resumed, turn (\d+))\)$/.exec(session)
226      await put($, {
227        session: parsed?.[1] ?? asked,
228        reviewId,
229        kind,
230        turn: parsed?.[2] ? Number(parsed[2]) : 1,
231        status: 'running',
232        outcome: null,
233        openCount: null,
234        code: null,
235        startedAt: now,
236        finishedAt: null,
237      })
238      return ran
239    }
240
241    const known = await target($, str(args.review_id), str(args.session))
242    // Only an answered call carries an envelope; an error's structured body says nothing about a job.
243    const env = ran.deny === undefined && !ran.isError ? envelopeOf(ran.result, text) : null
244    // A bare-JSON result has no `review_id:` line and may not carry the id, so the call's own
245    // argument names the job it answered.
246    const reviewId =
247      field(text, 'review_id') ?? str(env?.review_id) ?? known?.reviewId ?? (env ? str(args.review_id) : null)
248    const session = field(text, 'session')?.replace(RESULT_SESSION_SUFFIX, '') ?? str(env?.session)
249    // A job this mod never saw is recorded only from a response that names it; an error for one
250    // (an unknown or evicted id) says nothing about any job the band shows.
251    if (!known && (ran.deny !== undefined || ran.isError || !reviewId || !session)) return ran
252    // A job started before this mod loaded: rebuild what we can from the response.
253    const base: Review = known ? { ...known, reviewId: reviewId ?? known.reviewId } : {
254      session: session ?? '',
255      reviewId: reviewId ?? '',
256      kind,
257      turn: null,
258      status: 'running',
259      outcome: null,
260      openCount: null,
261      code: null,
262      startedAt: now,
263      finishedAt: null,
264    }
265
266    if (verb === '_cancel') {
267      if (ran.deny === undefined && !ran.isError) {
268        await put($, { ...base, status: 'cancelled', finishedAt: now })
269      }
270      return ran
271    }
272
273    if (ran.deny !== undefined) return ran
274    if (ran.isError) {
275      await put($, { ...base, status: 'failed', code: field(text, 'code'), finishedAt: now })
276      return ran
277    }
278
279    const status = str(env?.result_status) ?? str(env?.status) ?? field(text, 'status')
280    const turn = num(env?.turn) ?? base.turn
281
282    if (status === 'running') {
283      const elapsed = num(env?.elapsed_seconds)
284      await put($, {
285        ...base,
286        turn,
287        status: 'running',
288        startedAt: elapsed === null ? base.startedAt : now - elapsed * 1000,
289      })
290    } else if (status === 'completed') {
291      await put($, {
292        ...base,
293        turn,
294        status: 'completed',
295        outcome: str(env?.outcome),
296        openCount: num(env?.open_count),
297        finishedAt: now,
298      })
299    }
300    return ran
301  })
302}
303
types/index.d.ts 26 lines
1export type ReviewKind = 'review' | 'consult'
2
3export type ReviewStatus = 'running' | 'completed' | 'failed' | 'cancelled'
4
5/** The latest job seen for one cross-review session name. */
6export type Review = {
7  session: string
8  reviewId: string
9  kind: ReviewKind
10  turn: number | null
11  status: ReviewStatus
12  /** The envelope's `outcome` (converged, changes_requested, escalate, rebaseline). */
13  outcome: string | null
14  openCount: number | null
15  /** The failure code (RATE_LIMITED, SESSION_NOT_RESUMABLE, ...) when the job failed. */
16  code: string | null
17  startedAt: number
18  finishedAt: number | null
19}
20
21declare module 'claude-code' {
22  interface PluginState {
23    'cross-review-status': { reviews: Record<string, Review>; now: number }
24  }
25}
26