SLOPSHOPPER

peek

The spinner says what the turn is waiting on and for how long; a turn past 30 minutes ends with its time and token breakdown.

newspinnerguardprompttimer
v0.1.0MITupdated 2026-10-03henderson-tech/vybava/mods/peek
A shopper browsing a rack in a slop shop
README

Výbava

Výbava is FixIt Technologies' portable engineering environment: small tools, agent skills, and workstation diagnostics, distributed as one catalog where every item installs independently.

Packages

IDKindWhat it does
memorylintappletValidate and maintain AI memory homes — schema, indexes, wikilinks, fixtures, write hooks. → docs/memorylint.md
claude-guardsappletPreToolUse guard hooks for Claude Code — destructive git/docker, secret dumps, host-input automation, commit secrets, whole-disk walks, per-look Appium sessions, uncapped local test runners, and the context-budget rules (no shell-rewritten files, no whole-file dumps, no transcript reads); doctor re-wires hooks a settings.json rewrite dropped, vybava setup mac applies host settings. → docs/claude-guards.md
lokappletLocale catalogs for AI sessions — configured in vybava.config.ts, queried by key, written by verb with every locale kept in sync, extracted from source, gated in CI. → docs/lok.md
merge-assistappletMerge main without the mechanical conflicts — catalogs merge by key, generated files take theirs and regenerate, unmerged migrations renumber past the base; one table of what is left. → docs/merge-assist.md
vybava configverbThe shared per-repo vybava.config.ts every applet reads — init scaffolds it with typed helpers, check gates CI, show prints the evaluated JSON. → docs/config.md
shrtappletTerminal-safe short links on luko.to — offline repo rules, team-shared dynamic rules, minted codes; also the redirector server. → docs/shrt.md
postaappletDrive a shared test mailbox end to end — mint a per-run plus-address, wait for the mail a journey triggered, take its links and attachments. → docs/posta.md
fontfreezeappletFreeze variable webfonts at rendered axis positions and subset per language.
perfrigappletPerformance drills from a testing/<project>/perf manifest — ramp to first failure, percentile report.
framestatsappletAndroid frame metrics for agents - parse gfxinfo framestats dumps (cadence, present intervals, per-gesture first-input and release-window frames) and perfetto traces (per-frame UI and RenderThread work, texture uploads, ART pauses, FrameTimeline jank). → docs/framestats.md
perflab / expo-device-perfapplet + skillPhysical-device performance lab for Expo / React Native - one lease holder per phone (raw adb/devicectl on a leased phone is refused by claude-guards), native builds keyed by a portable Expo fingerprint, JS bundle variants without native rebuilds, measured runs through the project's vybava.config.ts perflab adapter, Perfetto and xctrace probes, and every number re-derived from kept evidence with compare and budget gates. The skill carries the loop, the instrument choice and the render-cost rules. → docs/perflab.md
ingressgenappletRender and drift-check complete default-deny Docker ingress policies from a manifest.
reconcileappletPull-based GitOps for the infra boxes — converge a VPS to its infra repo's merged main from a per-box manifest: HELD hotfixes, transactional nginx hooks, commit rollback, textfile metrics, mesh-only status page + estate hub. → docs/reconcile.md
readiness / release-readinessapplet + skillRelease prep of a whole integration branch: the skill runs authority questions, an inventory with a completeness critic, one named lane agent per journey cluster (own story, QA task, usertest board), capacity governance, a merged-branch roll-up and a readiness board. The applet validates the vybava.config.ts readiness adapter, freezes production..integration ranges, and seeds and renders the run directory. → docs/readiness.md
storesappletStore release verbs for App Store Connect, Google Play and EAS - versions, builds, the live listing and its store.config.json drift check, prepare/submit an App Store version, Play completed or staged rollouts that keep the live release (5xx retried with a fresh edit), and the one-distribution-certificate iOS signing check; credentials injected by onyx or read from gitignored files, never printed. → docs/stores.md
ui-loopappletThe UI polish loop's deterministic layer: syncs the TypeScript/Playwright capture harness (typed screen manifest, capture + lint, app map) into a repo with a drift gate, runs passes from the vybava.config.ts uiLoop section (on a Devbox too), split-publishes them to vitrinka and scores the review backlog. The vitrinka map / review-loop workflows drive it. → docs/uiloop.md
issues / issue-sweepapplet + skillA repository's whole GitHub issue backlog to verified outcomes: Codex lanes triage every open issue against a frozen base with a skeptic on each not-live verdict, one human gate settles every decision, live bugs are fixed test-first (RED proven, Claude fixer, independent Codex verify) into one PR per issue or dependency group, stale issues get evidence comments and approved closes, every issue is matched to its vitrinka task and the run is reported as a vitrinka artifact. → docs/issues.md
polish-kitappletThe polish skill's deterministic layer: infers app/ui/api targets from the diff through the vybava.config.ts polish globs, resolves device lanes (iOS simulators and phones, Android devices and emulators, URLs) with the exact create/boot fix, keeps the pass ledger (run.json cells: lane x screen x theme x nav x text size, plus adverse-condition matrix cells), shoots simulators and Android natively, renders contact and edge-zoom sheets and the Markdown report with a delta. → docs/polish-kit.md
prm / push-all / sync / push-backskillsThe git family — prm is the one PR verb (create → review rounds → gated merge → teardown), push-all the commit doctrine, sync the pull/merge-up flow, push-back the claim verifier. Install with vybava install ai-git.
gitkitappletThe deterministic layer those skills execute — PR selectors, review triage, merge gates, worktrees, path classification — as vybava gitkit <script>. → docs/gitkit.md
vybava setup teamverbOne-click henderson-tech Mac — the henderson group's tools (Onyx + Helium with the lazy browser MCPs, vitrinka, SwitcherooBar, Pultík; devbox opt-in) through their own channels plus the git family, as a checklist or --yes --json. → docs/setup-team.md
vybava setup agentsverbPlan, apply and check lazy Appium and shared cmux badges; safe fleet finish/resume and codexsync audit. → docs/agent-setup.md
codexsyncappletRender ~/.claude skills and commands into ~/.agents/skills, the structure Codex discovers — nesting preserved, each command a source-command skill, duplicate discovery suppressed. → docs/codexsync.md
ccxskillClaude Code stays orchestrator and app-source author; usertests, UI verification, computer use, app mapping and e2e writing/running go to Codex — the native codex subagent inside cc sessions, never the Codex CLI — routing table, briefs per mode, status handling, browser etiquette, commit duties, Opus fallback.
codexusageappletExplain where the Codex plan limit went — per-thread spend from ~/.codex rollouts, the derived allowance, and the runway left at the measured burn rate. → docs/codexusage.md
tokentimeappletWhere your AI tokens went — Claude Code transcripts and Codex rollouts indexed incrementally into permanent hour × project × model buckets (worktrees fold into their repo), rolled up by day, hour, project and model with an API-equivalent USD value. → docs/tokentime.md
readeffappletHow agents navigate code — what Claude Code and Codex reads and searches put into context against what they changed: re-reads, whole-file reads of big files, search hit rates and the most-read files, per repository, session and file. → docs/readeff.md
find-sessionapplet + skillFind the Claude Code session a pasted conversation came from — its author ranked above sessions that only quoted it — and the one line that resumes it from its launch directory under the switcheroo preset it started with (cc, cco, ccoo, …); the /find-session skill wraps it. Install with vybava install find-session-cli find-session. → docs/find-session.md
repolicyappletHold GitHub repository settings to a declared policy across whole owners — GitHub inherits no organization default, so audit reports the drift (exit 1) and apply converges it, touching only the settings the policy names. → docs/repolicy.md
menubar-doctorappletFind and fix macOS menu-bar items that run but never appear — since macOS 26 Control Center files a status item under the process that launched the app, so anything started from a terminal is filed under the terminal and stays invisible while its switch is off. → docs/menubar-doctor.md
reclaim / reclaiming-disk-spaceapplet + skillEmergency disk reclaim for a dev Mac — a fixed ladder of regenerating caches deleted biggest-first with live df after every step, an early-stop target, and by-hand notes for what it refuses to touch. The skill adds the read-only audit (whole-Data-volume size ranking, orphaned Docker volumes by compose project name, session residue: shared-temp scratch, untracked ~/Exports evidence, transcripts, idle simulators) and the confirm-then-delete workflow. Install with vybava install reclaim reclaiming-disk-space. → docs/reclaim.md
macwatchappletMac load sampler with owner attribution - append load, memory and the heaviest processes to a TSV every interval, each tagged with project, directory and the claude/codex session that spawned it; report integrates the file into offenders, per-project and per-session totals and a spike ledger. → docs/macwatch.md
plugin-gcappletGarbage-collect the Claude Code plugin cache — every version ever installed is kept behind a PID refcount that abandoned sessions never release, and the payload is almost all node_modules; reports by default, deletes only on --apply. → docs/plugin-gc.md
vpnappletPersistent macOS WireGuard tunnels outside WireGuard.app — install pulls the Onyx profile into a root-only LaunchDaemon that survives reboots; status reports the route truth (up via wg-quick (utun11)) beside the app's Disconnected, the daemon kind and whether the tunnel DNS answers. → docs/vpn.md
uplinkappletSend one process's traffic out of an iPhone's USB Personal Hotspot while the Mac's default route and VPN stay put — devices finds attached phones by UDID and their hotspot interface, relay is a loopback CONNECT relay bound to it (IP_BOUND_IF: no root, no routes), measure times a bounded random upload. claude-switcheroo's daemon drives it. → docs/uplink.md
onyx-restappletOne REST call with an Onyx-injected secret — Bearer or HTTP Basic, the response in a 0600 --out file; --base first and once, so an Onyx allowed_commands prefix pins the host. → docs/onyx-rest.md
vybava vault-stdinverbDeliver Onyx-injected JSON through a private process pipe; no plaintext credential files or terminal output. → docs/vault-stdin.md
handoffsappletHandoff ledger upkeep — handoffs reconcile judges every open handoff by whether its branches and PRs are still alive and archives the dead ones; unknown is never touched. → docs/handoffs.md
pressappletDeterministic state for the document family — project resolution, ~/Exports/<project>/ config and index, ARES lookups, shared doctrine. → docs/press.md
press-pdf / press-logo / press-offer / press-emailskillsOffer, documentation and legal PDFs; brand marks; Czech commercial DOCX; Outlook-paste client emails. Issuer identity stays machine-local. → docs/press.md

