SLOPSHOPPER

xv-band

A row above the prompt with live status of xvendor (xv) cross-vendor jobs: one line per running job or job finished in the last 10 minutes (vendor, round…

newbandprocesstimer
★ 1v0.1.1MITupdated 2026-10-13mangow314/mango-mods/xv-band
A shopper browsing a rack in a slop shop
README

xv-band

繁體中文版:README.zh-TW.md

A Claude Code mod that draws a band above the prompt with the live status of xv cross-vendor jobs (xvendor's codex exec / agy -p jobs).

Early access: useful only with xvendor, the author's cross-vendor review tool. The band's activity words are in Traditional Chinese.

What it does

  • Every 2 seconds it lists ${XV_DATA:-$HOME/.local/share/xvendor}/jobs/, stats each meta.json (re-reads it only when its mtime changed), and for a running job reads the tail of the current round's events.jsonl: the whole file when it is at most 16 KB, otherwise the last 16 KB through tail -c 16384 ($.fs.read has no offset). It never writes there.
  • One line per job that is running or finished within the last 10 minutes (by meta.json mtime), at most 5: every running job first (newest first), then at most 2 of the newest finished; the remaining finished jobs fold into one line. With no such job the band draws nothing.
  / codex        4553 r1  running 12m03s  執行 rg -n foo src
  - agy          d266 r2  running 45s     撰寫回答
    codex:local  c2bc r2  done    6s
    另有 3 個已完成(xv list)

<spinner> <vendor>[:<profile>] <id suffix> r<round> <status> <elapsed> <last activity>, columns aligned. The spinner (| / - \) turns on running lines only. Status is meta.json's own word (running / done / failed / timeout / stopped). The id suffix is the job id's last 4 characters; xv accepts it in place of the full id. Elapsed is now − round_started while running, secs once finished (- when missing). Last activity (running only, cut to the band's width): codex's latest command (執行 <cmd>, with a /bin/bash -lc '…' wrapper removed) or the first line of its latest agent message (回報 …); agy's latest tool (工具 <tool_name>) or 撰寫回答 while it streams a response.

  • Colors: vendor bold (codex green, agy blue), status yellow while running, red for failed / timeout, grey for stopped; finished-and-done lines and the last activity are dim.
  • A partial last line, a missing or corrupt job directory, or a failed read is skipped; the mod never throws. It steps aside for a survey and stacks under other mods' rows (ctx-relay, sitrep).

Try it

git clone https://github.com/mangow314/mango-mods
claude --plugin-dir ./mango-mods/xv-band

Known limits

  • A job whose xv process died without updating meta.json stays running on the band; the mod does not check run_pid.
  • The directory is scanned every 2 seconds even when nothing is running (one stat per job), so a new job shows within 2 seconds; with hundreds of old job directories, prune them.
  • Agent message text is shown as written (CJK counts as two columns when cutting); the band's own decorations are ASCII.
Source 2 files
hooks/register.tsx 268 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Band, Row } from '../types'
5
6// xv 的 job 目錄:${XV_DATA:-$HOME/.local/share/xvendor}/jobs/<job-id>/(唯讀,這個 mod 從不寫那裡)
7const TICK_MS = 2000
8// 結束後還留在 band 上的時間(看 meta.json 的 mtime)
9const RECENT_MS = 10 * 60_000
10const MAX_ROWS = 5
11// 結束的 job 最多畫幾行,其餘收成「另有 N 個已完成」
12const MAX_FINISHED = 2
13// 只讀 events.jsonl 最後這麼多 bytes
14const TAIL_BYTES = 16 * 1024
15
16const bandAtom = atom({ plugin: 'xv-band', key: 'band' } as const, { rows: [], more: 0 } as Band)
17
18const STATUS_COLOR: Record<string, string> = { running: 'warning', failed: 'error', timeout: 'error', stopped: 'inactive' }
19const VENDOR_COLOR: Record<string, string> = { codex: 'success', agy: 'suggestion' }
20const SPINNER = ['|', '/', '-', '\\']
21
22type Meta = {
23  vendor?: unknown
24  round?: unknown
25  status?: unknown
26  secs?: unknown
27  round_started?: unknown
28  profile?: unknown
29}
30
31// 模組變數:reload 會歸零,只是快取
32let timer: Timer | null = null
33let isBusy = false
34let lastJson = '[]'
35const metaCache = new Map<string, { mtimeMs: number; meta: Meta }>()
36const eventsCache = new Map<string, { size: number; mtimeMs: number; activity: string }>()
37
38export const register: Register = on => {
39  on('session.start', async ($, e, next) => {
40    const started = await next(e)
41    timer?.cancel()
42    const root = await dataRoot($)
43    $.clock.after(0, () => void refresh($, root))
44    timer = $.clock.every(TICK_MS, () => void refresh($, root))
45    return started
46  })
47
48  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
49    if (e.props.hasSurvey) return next(e)
50    const { rows, more } = await read($, bandAtom)
51    if (rows.length === 0 && more === 0) return next(e)
52    // band 只有一個:先讓排在下面的 mod 畫,自己疊在它下面
53    const below = await next(e)
54    const { Box, Text } = $.ui.resolve(e)
55    const columns = Math.max(20, e.props.bodyColumns)
56    const whoWidth = Math.max(0, ...rows.map(r => width(who(r))))
57    return (
58      <Box flexDirection="column">
59        {below}
60        {rows.map(row => {
61          const p = rowParts(row, columns, whoWidth)
62          return (
63            <Text key={`row-${row.id}`} dimColor={row.status === 'done'}>
64              {p.spin} <Text color={VENDOR_COLOR[row.vendor]} bold>{p.who}</Text>  {p.id}  <Text color={STATUS_COLOR[row.status]}>{p.status}</Text> {p.elapsed}
65              {p.activity ? <Text dimColor>  {p.activity}</Text> : null}
66            </Text>
67          )
68        })}
69        {more > 0 ? <Text key="more" dimColor>  另有 {more} 個已完成(xv list)</Text> : null}
70      </Box>
71    )
72  })
73}
74
75async function dataRoot($: EngineInterface): Promise<string> {
76  const custom = await $.env.get('XV_DATA')
77  if (custom) return custom.replace(/\/+$/, '')
78  const home = (await $.env.get('HOME')) ?? ''
79  return `${home}/.local/share/xvendor`
80}
81
82async function refresh($: EngineInterface, root: string): Promise<void> {
83  if (isBusy) return
84  isBusy = true
85  try {
86    const band = await scan($, root)
87    const json = JSON.stringify(band)
88    if (json === lastJson) return
89    lastJson = json
90    await update($, bandAtom, () => band)
91  } catch {
92    // 永不丟出:下一輪再試
93  } finally {
94    isBusy = false
95  }
96}
97
98async function scan($: EngineInterface, root: string): Promise<Band> {
99  const jobsDir = `${root}/jobs`
100  const entries = await $.fs.list(jobsDir).catch(() => [])
101  const now = await $.clock.now()
102  const found: { row: Row; mtimeMs: number }[] = []
103  for (const entry of entries) {
104    if (entry.kind !== 'dir') continue
105    const job = await jobRow($, `${jobsDir}/${entry.name}`, entry.name, now).catch(() => null)
106    if (job) found.push(job)
107  }
108  found.sort((a, b) => b.mtimeMs - a.mtimeMs)
109  const running = found.filter(f => f.row.status === 'running').slice(0, MAX_ROWS).map(f => f.row)
110  const finished = found.filter(f => f.row.status !== 'running').map(f => f.row)
111  const shown = finished.slice(0, Math.min(MAX_FINISHED, MAX_ROWS - running.length))
112  return { rows: [...running, ...shown], more: finished.length - shown.length }
113}
114
115async function jobRow($: EngineInterface, dir: string, id: string, now: number): Promise<{ row: Row; mtimeMs: number } | null> {
116  const metaPath = `${dir}/meta.json`
117  const st = await $.fs.stat(metaPath)
118  let cached = metaCache.get(id)
119  if (!cached || cached.mtimeMs !== st.mtimeMs) {
120    const parsed: unknown = JSON.parse(await $.fs.read(metaPath))
121    if (!parsed || typeof parsed !== 'object') return null
122    cached = { mtimeMs: st.mtimeMs, meta: parsed as Meta }
123    metaCache.set(id, cached)
124  }
125  const meta = cached.meta
126  const status = typeof meta.status === 'string' ? meta.status : ''
127  const round = typeof meta.round === 'number' ? meta.round : 0
128  const vendor = typeof meta.vendor === 'string' ? meta.vendor : '?'
129  if (!status) return null
130  const isRunning = status === 'running'
131  if (!isRunning && now - st.mtimeMs > RECENT_MS) return null
132  let elapsed: number | null = null
133  let activity = ''
134  if (isRunning) {
135    if (typeof meta.round_started === 'number') elapsed = Math.max(0, Math.floor(now / 1000 - meta.round_started))
136    activity = await lastActivity($, `${dir}/round-${round}/events.jsonl`, vendor).catch(() => '')
137  } else if (typeof meta.secs === 'number') {
138    elapsed = meta.secs
139  }
140  const profile = typeof meta.profile === 'string' && meta.profile ? meta.profile : null
141  return { row: { id, vendor, profile, round, status, elapsed, activity }, mtimeMs: st.mtimeMs }
142}
143
144async function lastActivity($: EngineInterface, path: string, vendor: string): Promise<string> {
145  const st = await $.fs.stat(path)
146  const cached = eventsCache.get(path)
147  if (cached && cached.size === st.size && cached.mtimeMs === st.mtimeMs) return cached.activity
148  let text: string
149  let isCut = false
150  if (st.size <= TAIL_BYTES) {
151    text = await $.fs.read(path)
152  } else {
153    // $.fs.read 沒有 offset:用 tail 只讀尾端,不把整個檔案搬進來
154    const out = await $.process.run(['tail', '-c', String(TAIL_BYTES), path], { timeoutMs: 5000 })
155    if (out.exitCode !== 0) return ''
156    text = out.stdout
157    isCut = true
158  }
159  const activity = activityOf(vendor, text, isCut)
160  eventsCache.set(path, { size: st.size, mtimeMs: st.mtimeMs, activity })
161  return activity
162}
163
164// 從尾端往回找最近一個有意義的事件;寫到一半的最後一行、讀尾端切斷的第一行都會 parse 失敗而略過
165export function activityOf(vendor: string, text: string, isCut: boolean): string {
166  const lines = text.split('\n')
167  if (isCut) lines.shift()
168  for (let i = lines.length - 1; i >= 0; i--) {
169    let ev: Record<string, unknown>
170    try {
171      const parsed: unknown = JSON.parse(lines[i] ?? '')
172      if (!parsed || typeof parsed !== 'object') continue
173      ev = parsed as Record<string, unknown>
174    } catch {
175      continue
176    }
177    if (vendor === 'agy') {
178      const step = ev.event === 'step_update' ? (ev.step_update as Record<string, unknown> | undefined) : undefined
179      if (!step || typeof step !== 'object') continue
180      if (step.step_type === 'tool' && typeof step.tool_name === 'string') return `工具 ${oneLine(step.tool_name)}`
181      if (step.step_type === 'agent_response') return '撰寫回答'
182      continue
183    }
184    const item = ev.item as Record<string, unknown> | undefined
185    if (!item || typeof item !== 'object') continue
186    if (item.type === 'command_execution' && typeof item.command === 'string') return `執行 ${unwrapShell(oneLine(item.command))}`
187    if (item.type === 'agent_message' && ev.type === 'item.completed' && typeof item.text === 'string') {
188      return `回報 ${oneLine(item.text.trim().split('\n')[0] ?? '')}`
189    }
190  }
191  return ''
192}
193
194function oneLine(s: string): string {
195  return s.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim()
196}
197
198export function formatElapsed(secs: number | null): string {
199  if (secs === null) return '-'
200  if (secs < 60) return `${secs}s`
201  const m = Math.floor(secs / 60)
202  const s = secs % 60
203  return `${m}m${String(s).padStart(2, '0')}s`
204}
205
206// codex 的指令包在 /bin/bash -lc '…' 裡,只留裡面那段
207function unwrapShell(s: string): string {
208  const m = /^\/bin\/(?:ba)?sh -lc (['"])(.*?)\1?$/.exec(s)
209  return m ? (m[2] ?? s) : s
210}
211
212function who(row: Row): string {
213  return row.profile ? `${row.vendor}:${row.profile}` : row.vendor
214}
215
216// 一行拆成幾段(各自上色);欄位對齊:廠商補到 whoWidth、狀態補到 7 欄、耗時補到 6 欄,
217// 最近動作吃剩下的寬度,放不下就不畫
218export function rowParts(row: Row, columns: number, whoWidth: number) {
219  const spin = row.status === 'running' ? (SPINNER[Math.floor((row.elapsed ?? 0) / 2) % SPINNER.length] ?? '|') : ' '
220  const parts = {
221    spin,
222    who: pad(who(row), whoWidth),
223    id: `${row.id.slice(-4)} r${row.round}`,
224    // meta.json 的狀態原字(running/done/failed/timeout/stopped),補到最長的 7 欄
225    status: pad(row.status, 7),
226    elapsed: pad(formatElapsed(row.elapsed), 6),
227    activity: '',
228  }
229  const used = width(`${parts.spin} ${parts.who}  ${parts.id}  ${parts.status} ${parts.elapsed}  `)
230  if (row.activity && columns - used >= 4) parts.activity = fit(row.activity, columns - used)
231  // 沒有動作接在後面時不補空白,行尾不留空格
232  if (!parts.activity) parts.elapsed = formatElapsed(row.elapsed)
233  return parts
234}
235
236function pad(s: string, w: number): string {
237  return s + ' '.repeat(Math.max(0, w - width(s)))
238}
239
240// 終端機欄寬:中日韓全形算 2 欄
241function charWidth(ch: string): number {
242  const c = ch.codePointAt(0) ?? 0
243  const isWide =
244    (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
245    (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) ||
246    (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1faff) || (c >= 0x20000 && c <= 0x3fffd)
247  return isWide ? 2 : 1
248}
249
250function width(s: string): number {
251  let w = 0
252  for (const ch of s) w += charWidth(ch)
253  return w
254}
255
256function fit(s: string, max: number): string {
257  if (width(s) <= max) return s
258  let out = ''
259  let w = 0
260  for (const ch of s) {
261    const cw = charWidth(ch)
262    if (w + cw > max - 3) break
263    out += ch
264    w += cw
265  }
266  return `${out}...`
267}
268
types/index.d.ts 26 lines
1// band 上一行對應的 xv job(每 2 秒掃一次 jobs/ 算出來)
2export type Row = {
3  // job id,例如 20261010T041615Z-codex-4553
4  id: string
5  vendor: string
6  profile: string | null
7  round: number
8  status: string
9  // 執行中=現在 - round_started;結束=secs;不知道=null
10  elapsed: number | null
11  // 執行中才有:最近一個動作,例如「執行 rg foo」「回報 …」「工具 view_file」;沒有=''
12  activity: string
13}
14
15// 要畫的東西:rows 執行中在前、再來是最新結束的(最多 2 筆),合計最多 5 筆;
16// more=10 分鐘內結束、但沒畫出來的 job 數(收成一行「另有 N 個已完成」)
17export type Band = { rows: Row[]; more: number }
18
19declare module 'claude-code' {
20  interface PluginState {
21    'xv-band': {
22      band: Band
23    }
24  }
25}
26