SLOPSHOPPER

ptr-mods

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

newpaneguardcommandstatusprocess
★ 1v0.1.0NOASSERTIONupdated 2026-10-05xrey167/PTR/.claude/plugins/ptr-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ptr-mods
│ ┃ PTR Runs ✕ › fix the failing auth test and add an audit log call │ ┃ No runs yet — start one with make smoke-gpu, │ ┃ see docs/DESKTOP_SETUP.md ⏺ Read(src/auth.ts) │ ┃ no NVIDIA GPU / nvidia-smi not found ⎿ Read 6 lines │ ┃ updated 2025-10-09 08:53:20Z ⏺ Update(src/auth.ts) │ ┃ [ Refresh ] ⎿ 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 │ │ › /runs │ ⎿ ptr-mods: PTR Runs pane opened (refreshes every 30 s). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ptr-mods: ptr feat/auth-refresh · 2 changed

Draws

Pane · PTR Runs
No runs yet — start one with make smoke-gpu, see docs/DESKTOP_SETUP.md no NVIDIA GPU / nvidia-smi not found updated 2025-10-09 08:53:20Z [ Refresh ]
README

ptr-mods

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).

What it does

  • /runs opens the pane PTR Runs:
  • newest training/runs/logs/*.log (name, mtime, last 15 lines);
  • whether the PID in the sibling <name>.pid is alive (kill -0 through $.process; "unknown" where kill is unavailable, e.g. Windows);
  • a GPU line from nvidia-smi (no NVIDIA GPU / nvidia-smi not found on failure);
  • refreshes every 30 s while the pane is open, plus a Refresh button (r);
  • empty state when there is no log directory: start one with make smoke-gpu, see docs/DESKTOP_SETUP.md.
  • Status line: 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.

Load

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.

Develop

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).

Source 3 files
hooks/register.tsx 243 lines
1import { 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}
243
hooks/lib.ts 132 lines
1// 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}
132
types/index.d.ts 22 lines
1export 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