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…

[!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.
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.
/painLogs 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.
| Form | What happens |
|---|---|
/pain the status line ate my prompt | Appends the entry at once and prints logged the-status-line-ate-my-prompt. |
/pain | Opens 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.
/fleetEvery 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.
| Key | Does |
|---|---|
1 to 9 | Picks a row. A picked session that is not this one gets an input under the table. |
| Enter in the input | Sends the line to that session as a message, the same delivery as the SendMessage tool. The toast says whether it landed. |
| Esc | Closes 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.
| Option | Default | What it does |
|---|---|---|
enabled | true | The one switch for all of it. Off, the heartbeat stops and /pain and /fleet are not registered. |
heartbeat_seconds | 15 | How often this session writes its row. |
stale_minutes | 10 | A session silent for this long is forgotten. |
| File | Holds |
|---|---|
hooks/register.tsx | Both commands, the heartbeat, the two panes |
hooks/util.test.tsx | Tests for the helpers, both forms of /pain, the fleet pane and the heartbeat |
types/index.d.ts | The $.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.
hooks/register.tsx 373 lines1// 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}
373types/index.d.ts 43 lines1// 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