SLOPSHOPPER

git-poller

Example: a git status band showing the branch and the changed and untracked counts, refreshed by a poller that follows the house conventions

newbandcommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-poller
› fix the failing auth test and add an audit log call ╭────────────────╮ │ git-poller │ ⏺ Read(src/auth.ts) │ git-poller off │ ⎿ 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 › /git-poller ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

git-poller

What it shows

A band above the prompt with the working-tree state: the branch, the number of changed files and the number of untracked files, plus a Refresh button (hotkey r). A background poller keeps it current by running git -C <cwd> status --porcelain=v1 --branch through $.process.run (an argument list, no shell), where <cwd> is the cwd of the session.start event. Without a usable cwd git does not run and the snapshot is marked stale.

It is a teaching example for a poller: one timer started once in session.start, refreshes that join, a minimum gap after turn.complete, main loop only, and a last good snapshot kept on error (the band then adds (stale)).

Settings

Constants exported from hooks/logic.ts:

ConstantValueMeaning
MOD_NAME'git-poller'The command name and the toast prefix
ENABLED_KEY'git-poller.enabled'The $.store key holding the switch; a missing key means on
USAGE`'usage: /git-poller [on\off]'`The answer to an unknown argument
POLL_INTERVAL_MS15000The timer refreshes every 15 seconds
MIN_GAP_MS5000A turn.complete refresh waits until 5 seconds have passed since the last refresh started
GIT_STATUS_ARGS['status', '--porcelain=v1', '--branch']What git is asked. gitArgv(cwd) returns ['git', '-C', cwd, ...GIT_STATUS_ARGS], the argument list given to $.process.run, so git runs in the session's directory

session.start starts the first refresh without waiting for it, so a slow git never delays the session or the command registration. turn.complete does not wait for git either: it starts the refresh and returns at once. Outside a git repository the band never appears.

Commands

CommandDoes
/git-pollerToggles the band
/git-poller onTurns it on
/git-poller offTurns it off
/git-poller <anything else>Answers `usage: /git-poller [on\off]`

Each change shows a toast, git-poller on or git-poller off. The switch is the store key git-poller.enabled; a missing key means on. The band reads the switch from the view atom, which session.start (and every poll) and the command fill from the store. While off, the command still answers, session.start still starts the timer and registers the command, and turn.complete and ui.render do nothing (the timer itself still runs and does no work, so on needs no restart).

KeyDoes
rRefresh now (a letter, not a digit: a digit hotkey on a band button also fires from an empty prompt)

House conventions it follows

From skills/mods-tutorial-quickstart/references/house-conventions.md:

  • An on/off switch, kept in $.store.
  • Keyboard first: the button has a letter hotkey.
  • Pure logic in hooks/logic.ts, thin hooks/register.ts.
  • Pollers: all eight rules (timer once in session.start, joined refreshes, minimum gap, main loop only, last good snapshot on error, $.process.run, redraw through $.state, timer tested with the kit).
  • Mutation-checked guard tests.

Tests

Run claude plugin test in this folder. tests/logic.test.ts covers the pure functions, tests/register.test.ts the wiring with the kit mock clock, and tests/ui.test.tsx mounts the band with the full AbovePrompt prop set and presses the button. Git, the store and the clock are all stubbed; no test runs real git.

Unverified

  • hooks/register.ts: the validator printed command=? when the command.run matcher was a variable, so the matcher is a string literal. Whether a variable would still work at run time was not tested.
  • hooks/register.ts: that session.start always carries cwd on every surface is shown only by the testing chapter's example.
  • hooks/register.ts: with no cwd on session.start, or after /branch into another directory (session.start does not fire after /clear, /resume or /branch: mods-doc-mods-reference chapter.md:136), the band stays empty or shows the old directory.
  • hooks/logic.ts: the ## No commits yet on and ## Initial commit on headers (newer and older git) come from git, not from a chapter; the tests feed them as text.
  • tests/helpers.ts: that clock.settle() also lets the mod's pending promises finish after session.start is observed, not documented.
  • Layout, colours and the key on your own terminal were not looked at: only the drawn tree is tested.

Remove it

Delete this folder. To clear the saved switch, run /git-poller on before removing it, or delete the git-poller.enabled key from the plugin store.

Licence

Written for this plugin; MIT, see LICENSE in this folder (a copy of the repository LICENSE). The ../LICENSE one level up covers the BeLazy167 examples, not this one.

