SLOPSHOPPER

goblin-util

Two mods for the machine you run Claude Code on: /pain logs friction with the tool itself without a turn, and /fleet shows every session on the machine and…

newpaneguardcommandtoastprocess
★ 5v0.1.0no licenseupdated 2026-10-05JasonWarrenUK/goblin-mode/marketplace/goblin-util
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · goblin-util
│ ┃ pain ✕ › fix the failing auth test and add an audit log call │ ┃ log friction with Claude Code itself. no │ ┃ turn, no judgement. ⏺ Read(src/auth.ts) │ ┃ app · feat/auth-refresh · last failed 0s ag… ⎿ Read 6 lines │ ┃ pain: what happened, in your own words ⏎ log ⏺ 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 │ │ › /pain │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pain
log friction with Claude Code itself. no turn, no judgement. app · feat/auth-refresh · last failed 0s ago: Bash: src/aut… pain: what happened, in your own words ⏎ log
Pane · fleet
only you. 1: ◀ app feat/auth-refresh · idle 0s · Bash 1 live · a digit picks a row · esc closes
README

goblin-util

[!NOTE] Two Claude Code mods from goblin-mode for the machine you run sessions on. Both are immediate commands: they work while Claude is busy, and neither starts a turn.


1. Install

claude plugin marketplace add JasonWarrenUK/goblin-mode
claude plugin install goblin-util@goblin-mode

Needs Claude Code 2.1.287 or later. The panes draw in the terminal; the heartbeat and /pain <text> work everywhere.


2. /pain

Logs friction with Claude Code itself (the tool, never your project) into ~/.claude/library/state/cc-pain-points.json, the file hud-cc_releases reads to recognise a fix in the changelog later. The track-cc_pain skill does the same by inferring your mood mid-turn; this is the direct channel.

FormWhat happens
/pain the status line ate my promptAppends the entry at once and prints logged the-status-line-ate-my-prompt.
/painOpens a small pane with the context line and an input. Enter logs it and closes the pane. Esc closes it.

The entry carries the schema library/state/README.md documents. sourceSession is pre-filled with the repo, the branch and the last failed tool call's first line, so the record says where it bit. An open entry with the same description is not duplicated; you are told its id instead. Marking an entry resolved stays with the skill.


3. /fleet

Every session on this machine writes a heartbeat row to the plugin's shared store: repo, branch, whether a turn is running, the last tool it called and when. /fleet opens a pane listing them, this session first.

KeyDoes
1 to 9Picks a row. A picked session that is not this one gets an input under the table.
Enter in the inputSends the line to that session as a message, the same delivery as the SendMessage tool. The toast says whether it landed.
EscCloses the pane.

A row that has not written for a minute reads quiet. Opening /fleet never writes: a row past the stale limit is dropped by the next heartbeat any running session writes, within heartbeat_seconds (15 by default). A session that ends cleanly removes its own row.


4. Options

OptionDefaultWhat it does
enabledtrueThe one switch for all of it. Off, the heartbeat stops and /pain and /fleet are not registered.
heartbeat_seconds15How often this session writes its row.
stale_minutes10A session silent for this long is forgotten.

5. Files

FileHolds
hooks/register.tsxBoth commands, the heartbeat, the two panes
hooks/util.test.tsxTests for the helpers, both forms of /pain, the fleet pane and the heartbeat
types/index.d.tsThe $.state contract: the picked session, the last failed tool, the branch

Check it with claude plugin validate marketplace/goblin-util --strict and claude plugin test marketplace/goblin-util.

