PTR mods: a /runs pane for training runs (log tail, PID, GPU) and a git/docs status line entry.

Claude Code mod (function hooks module with live UI) for the PTR repository. Early-access API; requires a Claude Code version with hooks modules (tested with 2.1.289).
/runs opens the pane PTR Runs:training/runs/logs/*.log (name, mtime, last 15 lines);<name>.pid is alive (kill -0 through $.process; "unknown" where kill is unavailable, e.g. Windows);nvidia-smi (no NVIDIA GPU / nvidia-smi not found on failure);r);make smoke-gpu, see docs/DESKTOP_SETUP.md.ptr <branch> · <n> changed, plus · docs? when a crates/<c>/src/** or crates/<c>/Cargo.toml change has no matching crates/<c>/component.toml change (the docs-as-code rule in CLAUDE.md). Updated on session start and after each Edit/Write/MultiEdit/Bash call (after the call has run; it never blocks or alters it). Cleared outside a git repo.All process and file calls are wrapped; failures are shown as text and never thrown out of a hook.
claude --plugin-dir .claude/plugins/ptr-mods
or add this repo's marketplace (if one lists ./.claude/plugins/ptr-mods) and install ptr-mods. Saving a file reloads the module in a watched session.
claude plugin validate .claude/plugins/ptr-mods
claude plugin test .claude/plugins/ptr-mods
Pure logic (status text, porcelain parsing, GPU csv, log picking) lives in hooks/lib.ts and is covered by tests/*.test.ts. Type-check with a tsconfig kept outside the folder (see the header of the engine's claude-code.d.ts).
hooks/register.tsx 243 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { PtrModsRuns } from '../types'
5import {
6 EMPTY_RUNS_TEXT,
7 LOG_DIR,
8 buildStatusText,
9 errText,
10 formatTime,
11 gpuLineFromRun,
12 parsePid,
13 pickNewestLog,
14 pidLine,
15 tailLines,
16} from './lib'
17
18type Engine = EngineInterface
19
20const PANE = 'ptr-runs'
21const REFRESH_MS = 30_000
22const runs = atom({ plugin: 'ptr-mods', key: 'runs' } as const, null)
23
24// ---------- /runs data ----------
25
26async function pidAlive(
27 $: Engine,
28 pid: number,
29): Promise<boolean | undefined> {
30 try {
31 const r = await $.process.run(['kill', '-0', String(pid)], { timeoutMs: 5000 })
32 if (r.exitCode === 0) return true
33 // EPERM means the process exists but belongs to someone else.
34 return /not permitted/i.test(r.stderr)
35 } catch {
36 return undefined // no `kill` (Windows) or cannot start: unknown
37 }
38}
39
40async function gpuLine($: Engine): Promise<string> {
41 try {
42 const r = await $.process.run(
43 [
44 'nvidia-smi',
45 '--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu',
46 '--format=csv,noheader,nounits',
47 ],
48 { timeoutMs: 8000 },
49 )
50 return gpuLineFromRun(r.exitCode, r.stdout)
51 } catch {
52 return gpuLineFromRun(undefined, '')
53 }
54}
55
56export async function collectRuns($: Engine): Promise<PtrModsRuns> {
57 const at = await $.clock.now()
58 const root = await $.session.root()
59 const dir = `${root.replace(/[\\/]$/, '')}/${LOG_DIR}`
60 const base: PtrModsRuns = {
61 at,
62 dir: LOG_DIR,
63 empty: null,
64 log: null,
65 pid: '',
66 gpu: await gpuLine($),
67 error: null,
68 }
69 try {
70 if (!(await $.fs.exists(dir))) return { ...base, empty: EMPTY_RUNS_TEXT }
71 const newest = pickNewestLog(await $.fs.list(dir))
72 if (newest === undefined) return { ...base, empty: EMPTY_RUNS_TEXT }
73
74 let tail: string[]
75 try {
76 tail = tailLines(await $.fs.read(`${dir}/${newest.name}`), 15)
77 } catch (e) {
78 tail = [`(cannot read log: ${errText(e)})`]
79 }
80
81 const pidPath = `${dir}/${newest.name.replace(/\.log$/, '')}.pid`
82 let pid: string
83 try {
84 if (await $.fs.exists(pidPath)) {
85 const n = parsePid(await $.fs.read(pidPath))
86 pid = pidLine(n, n === undefined ? undefined : await pidAlive($, n), true)
87 } else {
88 pid = pidLine(undefined, undefined, false)
89 }
90 } catch (e) {
91 pid = `PID: unknown (${errText(e)})`
92 }
93 return { ...base, log: { name: newest.name, mtimeMs: newest.mtimeMs, tail }, pid }
94 } catch (e) {
95 return { ...base, error: `refresh failed: ${errText(e)}` }
96 }
97}
98
99async function refreshRuns($: Engine): Promise<void> {
100 try {
101 const view = await collectRuns($)
102 await update($, runs, () => view)
103 } catch {
104 // never throw out of a hook
105 }
106}
107
108// ---------- status line ----------
109
110async function git($: Engine, argv: string[]): Promise<{ ok: boolean; out: string }> {
111 try {
112 const r = await $.process.run(['git', ...argv], { timeoutMs: 10_000 })
113 return { ok: r.exitCode === 0, out: r.stdout }
114 } catch {
115 return { ok: false, out: '' }
116 }
117}
118
119async function refreshStatus($: Engine): Promise<void> {
120 try {
121 const st = await git($, ['status', '--porcelain'])
122 if (!st.ok) {
123 $.ui.status(undefined) // not a git repo (or git missing)
124 return
125 }
126 const br = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])
127 $.ui.status(buildStatusText(st.out, br.ok ? br.out : '?'))
128 } catch {
129 try {
130 $.ui.status(undefined)
131 } catch {
132 // ignore
133 }
134 }
135}
136
137// ---------- refresh timer ----------
138
139// A reload drops the engine's timers and this variable together.
140let timer: { cancel: () => void } | undefined
141
142function stopTimer(): void {
143 timer?.cancel()
144 timer = undefined
145}
146
147async function tick($: Engine): Promise<void> {
148 try {
149 const open = (await $.ui.panes()).some(p => p.id === PANE)
150 if (!open) return stopTimer()
151 await refreshRuns($)
152 } catch {
153 // ignore
154 }
155}
156
157function startTimer($: Engine): void {
158 stopTimer()
159 timer = $.clock.every(REFRESH_MS, () => {
160 void tick($)
161 })
162}
163
164// ---------- module ----------
165
166export const register: Register = on => {
167 on('session.start', async ($, e, next) => {
168 try {
169 await $.command.register({
170 name: 'runs',
171 description: 'Show the newest PTR training run (log tail, PID, GPU) in a pane',
172 })
173 } catch {
174 // the status line still works without the command
175 }
176 const result = await next(e)
177 await refreshStatus($)
178 return result
179 })
180
181 on('command.run', { command: 'runs' }, async $ => {
182 try {
183 await refreshRuns($)
184 const opened = await $.ui.open({ id: PANE, title: 'PTR Runs' })
185 if (!opened.isPlaced) return { text: `PTR Runs pane not shown: ${opened.reason}` }
186 startTimer($)
187 return { text: 'PTR Runs pane opened (refreshes every 30 s).' }
188 } catch (e) {
189 return { text: `PTR Runs failed: ${errText(e)}` }
190 }
191 })
192
193 on('tool.call', { tool: /^(Edit|Write|MultiEdit|Bash)$/ }, async ($, e, next) => {
194 const ran = await next(e)
195 await refreshStatus($)
196 return ran
197 })
198
199 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
200 try {
201 const { Box, Button, Text } = $.ui.resolve(e)
202 const view = await read($, runs)
203 if (view === null) {
204 return (
205 <Box flexDirection="column">
206 <Text dimColor>Loading…</Text>
207 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refreshRuns($)} />
208 </Box>
209 )
210 }
211 return (
212 <Box flexDirection="column">
213 {view.empty !== null ? (
214 <Text>{view.empty}</Text>
215 ) : (
216 <Box flexDirection="column">
217 <Text bold>{view.log?.name ?? ''}</Text>
218 <Text dimColor>
219 {view.dir} · modified {view.log ? formatTime(view.log.mtimeMs) : '?'}
220 </Text>
221 <Text>{view.pid}</Text>
222 </Box>
223 )}
224 <Text>{view.gpu}</Text>
225 {view.error !== null && <Text color="red">{view.error}</Text>}
226 {view.log !== null && (
227 <Box flexDirection="column">
228 <Text dimColor>── last {view.log.tail.length} lines ──</Text>
229 {view.log.tail.map(line => (
230 <Text wrap="truncate-end">{line}</Text>
231 ))}
232 </Box>
233 )}
234 <Text dimColor>updated {formatTime(view.at)}</Text>
235 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refreshRuns($)} />
236 </Box>
237 )
238 } catch {
239 return next(e)
240 }
241 })
242}
243hooks/lib.ts 132 lines1// Pure helpers of ptr-mods: no `$`, so they are unit-tested directly.
2
3export const LOG_DIR = 'training/runs/logs'
4
5export const EMPTY_RUNS_TEXT =
6 'No runs yet — start one with make smoke-gpu, see docs/DESKTOP_SETUP.md'
7
8export const NO_GPU_TEXT = 'no NVIDIA GPU / nvidia-smi not found'
9
10// ---------- status line ----------
11
12/** Path of one `git status --porcelain` line (renames: the new path), unquoted. */
13export function porcelainPath(line: string): string {
14 let rest = line.slice(3)
15 const arrow = rest.indexOf(' -> ')
16 if (arrow >= 0 && (line[0] === 'R' || line[1] === 'R' || line[0] === 'C' || line[1] === 'C')) {
17 rest = rest.slice(arrow + 4)
18 }
19 if (rest.length >= 2 && rest.startsWith('"') && rest.endsWith('"')) {
20 rest = rest.slice(1, -1).replace(/\\(["\\])/g, '$1')
21 }
22 return rest
23}
24
25export function porcelainPaths(porcelain: string): string[] {
26 return porcelain
27 .split('\n')
28 .filter(line => line.length > 3)
29 .map(porcelainPath)
30}
31
32/** True when a crate's src/ or Cargo.toml changed but its component.toml did not. */
33export function needsDocs(paths: readonly string[]): boolean {
34 const touched = new Set<string>()
35 const documented = new Set<string>()
36 for (const p of paths) {
37 const m = /^crates\/([^/]+)\/(.*)$/.exec(p)
38 if (m === null) continue
39 const crate = m[1] as string
40 const rest = m[2] as string
41 if (rest === 'component.toml') documented.add(crate)
42 else if (rest === 'Cargo.toml' || rest === 'src' || rest.startsWith('src/')) touched.add(crate)
43 }
44 for (const crate of touched) if (!documented.has(crate)) return true
45 return false
46}
47
48/** `ptr <branch> · <n> changed` plus ` · docs?` when component.toml lags. */
49export function buildStatusText(porcelain: string, branch: string): string {
50 const paths = porcelainPaths(porcelain)
51 const name = branch.trim() === '' ? '?' : branch.trim()
52 return `ptr ${name} · ${paths.length} changed${needsDocs(paths) ? ' · docs?' : ''}`
53}
54
55// ---------- GPU ----------
56
57export type GpuInfo = { utilPct: number; usedMiB: number; totalMiB: number; tempC: number }
58
59/** Parses `--format=csv,noheader,nounits` rows; malformed rows are skipped. */
60export function parseGpuCsv(stdout: string): GpuInfo[] {
61 const gpus: GpuInfo[] = []
62 for (const line of stdout.split('\n')) {
63 if (line.trim() === '') continue
64 const f = line.split(',').map(s => Number(s.trim()))
65 if (f.length < 4 || f.slice(0, 4).some(n => !Number.isFinite(n))) continue
66 gpus.push({
67 utilPct: f[0] as number,
68 usedMiB: f[1] as number,
69 totalMiB: f[2] as number,
70 tempC: f[3] as number,
71 })
72 }
73 return gpus
74}
75
76export function formatGpuLine(gpus: readonly GpuInfo[]): string {
77 if (gpus.length === 0) return NO_GPU_TEXT
78 return gpus
79 .map(
80 (g, i) =>
81 `${gpus.length > 1 ? `GPU ${i}: ` : 'GPU: '}${g.utilPct}% util · ` +
82 `${g.usedMiB}/${g.totalMiB} MiB · ${g.tempC}°C`,
83 )
84 .join('\n')
85}
86
87/** GPU line from an nvidia-smi run; `exitCode` undefined = could not start. */
88export function gpuLineFromRun(exitCode: number | undefined, stdout: string): string {
89 if (exitCode !== 0) return NO_GPU_TEXT
90 return formatGpuLine(parseGpuCsv(stdout))
91}
92
93// ---------- logs and PID ----------
94
95export type LogEntry = { name: string; kind: string; mtimeMs: number }
96
97/** Newest `*.log` file by mtime (name breaks ties, descending). */
98export function pickNewestLog(entries: readonly LogEntry[]): LogEntry | undefined {
99 const logs = entries.filter(e => e.kind === 'file' && e.name.endsWith('.log'))
100 logs.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.name < b.name ? 1 : -1))
101 return logs[0]
102}
103
104export function tailLines(text: string, n: number): string[] {
105 const lines = text.replace(/\r\n/g, '\n').split('\n')
106 while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
107 return lines.slice(-n)
108}
109
110export function parsePid(text: string): number | undefined {
111 const m = /^\s*(\d+)\s*$/m.exec(text)
112 if (m === null) return undefined
113 const pid = Number(m[1])
114 return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined
115}
116
117export function pidLine(pid: number | undefined, alive: boolean | undefined, hasFile: boolean): string {
118 if (!hasFile) return 'PID: no .pid file next to the log'
119 if (pid === undefined) return 'PID: .pid file unreadable (unknown)'
120 if (alive === undefined) return `PID ${pid}: unknown`
121 return `PID ${pid}: ${alive ? 'alive' : 'not running'}`
122}
123
124export function errText(e: unknown): string {
125 if (e instanceof Error) return e.message
126 return String(e)
127}
128
129export function formatTime(ms: number): string {
130 return new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + 'Z'
131}
132types/index.d.ts 22 lines1export type PtrModsRuns = {
2 /** Epoch ms of the refresh that produced this view. */
3 at: number
4 /** Where the log directory was looked up (display only). */
5 dir: string
6 /** Empty-state text when there is no log, else null. */
7 empty: string | null
8 log: { name: string; mtimeMs: number; tail: string[] } | null
9 /** Text for the PID line, e.g. "pid 123: alive". */
10 pid: string
11 /** Text for the GPU line. */
12 gpu: string
13 /** A failure of the refresh itself, shown as text. */
14 error: string | null
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'ptr-mods': { runs: PtrModsRuns | null }
20 }
21}
22