Groups (recommended, experimental, claude-hooks, ai-git, press-family, everything) are composable presets in the catalog — never code. claude-hooks is what a Claude home's hooks call; on Linux (a Devbox portal box) the same release archive installs it: vybava install claude-hooks.

Install

Homebrew (everything is public — no authentication needed):

brew install --cask FixIt-Technologies/tap/vybava

CI images, workflows and provisioning scripts use the release installer in ci/ — never a checkout of this repository:

curl -fsSL -o /tmp/vybava-install.sh \
  https://raw.githubusercontent.com/FixIt-Technologies/vybava/v0.3.3/ci/install.sh \
  && bash /tmp/vybava-install.sh --version 0.3.3 --bin-dir /usr/local/bin --install memorylint,hotfix

From source:

go build -o ./bin/vybava ./cmd/vybava
./bin/vybava catalog list
./bin/vybava install recommended

install takes item or group selectors (default: the recommended group) and supports --agent claude|codex|all, --scope user|project, --dry-run, and --json. Installed applets are links to the vybava binary, so vybava memory lint . and memorylint . are equivalent.

Layout

catalog/catalog.yaml   package and group source of truth
cmd/vybava/            multicall entrypoint
internal/<id>/         one focused Go package per capability
skills/<id>/           canonical cross-agent skill payloads (SKILL.md plus any
                       references/ and assets/ the skill ships)