Source 2 files
hooks/register.tsx 373 lines
1// goblin-util: two mods for the machine. `/pain` logs friction with Claude
2// Code itself into library/state/cc-pain-points.json without a turn, the
3// context pre-filled from the last failed tool call. `/fleet` shows every
4// session on the machine from a shared heartbeat in $.store and sends one a
5// line. Both are immediate commands, so they work while Claude is busy.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import type { Heartbeat, LastError, Self } from '../types'
11
12const PAIN_PANE = 'pain'
13const FLEET_PANE = 'fleet'
14const FLEET_PREFIX = 'fleet:'
15/** The least time between two heartbeats written for tool events. */
16const BEAT_GAP_MS = 5_000
17
18const selected = atom({ plugin: 'goblin-util', key: 'selected' } as const, null as string | null)
19const lastError = atom({ plugin: 'goblin-util', key: 'lastError' } as const, null as LastError | null)
20const branch = atom({ plugin: 'goblin-util', key: 'branch' } as const, '')
21const repoName = atom({ plugin: 'goblin-util', key: 'repoName' } as const, '')
22const self = atom({ plugin: 'goblin-util', key: 'self' } as const, {
23	sessionId: '',
24	startedAt: 0,
25	isWorking: false,
26	lastTool: '',
27	lastToolAt: 0,
28	beatAt: 0,
29} as Self)
30
31// Module-level because the validator holds `$` to top-level functions; set
32// by `register` from the options. `cwd` is read only by the heartbeat, never
33// drawn.
34let heartbeatMs = 15_000
35let staleMs = 10 * 60_000
36let cwd = ''
37
38const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
39
40const basename = (path: string): string => path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? path
41
42/** A kebab-case id from a description, as the pain-points schema wants. */
43export const slug = (text: string): string =>
44	text
45		.toLowerCase()
46		.replace(/[^a-z0-9]+/g, '-')
47		.replace(/^-+|-+$/g, '')
48		.slice(0, 48)
49		.replace(/-+$/, '') || 'pain'
50
51/** The local calendar date, as the skill writes `logged`. */
52export const localDate = (ms: number): string => {
53	const d = new Date(ms)
54	const pad = (n: number) => String(n).padStart(2, '0')
55	return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
56}
57
58export const ago = (ms: number): string => {
59	const s = Math.max(0, Math.round(ms / 1000))
60	if (s < 60) return `${s}s`
61	const m = Math.floor(s / 60)
62	if (m < 60) return `${m}m`
63	return `${Math.floor(m / 60)}h ${m % 60}m`
64}
65
66const painPath = async ($: EngineInterface): Promise<string> => {
67	const home = await $.env.get('HOME')
68	if (!home) throw new Error('HOME is not set')
69	return `${home}/.claude/library/state/cc-pain-points.json`
70}
71
72/** The file's entries and its text as read, or null text when it is missing; throws on a file that will not parse. */
73const readPain = async ($: EngineInterface, path: string): Promise<{ entries: Record<string, unknown>[]; text: string | null }> => {
74	if (!(await $.fs.exists(path))) return { entries: [], text: null }
75	const text = await $.fs.read(path)
76	const parsed: unknown = JSON.parse(text)
77	if (!Array.isArray(parsed)) throw new Error('not a list')
78	return { entries: parsed.filter(isRecord), text }
79}
80
81/**
82 * Append one pain point, unless an open entry already says the same thing.
83 * Returns the one line the person sees; only a line beginning `logged `
84 * means something was written.
85 */
86const logPain = async ($: EngineInterface, description: string): Promise<string> => {
87	const text = description.trim()
88	if (!text) return 'nothing to log.'
89	let path: string
90	try {
91		path = await painPath($)
92	} catch {
93		return 'HOME is not set; nothing written.'
94	}
95	let first: Awaited<ReturnType<typeof readPain>>
96	try {
97		first = await readPain($, path)
98	} catch {
99		return 'cc-pain-points.json will not parse; nothing written. fix the file first.'
100	}
101	const open = first.entries.find(
102		e => e.resolvedIn === null && typeof e.description === 'string' && e.description.trim().toLowerCase() === text.toLowerCase(),
103	)
104	if (open) return `already logged as ${String(open.id)}`
105	const ids = new Set(first.entries.map(e => e.id))
106	const base = slug(text)
107	let id = base
108	for (let n = 2; ids.has(id); n++) id = `${base}-${n}`
109	const [err, b, repo, now] = await Promise.all([read($, lastError), read($, branch), read($, repoName), $.clock.now()])
110	const source = [repo, b, err ? `last failed: ${err.tool}: ${err.text}` : '']
111		.filter(part => part !== '')
112		.join(' · ')
113	// Another session may have written since the first read: a whole-file write
114	// would drop its entry, so read again and give up when the file moved.
115	let again: Awaited<ReturnType<typeof readPain>>
116	try {
117		again = await readPain($, path)
118	} catch {
119		return 'cc-pain-points.json changed under me and will not parse; nothing written.'
120	}
121	if (again.text !== first.text) return 'cc-pain-points.json changed while I read it; try again.'
122	const entries = [...again.entries, { id, description: text, logged: localDate(now), sourceSession: source, resolvedIn: null, resolvedNoted: null }]
123	await $.fs.write(path, JSON.stringify(entries, null, '\t') + '\n')
124	return `logged ${id}`
125}
126
127const refreshBranch = async ($: EngineInterface): Promise<void> => {
128	try {
129		const ran = await $.process.run(['git', 'branch', '--show-current'], { timeoutMs: 5_000 })
130		await update($, branch, () => (ran.exitCode === 0 ? ran.stdout.trim() : ''))
131	} catch {
132		await update($, branch, () => '')
133	}
134}
135
136const isHeartbeat = (v: unknown): v is Heartbeat =>
137	isRecord(v) && typeof v.id === 'string' && typeof v.at === 'number' && typeof v.repo === 'string' && typeof v.branch === 'string'
138
139/**
140 * Write this session's row to the shared store, under the id the session
141 * has now (a /clear changes it with no session.start), and prune rows that
142 * went stale. `force` skips the gap that throttles tool-event beats.
143 */
144const beat = async ($: EngineInterface, force = true): Promise<void> => {
145	const now = await $.clock.now()
146	const me = await read($, self)
147	if (!force && now - me.beatAt < BEAT_GAP_MS) return
148	let sessionId = me.sessionId
149	let startedAt = me.startedAt
150	try {
151		sessionId = await $.session.id()
152	} catch {
153		// no id to write under
154	}
155	if (!sessionId) return
156	if (sessionId !== me.sessionId) startedAt = now
157	await update($, self, s => ({ ...s, sessionId, startedAt, beatAt: now }))
158	const row: Heartbeat = {
159		id: sessionId,
160		cwd,
161		repo: await read($, repoName),
162		branch: await read($, branch),
163		isWorking: me.isWorking,
164		lastTool: me.lastTool,
165		lastToolAt: me.lastToolAt,
166		startedAt,
167		at: now,
168	}
169	await $.store.set(FLEET_PREFIX + sessionId, row)
170	for (const key of await $.store.keys()) {
171		if (!key.startsWith(FLEET_PREFIX) || key === FLEET_PREFIX + sessionId) continue
172		const other = await $.store.get(key)
173		if (!isHeartbeat(other) || now - other.at > staleMs) await $.store.delete(key)
174	}
175}
176
177/** Every row in the store, this session first; never writes. */
178const fleet = async ($: EngineInterface): Promise<Heartbeat[]> => {
179	const me = await read($, self)
180	const rows: Heartbeat[] = []
181	for (const key of await $.store.keys()) {
182		if (!key.startsWith(FLEET_PREFIX)) continue
183		const row = await $.store.get(key)
184		if (isHeartbeat(row)) rows.push(row)
185	}
186	return rows.sort((a, b) =>
187		a.id === me.sessionId ? -1 : b.id === me.sessionId ? 1 : a.repo.localeCompare(b.repo) || a.branch.localeCompare(b.branch),
188	)
189}
190
191const isPaneOpen = async ($: EngineInterface, id: string): Promise<boolean> => {
192	try {
193		return (await $.ui.panes()).some(p => p.id === id)
194	} catch {
195		return false
196	}
197}
198
199export const register: Register = (on, options) => {
200	if (options.enabled === false) return
201
202	heartbeatMs = (typeof options.heartbeat_seconds === 'number' ? options.heartbeat_seconds : 15) * 1000
203	staleMs = (typeof options.stale_minutes === 'number' ? options.stale_minutes : 10) * 60_000
204
205	on('session.start', async ($, e, next) => {
206		cwd = e.cwd
207		try {
208			const repo = await $.session.repo()
209			await update($, repoName, () => basename(repo?.root ?? e.cwd))
210		} catch {
211			await update($, repoName, () => basename(e.cwd))
212		}
213		await refreshBranch($)
214		await beat($)
215		$.clock.every(heartbeatMs, () => {
216			void beat($)
217		})
218		$.clock.every(10_000, () => {
219			void isPaneOpen($, FLEET_PANE).then(open => {
220				if (open) $.ui.invalidate('ui.render')
221			})
222		})
223		// One try each: a taken name must not cost the other command.
224		try {
225			await $.command.register({
226				name: 'pain',
227				description: 'Log friction with Claude Code itself, no turn: /pain <what happened>, or alone for a box',
228				argumentHint: '[what happened]',
229				immediate: true,
230			})
231		} catch {
232			// the name is taken
233		}
234		try {
235			await $.command.register({
236				name: 'fleet',
237				description: 'Every Claude Code session on this machine, and a line to one of them',
238				immediate: true,
239			})
240		} catch {
241			// the name is taken
242		}
243		return next(e)
244	})
245
246	on('session.end', async ($, e, next) => {
247		await $.store.delete(FLEET_PREFIX + e.sessionId).catch(() => undefined)
248		return next(e)
249	})
250
251	// A turn, not a submission: a prompt a hook drops starts nothing.
252	on('turn.start', async ($, e, next) => {
253		await update($, self, s => ({ ...s, isWorking: true }))
254		await beat($)
255		return next(e)
256	})
257
258	on('turn.complete', async ($, e, next) => {
259		if (e.agentId) return next(e)
260		await update($, self, s => ({ ...s, isWorking: false }))
261		await beat($)
262		return next(e)
263	})
264
265	on('tool.call', async ($, e, next) => {
266		if (!e.agentId) {
267			const now = await $.clock.now()
268			await update($, self, s => ({ ...s, lastTool: e.tool, lastToolAt: now }))
269		}
270		const ran = await next(e)
271		if (e.agentId) return ran
272		if (ran.deny === undefined && ran.isError === true) {
273			const text = typeof ran.text === 'string' ? ran.text : ''
274			const me = await read($, self)
275			await update($, lastError, () => ({ tool: e.tool, text: text.replace(/\s+/g, ' ').trim().slice(0, 160), at: me.lastToolAt }))
276		}
277		const movedBranch =
278			e.tool === 'EnterWorktree' || e.tool === 'ExitWorktree' || (e.tool === 'Bash' && /\bgit\b[^|;&]*\b(checkout|switch|worktree)\b/.test(e.command))
279		if (movedBranch) await refreshBranch($)
280		await beat($, movedBranch)
281		return ran
282	})
283
284	// Each pane command opens its pane, or closes it when it is already up: a
285	// pane is a tab beside any other, /diff included, and the same word takes
286	// it down again.
287	on('command.run', { command: 'pain' }, async ($, e) => {
288		if (e.args.trim()) return { text: await logPain($, e.args) }
289		if (await isPaneOpen($, PAIN_PANE)) await $.ui.close({ id: PAIN_PANE })
290		else await $.ui.open({ id: PAIN_PANE, title: 'pain', focus: true, closeOnEscape: true, rows: 7 })
291		return {}
292	})
293
294	on('command.run', { command: 'fleet' }, async $ => {
295		if (await isPaneOpen($, FLEET_PANE)) await $.ui.close({ id: FLEET_PANE })
296		else await $.ui.open({ id: FLEET_PANE, title: 'fleet', focus: true, closeOnEscape: true, rows: 12 })
297		return {}
298	})
299
300	on('ui.render', { component: 'Pane', requestId: PAIN_PANE }, async ($, e, next) => {
301		if (e.surface !== 'terminal') return next(e)
302		const { Box, Text, Input } = $.ui.resolve(e)
303		const [err, b, repo, now] = await Promise.all([read($, lastError), read($, branch), read($, repoName), $.clock.now()])
304		const context = [repo, b, err ? `last failed ${ago(now - err.at)} ago: ${err.tool}: ${err.text}` : 'nothing has failed yet']
305			.filter(part => part !== '')
306			.join(' · ')
307		return (
308			<Box flexDirection="column" rowGap={0}>
309				<Text bold>log friction with Claude Code itself. no turn, no judgement.</Text>
310				<Text dimColor wrap="truncate-end">
311					{context}
312				</Text>
313				<Input
314					key="pain-text"
315					label="pain"
316					placeholder="what happened, in your own words"
317					submitLabel="log"
318					autoFocus
319					onSubmit={async value => {
320						const line = await logPain($, value)
321						$.ui.toast(line)
322						// Only a write closes the box; a refusal keeps what was typed in view.
323						if (line.startsWith('logged ')) await $.ui.close({ id: PAIN_PANE })
324					}}
325				/>
326			</Box>
327		)
328	})
329
330	on('ui.render', { component: 'Pane', requestId: FLEET_PANE }, async ($, e, next) => {
331		if (e.surface !== 'terminal') return next(e)
332		const { Box, Text, Input, Button } = $.ui.resolve(e)
333		const [rows, picked, me, now] = await Promise.all([fleet($), read($, selected), read($, self), $.clock.now()])
334		const target = rows.find(r => r.id === picked && r.id !== me.sessionId)
335		const width = Math.max(40, e.props.bodyColumns)
336		return (
337			<Box flexDirection="column" width={width}>
338				{rows.length <= 1 && <Text dimColor>only you.</Text>}
339				{rows.map((row, i) => {
340					const here = row.id === me.sessionId
341					const quiet = now - row.at > 60_000
342					const state = row.isWorking ? 'working' : `idle ${ago(now - Math.max(row.lastToolAt, row.startedAt))}`
343					const label = `${here ? '◀' : ' '} ${row.repo} ${row.branch || '(no branch)'} · ${quiet ? `quiet ${ago(now - row.at)}` : state}${row.lastTool ? ` · ${row.lastTool}` : ''}`
344					return (
345						<Button
346							key={`row-${row.id}`}
347							label={label}
348							plain
349							{...(i < 9 ? { hotkey: String(i + 1) } : {})}
350							dimColor={quiet}
351							onPress={() => update($, selected, () => row.id)}
352						/>
353					)
354				})}
355				{target && (
356					<Input
357						key="fleet-msg"
358						label={`to ${target.repo}/${target.branch || target.id}`}
359						placeholder="one line, delivered as a message"
360						submitLabel="send"
361						onSubmit={async value => {
362							if (!value.trim()) return
363							const sent = await $.session.send({ to: { sessionId: target.id }, text: value.trim() })
364							$.ui.toast(sent.isDelivered ? `sent to ${target.repo}/${target.branch || target.id}` : `not delivered: ${sent.reason}`)
365						}}
366					/>
367				)}
368				<Text dimColor>{rows.length} live · a digit picks a row · esc closes</Text>
369			</Box>
370		)
371	})
372}
373
types/index.d.ts 43 lines
1// The state contract for goblin-util: what the fleet pane and the pain pane
2// draw from, held by the host for the session.
3
4export type LastError = {
5	tool: string
6	text: string
7	at: number
8}
9
10export type Heartbeat = {
11	id: string
12	cwd: string
13	repo: string
14	branch: string
15	isWorking: boolean
16	lastTool: string
17	lastToolAt: number
18	startedAt: number
19	at: number
20}
21
22/** This session as the fleet sees it, held in state so a hot reload keeps it. */
23export type Self = {
24	sessionId: string
25	startedAt: number
26	isWorking: boolean
27	lastTool: string
28	lastToolAt: number
29	beatAt: number
30}
31
32declare module 'claude-code' {
33	interface PluginState {
34		'goblin-util': {
35			selected: string | null
36			lastError: LastError | null
37			branch: string
38			repoName: string
39			self: Self
40		}
41	}
42}
43