SLOPSHOPPER

tool-lines

Draws each tool call as one compact 'tool_call: Tool(input) - meta' line and hides its result block

newrows
v0.1.0MITupdated 2026-10-09binbandit/bin-stack/mods/tool-lines
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-lines
⟨Claude Code's own drawing⟩ ⏺ Read(src/auth.ts) ⎿ Read 6 lines ● tool_call: Edit(/work/app/src/auth.ts) ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
⟨Claude Code's own drawing⟩
Tool row
● tool_call: Edit(/work/app/src/auth.ts)
README

bin-stack

A harness-agnostic agent skill stack.

Skills follow the Agent Skills format (SKILL.md + optional scripts/references/assets). One source of truth under skills/. Works with Cursor, Claude Code, Codex, and any other agent that discovers skills the standard way.

Inspired by stacks like pstack, but not locked to a single product.

Install

Via skills CLI

npx skills add binbandit/bin-stack

Pick specific skills:

npx skills add binbandit/bin-stack --skill file-pr --skill babysit-pr --skill arena --skill bro

Symlink a clone (one copy, every harness)

git clone https://github.com/binbandit/bin-stack.git ~/Developer/ai/bin-stack
cd ~/Developer/ai/bin-stack
./scripts/link-skills.sh

That links each skill directory into the harness roots you already have (~/.agents/skills, ~/.cursor/skills, ~/.claude/skills, ~/.codex/skills, …). Edit here; every harness sees the same files.

Skills

SkillWhen to use
file-prFile or update a concise pull request
babysit-prMonitor a PR through review bots and CI
arenaRun competing attempts, then graft the strongest ideas into one result
broRestate the last message in plain language
verifyProve a behavior change on the user surface with revision-bound evidence
reviewFilter adversarial findings by reachable failure and concrete evidence

Mods

Claude Code mods: plugins of function hooks that change how Claude Code looks and behaves. They are Claude Code only.

ModWhat it does
glamour-darkDraws replies as rich markdown in glamour's dark style, one sentence per line
marathon-footerRestyles the hints after the mode label under the prompt to match the SLOPOTHON banner: ⏵⏵ auto mode on · ⇧⇥ CYCLE // ← 1 AGENT
redact-secretsSwaps secrets for placeholders before Claude reads them or the session logs them; Write and Edit put the real values back
tool-linesDraws each tool call as one compact line and hides its result block
ascii-spinnerSwaps the working line (verb, timer, tokens, effort, tip) for a one-row WEAVEworm rethreading synthsilk, in Sekiguchi colors
slopothonPaints a SLOPOTHON lockup over the Claude logo in Marathon's design language: block wordmark, a dot-matrix card naming the model and branch, and a livery strip with a tagline and barcode per session. Fullscreen ("tui": "fullscreen") only; it starts each session with a /slopothon row and the logo shows for ~130ms first. Type /slopothon to redraw it inline

Install every mod at user scope:

./scripts/install-mods.sh

The script adds mods/ as the bin-stack marketplace and installs each mod from it. Claude Code reads the mods in place from this clone, so an edit here applies on the next session or /reload-plugins. Re-run it after adding a mod.

Layout

skills/
  file-pr/SKILL.md
  babysit-pr/SKILL.md
  arena/SKILL.md
  bro/SKILL.md
  verify/SKILL.md
  review/SKILL.md
mods/
  .claude-plugin/marketplace.json
  glamour-dark/
  marathon-footer/
  redact-secrets/
  tool-lines/
  ascii-spinner/
  slopothon/
scripts/
  link-skills.sh   # optional: symlink into local harness skill roots
  install-mods.sh  # install the Claude Code mods

Canonical skills live in skills/. Harness-specific paths are adapters (symlinks or CLI installs), not forks of the content.

Authoring

  1. Add skills/<name>/SKILL.md with required name and description frontmatter.
  2. Keep name matching the directory. Lowercase, hyphens only.
  3. Put long reference material in references/; keep SKILL.md short enough to load on activation.
  4. Prefer harness-neutral wording. Call out Cursor / Claude / Codex only when a step truly differs.

See agentskills.io/specification.

License

MIT

The verify and review skills adapt selected pstack guidance by Lauren Tan at revision 9f451cf875ad1239912762f67741e8e5ba6ac0f1. Each skill bundles its provenance and the original MIT notice in LICENSE-pstack so attribution travels with individual skill installs.