docs/                  per-tool references, release flow, decisions
Dockerfile             the luko.to redirector image (deployik app "luko")

Extending

One payload + one catalog entry = one package; presets are one more catalog line. Contract: docs/decisions/0001-modular-catalog.md · checklist: CONTRIBUTING.md · releases: docs/homebrew.md.

Source 2 files
hooks/register.ts 200 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, ToolCallInput, TurnUsage } from 'claude-code'
3
4import type { PeekCall, PeekToolTime } from '../types'
5
6const calls = atom({ plugin: 'peek', key: 'calls' } as const, [] as PeekCall[])
7const background = atom({ plugin: 'peek', key: 'background' } as const, [] as string[])
8const turn = atom({ plugin: 'peek', key: 'turn' } as const, [] as PeekToolTime[])
9const tick = atom({ plugin: 'peek', key: 'tick' } as const, 0)
10
11const TICK_MS = 30_000
12const LONG_TURN_MS = 30 * 60_000
13const LABEL_MAX = 48
14const TASK_ID = /<task-id>([^<]+)<\/task-id>/
15const AGENT_TOOLS = new Set(['Agent', 'Task'])
16
17// peek only watches: every hook passes its event on unchanged, and the one
18// rewrite is the spinner's suffix. Nothing here spawns a process.
19export const register: Register = on => {
20  on('session.start', async ($, e, next) => {
21    // A reload drops the hooks that would have closed the calls in flight.
22    await update($, calls, () => [])
23    $.clock.every(TICK_MS, () => {
24      void update($, tick, n => n + 1)
25    })
26    return next(e)
27  })
28
29  on('turn.start', async ($, e, next) => {
30    await update($, turn, () => [])
31    return next(e)
32  })
33
34  on('tool.call', async ($, e, next) => {
35    const call: PeekCall = {
36      id: e.tool_use_id,
37      tool: e.tool,
38      label: labelOf(e),
39      startedAt: await $.clock.now(),
40      agentId: e.agentId ?? null,
41    }
42    await update($, calls, list => [...list, call])
43    try {
44      const ran = await next(e)
45      const taskId = ran.deny === undefined ? backgroundTaskId(ran.result) : null
46      if (taskId !== null) {
47        await update($, background, ids => [...ids, taskId])
48      }
49      if (call.agentId === null) {
50        const elapsed = (await $.clock.now()) - call.startedAt
51        await update($, turn, times => addTime(times, call.tool, elapsed))
52      }
53      return ran
54    } finally {
55      await update($, calls, list => list.filter(one => one.id !== call.id))
56    }
57  })
58
59  on('prompt.submit', async ($, e, next) => {
60    const done = e.origin?.kind === 'task-notification' ? TASK_ID.exec(e.text)?.[1] : undefined
61    if (done !== undefined) {
62      await update($, background, ids => ids.filter(id => id !== done))
63    }
64    return next(e)
65  })
66
67  // The turn's end carries the engine's own list of background work: the
68  // authoritative count, so a task that ended without a notification (stopped,
69  // or folded into a running turn) never lingers as "1 bg".
70  on('classic.Stop', async ($, e, next) => {
71    const ran = await next(e)
72    if (e.background_tasks !== undefined) {
73      const shells = e.background_tasks.filter(task => task.type === 'shell').map(task => task.id)
74      await update($, background, () => shells)
75    }
76    return ran
77  })
78
79  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
80    const list = await read($, calls)
81    const running = (await read($, background)).length
82    await read($, tick) // subscribes the drawing to the age tick
83    const detail = describe(list, running, await $.clock.now())
84    if (detail === null) {
85      return next(e)
86    }
87    return next({ ...e, props: { ...e.props, suffix: `${e.props.suffix} ${detail}` } })
88  })
89
90  on('turn.complete', async ($, e, next) => {
91    const ran = await next(e)
92    if (e.agentId !== undefined) {
93      return ran
94    }
95    // Nothing of the main loop runs past its turn; a reload may have left one.
96    await update($, calls, list => list.filter(one => one.agentId !== null))
97    if (e.durationMs < LONG_TURN_MS) {
98      return ran
99    }
100    const line = breakdown(e.durationMs, await read($, turn), e.usage)
101    // next() answers the answer's own text; any other text is a line drawn
102    // beneath it. A line a plugin below already set stays, ours goes after it.
103    return { ...ran, text: ran.text === e.answer ? line : `${ran.text}\n${line}` }
104  })
105}
106
107// describe is what the spinner adds: the main loop's oldest call in flight
108// (its age once it has run a tick), foreground agents, background tasks.
109function describe(list: readonly PeekCall[], running: number, now: number): string | null {
110  const main = list.filter(one => one.agentId === null)
111  const agents = main.filter(one => AGENT_TOOLS.has(one.tool)).length
112  const parts: string[] = []
113  const oldest = main.reduce<PeekCall | null>((first, one) => (first === null || one.startedAt < first.startedAt ? one : first), null)
114  if (oldest !== null) {
115    const age = now - oldest.startedAt
116    parts.push(age >= TICK_MS ? `${oldest.label} · ${duration(age)}` : oldest.label)
117    if (main.length > 1) {
118      parts.push(`+${main.length - 1} more`)
119    }
120  }
121  if (agents > 0) {
122    parts.push(agents === 1 ? '1 agent' : `${agents} agents`)
123  }
124  if (running > 0) {
125    parts.push(`${running} bg`)
126  }
127  return parts.length === 0 ? null : parts.join(' · ')
128}
129
130function breakdown(durationMs: number, times: readonly PeekToolTime[], usage: TurnUsage | undefined): string {
131  const tools = [...times]
132    .sort((a, b) => b.ms - a.ms)
133    .slice(0, 4)
134    .map(one => `${one.tool} ${duration(one.ms)} ×${one.calls}`)
135  const parts = [`Turn ${duration(durationMs)}`]
136  if (tools.length > 0) {
137    parts.push(`tool time: ${tools.join(', ')}`)
138  }
139  if (usage !== undefined) {
140    const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
141    parts.push(`tokens: ${count(input)} in (${count(usage.cache_read_input_tokens)} cached) / ${count(usage.output_tokens)} out`)
142  }
143  return parts.join(' — ')
144}
145
146function addTime(times: readonly PeekToolTime[], tool: string, ms: number): PeekToolTime[] {
147  const found = times.find(one => one.tool === tool)
148  if (found === undefined) {
149    return [...times, { tool, ms, calls: 1 }]
150  }
151  return times.map(one => (one.tool === tool ? { tool, ms: one.ms + ms, calls: one.calls + 1 } : one))
152}
153
154function labelOf(e: ToolCallInput): string {
155  if ('description' in e && typeof e.description === 'string' && e.description.trim() !== '') {
156    return clip(e.description.trim())
157  }
158  if (e.tool === 'Bash') {
159    return clip(e.command.trim().split('\n')[0] ?? '')
160  }
161  const tool = e.tool.startsWith('mcp__') ? (e.tool.split('__').at(-1) ?? e.tool) : e.tool
162  if ('file_path' in e && typeof e.file_path === 'string') {
163    return clip(`${tool} ${e.file_path.split('/').at(-1) ?? ''}`)
164  }
165  return tool
166}
167
168function backgroundTaskId(result: unknown): string | null {
169  if (typeof result !== 'object' || result === null || !('backgroundTaskId' in result)) {
170    return null
171  }
172  return typeof result.backgroundTaskId === 'string' ? result.backgroundTaskId : null
173}
174
175function clip(text: string): string {
176  return text.length <= LABEL_MAX ? text : `${text.slice(0, LABEL_MAX - 1)}…`
177}
178
179function duration(ms: number): string {
180  const minutes = Math.floor(ms / 60_000)
181  if (minutes < 1) {
182    return `${Math.floor(ms / 1000)}s`
183  }
184  if (minutes < 60) {
185    return `${minutes}m`
186  }
187  const rest = minutes % 60
188  return rest === 0 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 60)}h ${rest}m`
189}
190
191function count(tokens: number): string {
192  if (tokens >= 1_000_000) {
193    return `${(tokens / 1_000_000).toFixed(1)}M`
194  }
195  if (tokens >= 1000) {
196    return `${Math.round(tokens / 1000)}k`
197  }
198  return String(tokens)
199}
200
types/index.d.ts 29 lines
1// peek's state contract: the session values its hooks keep in $.state.
2
3/** A tool call in flight, as the spinner names it. */
4export type PeekCall = {
5  id: string
6  tool: string
7  /** The call's own description, else its tool and a head of its input. */
8  label: string
9  startedAt: number
10  /** The subagent's loop; null on the main loop. */
11  agentId: string | null
12}
13
14/** One tool's share of the running main-loop turn. */
15export type PeekToolTime = { tool: string; ms: number; calls: number }
16
17declare module 'claude-code' {
18  interface PluginState {
19    peek: {
20      calls: PeekCall[]
21      /** Background task ids still running (Bash run_in_background). */
22      background: string[]
23      turn: PeekToolTime[]
24      /** Bumped every 30 s so the spinner's ages redraw. */
25      tick: number
26    }
27  }
28}
29