Read-only harness issue, budget, context, and nightly status

Claude Code 2.1.289 mod showing read-only harness status in the status line, AbovePrompt band, and /hstatus.
.claude/issue-db/registry.json from the session working directory on session start and after main turns when its modification time changes. Shows the latest IN_PROGRESS issue and count.opus_budget_state.daily.cost_usd and daily.limit_usd. The current registry has no daily limit field, so the limit appears as —. The recorded budget date appears in the detailed view to avoid presenting an old daily record as today's spending.$.session.usage().context.percent when available.~/Library/Logs/insuregraph-night/ on session start and every 30 minutes. An unavailable log is omitted.Unknown or unreadable values appear as —; no information is written back to either source.
hooks/register.tsx 139 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3import type { HarnessBudget, HarnessIssue, HarnessNight, HarnessSnapshot } from '../types/index'
4
5const snapshot = atom({ plugin: 'harness-status', key: 'snapshot' } as const, {
6 issueCount: null, latestIssue: null, budget: null, registryMtimeMs: null,
7 night: null, contextPercent: null,
8} as HarnessSnapshot)
9
10function object(value: unknown): Record<string, unknown> | null {
11 return value !== null && typeof value === 'object' && !Array.isArray(value)
12 ? value as Record<string, unknown> : null
13}
14
15function finite(value: unknown): number | null {
16 return typeof value === 'number' && Number.isFinite(value) ? value : null
17}
18
19export function parseRegistry(raw: string): Pick<HarnessSnapshot, 'issueCount' | 'latestIssue' | 'budget'> {
20 const data = object(JSON.parse(raw))
21 if (!data) return { issueCount: null, latestIssue: null, budget: null }
22 const issues = Array.isArray(data.issues) ? data.issues : null
23 const active: HarnessIssue[] = (issues ?? []).flatMap(value => {
24 const issue = object(value)
25 if (!issue || issue.status !== 'IN_PROGRESS' || typeof issue.id !== 'string') return []
26 return [{ id: issue.id, title: typeof issue.title === 'string' ? issue.title : '—',
27 updatedAt: typeof issue.updated_at === 'string' ? issue.updated_at :
28 typeof issue.created_at === 'string' ? issue.created_at : '' }]
29 })
30 active.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
31 const budgetState = object(data.opus_budget_state)
32 const daily = object(budgetState?.daily)
33 const budget: HarnessBudget | null = budgetState ? {
34 date: typeof daily?.date === 'string' ? daily.date : null,
35 costUsd: finite(daily?.cost_usd),
36 limitUsd: finite(daily?.limit_usd),
37 } : null
38 return { issueCount: issues ? active.length : null, latestIssue: active[0] ?? null, budget }
39}
40
41async function refreshRegistry($: EngineInterface, force = false): Promise<void> {
42 const path = `${await $.session.cwd()}/.claude/issue-db/registry.json`
43 try {
44 const stat = await $.fs.stat(path)
45 const old = await read($, snapshot)
46 if (!force && stat.mtimeMs === old.registryMtimeMs) return
47 const parsed = parseRegistry(await $.fs.read(path))
48 await update($, snapshot, old => ({ ...old, ...parsed, registryMtimeMs: stat.mtimeMs }))
49 } catch {
50 await update($, snapshot, old => ({ ...old, issueCount: null, latestIssue: null,
51 budget: null, registryMtimeMs: null }))
52 }
53}
54
55export function parseNight(date: string, raw: string): HarnessNight | null {
56 const last = raw.trimEnd().split(/\r?\n/).at(-1) ?? ''
57 const time = last.match(/\b(\d{2}:\d{2})(?::\d{2})?\b/)?.[1]
58 const result = /실패|에러|오류|failed|error/i.test(last) ? '실패'
59 : /완료|성공|completed|success/i.test(last) ? '완료'
60 : /진행|실행 중|시작|running|started/i.test(last) ? '진행' : null
61 return time && result ? { date, time, result } : null
62}
63
64async function refreshNight($: EngineInterface): Promise<void> {
65 let night: HarnessNight | null = null
66 try {
67 const home = await $.env.get('HOME')
68 if (home) {
69 const dir = `${home}/Library/Logs/insuregraph-night`
70 const dates = (await $.fs.list(dir)).filter(entry => entry.kind === 'file' &&
71 /^\d{4}-\d{2}-\d{2}\.log$/.test(entry.name)).sort((a, b) => b.name.localeCompare(a.name))
72 if (dates[0]) night = parseNight(dates[0].name.slice(0, 10), await $.fs.read(`${dir}/${dates[0].name}`))
73 }
74 } catch { /* An absent or unreadable log has no status. */ }
75 await update($, snapshot, old => ({ ...old, night }))
76}
77
78async function refreshContext($: EngineInterface): Promise<void> {
79 let contextPercent: number | null = null
80 try { contextPercent = finite((await $.session.usage()).context.percent) } catch { /* unavailable */ }
81 await update($, snapshot, old => ({ ...old, contextPercent }))
82}
83
84function money(value: number | null): string { return value === null ? '—' : `$${value}` }
85function budgetText(value: HarnessBudget | null): string {
86 return `예산${value?.date ? `(${value.date})` : ''} ${money(value?.costUsd ?? null)}/${money(value?.limitUsd ?? null)}`
87}
88function shortText(s: HarnessSnapshot): string {
89 const parts = [s.latestIssue?.id ?? '이슈 —', budgetText(s.budget)]
90 if (s.contextPercent !== null) parts.push(`컨텍스트 ${Math.round(s.contextPercent)}%`)
91 if (s.night) parts.push(`야간 ${s.night.time} ${s.night.result}`)
92 return parts.join(' · ')
93}
94export function detailText(s: HarnessSnapshot): string {
95 const issue = s.latestIssue
96 ? `${s.latestIssue.id} ${s.latestIssue.title.slice(0, 20)}${s.issueCount !== null && s.issueCount > 1 ? ` (진행 중 ${s.issueCount}건)` : ''}`
97 : s.issueCount === 0 ? '진행 중 이슈 없음' : '진행 중 이슈 —'
98 const parts = [issue, budgetText(s.budget)]
99 if (s.contextPercent !== null) parts.push(`컨텍스트 ${Math.round(s.contextPercent)}%`)
100 if (s.night) parts.push(`야간 ${s.night.date} ${s.night.time} ${s.night.result}`)
101 return parts.join(' · ')
102}
103
104export const register: Register = on => {
105 on('session.start', async ($, e, next) => {
106 await $.command.register({ name: 'hstatus', description: '하네스 상태 표시' })
107 await refreshRegistry($, true)
108 await refreshNight($)
109 await refreshContext($)
110 $.ui.status(shortText(await read($, snapshot)))
111 $.clock.every(30 * 60 * 1000, async () => {
112 await refreshNight($)
113 $.ui.status(shortText(await read($, snapshot)))
114 })
115 return next(e)
116 })
117
118 on('turn.complete', async ($, e, next) => {
119 if (e.agentId === undefined) {
120 await refreshRegistry($)
121 await refreshContext($)
122 $.ui.status(shortText(await read($, snapshot)))
123 }
124 return next(e)
125 })
126
127 on('command.run', { command: 'hstatus' }, async $ => {
128 return { text: detailText(await read($, snapshot)) }
129 })
130
131 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
132 const s = await read($, snapshot)
133 if (e.props.hasSurvey || !s.latestIssue) return next(e)
134 const { Box, Text } = $.ui.resolve(e)
135 const line = e.props.bodyColumns < 80 ? shortText(s) : detailText(s)
136 return <Box><Text wrap="truncate-end">{line}</Text></Box>
137 })
138}
139types/index.d.ts 18 lines1export type HarnessIssue = { id: string; title: string; updatedAt: string }
2export type HarnessBudget = { date: string | null; costUsd: number | null; limitUsd: number | null }
3export type HarnessNight = { date: string; time: string; result: '완료' | '실패' | '진행' }
4export type HarnessSnapshot = {
5 issueCount: number | null
6 latestIssue: HarnessIssue | null
7 budget: HarnessBudget | null
8 registryMtimeMs: number | null
9 night: HarnessNight | null
10 contextPercent: number | null
11}
12
13declare module 'claude-code' {
14 interface PluginState {
15 'harness-status': { snapshot: HarnessSnapshot }
16 }
17}
18