SLOPSHOPPER

harness-status

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

newbandcommandstatustimer
v0.1.0no licenseupdated 2026-10-04myaji35/GH_Harness/mods/harness-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · harness-status
› 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 › /hstatus ⎿ harness-status: 진행 중 이슈 — · 예산 —/— · 컨텍스트 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ harness-status: 이슈 — · 예산 —/— · 컨텍스트 49%
README

harness-status

Claude Code 2.1.289 mod showing read-only harness status in the status line, AbovePrompt band, and /hstatus.

  • Reads .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.
  • Reads 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.
  • Reads $.session.usage().context.percent when available.
  • Reads the last line of the newest dated log under ~/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.

Source 2 files
hooks/register.tsx 139 lines
1import { 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}
139
types/index.d.ts 18 lines
1export 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