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…

繁體中文版: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.
${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.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.
git clone https://github.com/mangow314/mango-mods
claude --plugin-dir ./mango-mods/xv-band
xv process died without updating meta.json stays running on the band; the mod does not check run_pid.stat per job), so a new job shows within 2 seconds; with hundreds of old job directories, prune them.hooks/register.tsx 268 lines1import { 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}
268types/index.d.ts 26 lines1// 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