Source 1 files
hooks/register.ts 144 lines
1import type { Register } from 'claude-code'
2
3const ENGINE_ROW_INDENT = 4
4const DOT_COLUMN_WIDTH = 2
5const MIN_TEXT_WIDTH = 20
6const MIN_LINE_CHARS = 120
7const MAX_LINE_ROWS = 3
8const MAX_SUBJECT_CHARS = 35
9const UNMEASURED_TERMINAL_COLUMNS = 120
10
11type ToolRow = {
12  tool: string
13  input: unknown
14  output?: unknown
15  isRunning: boolean
16  isErrored: boolean
17  isInterrupted: boolean
18}
19
20const SUBJECT_KEYS_BY_PRIORITY = [
21  'command',
22  'file_path',
23  'notebook_path',
24  'pattern',
25  'url',
26  'query',
27  'skill',
28  'description',
29  'path',
30  'action',
31  'prompt',
32]
33
34const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
35
36const asOneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
37
38function subjectOf(input: unknown): string {
39  if (!isRecord(input)) return ''
40  const subjectKey = SUBJECT_KEYS_BY_PRIORITY.find(key => typeof input[key] === 'string' && input[key] !== '')
41  if (subjectKey !== undefined) return asOneLine(String(input[subjectKey]))
42  return Object.keys(input).length === 0 ? '' : asOneLine(JSON.stringify(input))
43}
44
45const resultField = (row: ToolRow, name: string): unknown => (isRecord(row.output) ? row.output[name] : undefined)
46
47const printedOutput = (row: ToolRow) => `${resultField(row, 'stdout') ?? ''}\n${resultField(row, 'stderr') ?? ''}`
48
49type MetaProbe = (row: ToolRow) => string | undefined
50
51const META_PROBES: MetaProbe[] = [
52  row => (row.isInterrupted ? 'interrupted' : undefined),
53  row => (row.isErrored && !row.isInterrupted ? 'error' : undefined),
54  row => (resultField(row, 'backgroundTaskId') !== undefined ? 'background' : undefined),
55  row => (resultField(row, 'timedOutAfterMs') !== undefined ? 'timed out' : undefined),
56  row => (/Shell cwd was reset/.test(printedOutput(row)) ? 'shell cwd was reset' : undefined),
57  row => {
58    const note = resultField(row, 'returnCodeInterpretation')
59    return typeof note === 'string' && note !== '' ? note : undefined
60  },
61]
62
63type ToolLine = { tool: string; subject: string; meta: string }
64
65function clip(text: string, room: number): string {
66  if (text.length <= room) return text
67  return room <= 1 ? '…' : text.slice(0, room - 1) + '…'
68}
69
70function lineFor(row: ToolRow, columns: number): ToolLine {
71  const textWidth = Math.max(columns - ENGINE_ROW_INDENT - DOT_COLUMN_WIDTH, MIN_TEXT_WIDTH)
72  const lineBudget = Math.min(Math.max(textWidth, MIN_LINE_CHARS), textWidth * MAX_LINE_ROWS)
73  const meta = META_PROBES.map(probe => probe(row))
74    .filter(note => note !== undefined)
75    .join(', ')
76  const frameChars = `tool_call: ${row.tool}()`.length + (meta === '' ? 0 : ` - ${meta}`.length)
77  const subjectRoom = Math.min(MAX_SUBJECT_CHARS, lineBudget - frameChars)
78  return { tool: row.tool, subject: clip(subjectOf(row.input), subjectRoom), meta }
79}
80
81const hasFailed = (row: ToolRow) => row.isErrored || row.isInterrupted
82
83const hasFinished = (row: ToolRow) => !row.isRunning && row.output !== undefined
84
85function dotColor(row: ToolRow): 'error' | 'success' | 'warning' {
86  if (hasFailed(row)) return 'error'
87  return hasFinished(row) ? 'success' : 'warning'
88}
89
90let isTranscriptExpanded = false
91
92const isCompact = (surface: string) => surface === 'terminal' && !isTranscriptExpanded
93
94export const register: Register = on => {
95  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'composer' } } }, async ($, e, next) => {
96    if (e.surface === 'terminal' && e.props.isExpanded !== isTranscriptExpanded) {
97      isTranscriptExpanded = e.props.isExpanded
98      $.ui.invalidate('ui.render')
99    }
100    if (!isCompact(e.surface)) return next(e)
101    const { Box } = $.ui.resolve(e)
102    return Box({ flexDirection: 'column', marginBottom: 1, children: [await next(e)] })
103  })
104
105  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
106    if (!isCompact(e.surface)) return next(e)
107    const { Box, Text } = $.ui.resolve(e)
108    const line = lineFor(e.props, e.viewport?.columns ?? UNMEASURED_TERMINAL_COLUMNS)
109    const metaStyle = e.props.isErrored ? { color: 'error' } : { dimColor: true }
110    const dotColumn = Box({
111      minWidth: DOT_COLUMN_WIDTH,
112      flexShrink: 0,
113      children: [Text({ color: dotColor(e.props), children: ['●'] })],
114    })
115    const textColumn = Box({
116      flexShrink: 1,
117      children: [
118        Text({
119          wrap: 'wrap',
120          children: [
121            Text({ dimColor: true, children: ['tool_call: '] }),
122            Text({ bold: true, children: [line.tool] }),
123            `(${line.subject})`,
124            ...(line.meta === '' ? [] : [Text({ ...metaStyle, children: [` - ${line.meta}`] })]),
125          ],
126        }),
127      ],
128    })
129    return Box({ flexDirection: 'row', marginLeft: 2, children: [dotColumn, textColumn] })
130  })
131
132  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
133    if (!isCompact(e.surface)) return next(e)
134    const unfoldedGroup = { ...e.props, isExpanded: true }
135    return next({ ...e, props: unfoldedGroup })
136  })
137
138  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
139    if (!isCompact(e.surface)) return next(e)
140    const { Box } = $.ui.resolve(e)
141    return Box({})
142  })
143}
144