Source 3 files
hooks/register.ts 186 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { View } from '../types/index.js'
5import { ENABLED_KEY, MIN_GAP_MS, MOD_NAME, POLL_INTERVAL_MS, USAGE, gitArgv, isDue, parseStatus, parseSwitch } from './logic.js'
6
7// The values this band draws are kept in $.state atoms, so a hot reload keeps them and the readers redraw.
8// (mods-doc-mods-interfaces chapter.md:740-748 compares module variables, $.state and $.store;
9// the atom, read and update calls are shown at :789).
10// enabled is the off switch copied from the store, so ui.render reads only this atom and never the store.
11// Writers of enabled: command.run, and the session.start kick (once), which writes only if no command ran
12// while its store read was pending (switchGen). Nothing else writes it, so a poll that read the store
13// before a /git-poller off cannot write a stale value back over it.
14const view = atom({ plugin: 'git-poller', key: 'view' } as const, { snapshot: null, stale: false, enabled: true } as View)
15
16// Counts the /git-poller commands handled. startPolling compares it before and after its store read.
17let switchGen = 0
18
19// Module variables: timers stop when the module reloads (mods-doc-mods-api chapter.md:231) and
20// module variables reset on a reload (mods-doc-mods-interfaces chapter.md:740-748), so they stay in step.
21// The session's directory, from session.start (its event carries cwd: mods-doc-mods-testing chapter.md:138).
22let cwd: string | undefined
23let inflight: Promise<void> | null = null
24let lastStartedAt: number | null = null
25// The timer handle (cancel() per mods-doc-mods-api chapter.md:196). null until session.start starts it.
26let timer: ReturnType<EngineInterface['clock']['every']> | null = null
27
28// The store holds the off switch. A missing key means on, so only an explicit false turns the mod off.
29// $.store.get(key) resolves to the value or undefined (mods-doc-mods-interfaces chapter.md:744).
30async function isEnabled($: EngineInterface): Promise<boolean> {
31  return (await $.store.get(ENABLED_KEY)) !== false
32}
33
34async function runGit($: EngineInterface): Promise<void> {
35  // On any failure keep the last good snapshot and only mark it stale: blanking the band
36  // would hide a transient error as if the repository had vanished.
37  try {
38    // No cwd (an event without one): do not run git in whatever directory the engine runs in, which
39    // could report another repository's branch. Keep the last snapshot and mark it stale.
40    if (typeof cwd !== 'string' || cwd === '') {
41      await update($, view, (v) => ({ ...v, stale: true }))
42      return
43    }
44    // git -C <cwd> runs git in the session's directory (see gitArgv). The rejection on start
45    // failure or timeout is documented at mods-doc-mods-api chapter.md:290.
46    const out = await $.process.run(gitArgv(cwd))
47    const snapshot = out.exitCode === 0 ? parseStatus(out.stdout) : null
48    if (snapshot === null) await update($, view, (v) => ({ ...v, stale: true }))
49    else await update($, view, (v) => ({ ...v, snapshot, stale: false }))
50  } catch {
51    await update($, view, (v) => ({ ...v, stale: true }))
52  }
53}
54
55// A refresh that starts while another runs joins it. inflight is assigned synchronously,
56// before any await, so two callers in the same tick cannot both start git.
57// $.clock.now() resolves to the time (mods-doc-mods-testing chapter.md:216).
58function refresh($: EngineInterface): Promise<void> {
59  if (inflight !== null) return inflight
60  inflight = (async () => {
61    lastStartedAt = await $.clock.now()
62    await runGit($)
63  })().finally(() => {
64    inflight = null
65  })
66  return inflight
67}
68
69// Refreshes when the store switch is on. It only reads the switch and never writes the view atom:
70// the atom is written by command.run and the session.start kick alone (see the comment on `view`).
71// runGit already handles git failures; this guards what is left (a rejected store read or clock
72// call) so a background caller never raises an unhandled rejection. The next tick simply tries again.
73async function pollIfEnabled($: EngineInterface): Promise<void> {
74  try {
75    if (await isEnabled($)) await refresh($)
76  } catch {
77    // Nothing to report to: the timer, turn.complete and the session.start kick have no caller to throw to.
78  }
79}
80
81// The session.start kick: copies the store switch into the view atom once (a restart or reload
82// leaves the atom at its default, so the band would otherwise ignore a stored off), then polls.
83async function startPolling($: EngineInterface): Promise<void> {
84  try {
85    // A /git-poller command that runs while the store read below is held back is newer than that read:
86    // its switch must win, so the generation taken before the read gates the write and the refresh.
87    const gen = switchGen
88    const enabled = await isEnabled($)
89    if (gen !== switchGen) return
90    await update($, view, (v) => (v.enabled === enabled ? v : { ...v, enabled }))
91    // A command that ran between the update and the refresh must not get a stale poll (it would cost one wasted git call; the band stays hidden either way). No test opens this window.
92    if (enabled && gen === switchGen) await refresh($)
93  } catch {
94    // Same as pollIfEnabled: no caller to throw to.
95  }
96}
97
98export const register: Register = (on) => {
99  on('session.start', async ($, e, next) => {
100    // The session's directory for every git call (the event carries cwd: mods-doc-mods-testing chapter.md:138).
101    cwd = e.cwd
102    // Started once. It also runs while the mod is off, doing nothing, so that /git-poller on
103    // needs no restart. The callback captures `$` the way the countdown example does
104    // (mods-doc-mods-testing chapter.md:224-227, "Test a timer").
105    // session.start fires again after a reload of the mod (mods-doc-mods-reference chapter.md:136),
106    // and a reload resets module variables, so the null `timer` after a reload restarts it.
107    if (timer === null) {
108      timer = $.clock.every(POLL_INTERVAL_MS, async () => {
109        await pollIfEnabled($)
110      })
111    }
112    // The first refresh is not awaited: a slow git or store must not delay the session start
113    // or skip the command registration below (mods-doc-mods-api chapter.md:208 does the same
114    // with its timer: "Return without waiting for the timer, so the session starts right away").
115    // It also copies the store switch into the view atom.
116    void startPolling($)
117    // Register the command last, as the chapter says (mods-doc-mods-api chapter.md:49): a taken
118    // name throws, and a hook that throws is skipped, so nothing after this call would run.
119    // The call and its fields: mods-doc-mods-api chapter.md:34.
120    await $.command.register({ name: MOD_NAME, description: 'Switch the git working-tree band on or off', argumentHint: '[on|off]' })
121    // next(e) passes the event on unchanged (mods-doc-mods-reference chapter.md:69; session.start row at :136).
122    return next(e)
123  })
124
125  on('turn.complete', async ($, e, next) => {
126    // A subagent's turn fires turn.complete with agentId set: poll for the main loop only
127    // (mods-doc-mods-and-events chapter.md:253, mods-doc-mods-reference chapter.md:85).
128    // The refresh is not awaited: turn.complete must not wait for git, so a slow git cannot delay
129    // the end of the turn. pollIfEnabled catches its own errors, so nothing is left unhandled.
130    if (e.agentId === undefined && isDue(await $.clock.now(), lastStartedAt, MIN_GAP_MS)) void pollIfEnabled($)
131    // next(e) passes the event on unchanged (mods-doc-mods-reference chapter.md:69; turn.complete row at :126).
132    return next(e)
133  })
134
135  // A string literal, not MOD_NAME: with a variable, claude plugin validate printed command=? for this hook.
136  // e.args is the text typed after the command name, or an empty string (mods-doc-mods-api chapter.md:40).
137  on('command.run', { command: 'git-poller' }, async ($, e) => {
138    const action = parseSwitch(e.args, await isEnabled($))
139    if (action.kind === 'usage') return { text: USAGE }
140    // Taken before the store write and the atom update below (a usage answer changes nothing, so it
141    // does not count): a startPolling store read pending across this point sees the counter move
142    // and drops its answer.
143    switchGen++
144    // $.store.set(key, value) takes any JSON value (mods-doc-mods-interfaces chapter.md:744).
145    await $.store.set(ENABLED_KEY, action.enabled)
146    // The band reads the switch from the view atom, so write it here too (the other writer is the start kick).
147    await update($, view, (v) => ({ ...v, enabled: action.enabled }))
148    // $.ui.toast shows a notification with the mod's name (mods-doc-mods-api chapter.md:222).
149    await $.ui.toast(MOD_NAME + (action.enabled ? ' on' : ' off'))
150    // Not awaited: switching on must not wait for git. pollIfEnabled catches its own errors.
151    if (action.enabled) void pollIfEnabled($)
152    // No $.ui.invalidate call: a ui.render hook that read an atom runs again each time the atom is
153    // written (mods-doc-mods-interfaces chapter.md:798), and the update above writes the atom ui.render reads.
154    // {} is an allowed command.run answer (mods-doc-mods-reference chapter.md:113).
155    return {}
156  })
157
158  // The { component: 'AbovePrompt' } matcher draws in the band above the prompt (mods-doc-mods-interfaces chapter.md:216;
159  // AbovePrompt in the render sites table: mods-doc-mods-reference chapter.md:231). A hook returns a tree, or next(e)
160  // to show nothing (mods-doc-mods-interfaces chapter.md:214).
161  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162    // Read the state first: the read subscribes this site to later writes. It is the only read here.
163    const now = await read($, view)
164    if (!now.enabled || now.snapshot === null) return next(e)
165    // $.ui.resolve(e) returns the elements (mods-doc-mods-interfaces chapter.md:361). Box, Text and Button props, with
166    // dimColor and color on Text: mods-doc-mods-reference chapter.md:260-262 and mods-doc-mods-interfaces chapter.md:436.
167    const { Box, Text, Button } = $.ui.resolve(e)
168    const { branch, changed, untracked } = now.snapshot
169    return Box({
170      children: [
171        Text({ dimColor: true, children: 'git: ' }),
172        Text({ children: branch }),
173        changed > 0 ? Text({ color: 'yellow', children: ' · ' + changed + ' changed' }) : '',
174        untracked > 0 ? Text({ color: 'cyan', children: ' · ' + untracked + ' untracked' }) : '',
175        now.stale ? Text({ dimColor: true, children: ' (stale)' }) : '',
176        Text({ children: ' ' }),
177        // Letter hotkey: a digit on a band button also fires from an empty prompt.
178        // plain: true drops the brackets and shows the hotkey (mods-doc-mods-interfaces chapter.md:398).
179        // The Button props key, label, onPress and hotkey: mods-doc-mods-reference chapter.md:262
180        // and the example at mods-doc-mods-interfaces chapter.md:402.
181        Button({ key: 'refresh', label: 'Refresh', hotkey: 'r', plain: true, onPress: async () => { await refresh($) } }),
182      ],
183    })
184  })
185}
186
hooks/logic.ts 59 lines
1import type { Snapshot } from '../types/index.js'
2
3export const MOD_NAME = 'git-poller'
4// Store key for the off switch. A missing key means on.
5export const ENABLED_KEY = 'git-poller.enabled'
6export const USAGE = 'usage: /git-poller [on|off]'
7// How often the timer refreshes the snapshot.
8export const POLL_INTERVAL_MS = 15_000
9// Minimum time between two refreshes started by turn.complete.
10export const MIN_GAP_MS = 5_000
11// What git is asked, after `git -C <cwd>`.
12export const GIT_STATUS_ARGS = ['status', '--porcelain=v1', '--branch'] as const
13
14/** The argument list for $.process.run: git runs in the session's directory, not wherever the engine runs. */
15export function gitArgv(cwd: string): string[] {
16  return ['git', '-C', cwd, ...GIT_STATUS_ARGS]
17}
18
19/**
20 * Parses the output of `git status --porcelain=v1 --branch`.
21 * Returns null when the first line is not a branch header (not a repo, or not git output).
22 */
23export function parseStatus(stdout: string): Snapshot | null {
24  const lines = stdout.split('\n').filter(Boolean)
25  const header = lines[0]
26  if (header === undefined || !header.startsWith('## ')) return null
27  const text = header.slice(3)
28  let branch: string
29  // Older git prints "Initial commit on <branch>" where newer git prints "No commits yet on <branch>".
30  // UNVERIFIED: both spellings come from git itself, not from a chapter; the tests feed them as text.
31  if (text.startsWith('No commits yet on ')) branch = text.slice('No commits yet on '.length)
32  else if (text.startsWith('Initial commit on ')) branch = text.slice('Initial commit on '.length)
33  else if (text.startsWith('HEAD (no branch)')) branch = '(detached)'
34  else branch = text.split('...')[0]!.split(' [')[0]!
35  let changed = 0
36  let untracked = 0
37  for (const line of lines.slice(1)) {
38    if (line.startsWith('?? ')) untracked += 1
39    else changed += 1
40  }
41  return { branch, changed, untracked }
42}
43
44export type SwitchAction = { kind: 'set'; enabled: boolean } | { kind: 'usage' }
45
46/** Reads the argument of /git-poller: empty toggles, on and off set, anything else is usage. */
47export function parseSwitch(args: string, current: boolean): SwitchAction {
48  const word = args.trim().toLowerCase()
49  if (word === '') return { kind: 'set', enabled: !current }
50  if (word === 'on') return { kind: 'set', enabled: true }
51  if (word === 'off') return { kind: 'set', enabled: false }
52  return { kind: 'usage' }
53}
54
55/** True when no refresh has started yet, or the last one started at least gapMs ago. */
56export function isDue(now: number, lastStartedAt: number | null, gapMs: number): boolean {
57  return lastStartedAt === null || now - lastStartedAt >= gapMs
58}
59
types/index.d.ts 11 lines
1export type Snapshot = { branch: string; changed: number; untracked: number }
2
3/** What the band draws: the last good snapshot, whether a later refresh failed, and the off switch (copied from the store so ui.render never reads the store). */
4export type View = { snapshot: Snapshot | null; stale: boolean; enabled: boolean }
5
6declare module 'claude-code' {
7  interface PluginState {
8    'git-poller': { view: View }
9  }
10}
11