SLOPSHOPPER

work-session

Kick off and wrap up working sessions with your start and end commands, and give each session a useful title

newbandguardprompttool
★ 1v0.1.0MITupdated 2026-10-10enhki/claude-mods/mods/work-session
A shopper browsing a rack in a slop shop
README

claude-mods

Small Claude Code mods: plugins of function hooks that change what Claude Code shows or does, in the terminal and in the desktop app's Code tab.

Install

In a terminal Claude Code session:

/plugin install <mod> --marketplace enhki/claude-mods

Answer y to add the marketplace, then pick the user scope so the mod loads in every session, desktop ones included.

Mods

ModWhat it does
statusline-desktopDraws your terminal status line above the prompt in the desktop app
forgejo-issuesBrowse, search and manage the current repo's Forgejo or Gitea issues in a pane
work-sessionKicks off and wraps up working sessions with your own start and end commands, and titles each session
walkthroughA checklist pane for testing by hand: mark each step, add notes, send the results back in one message

statusline-desktop

The desktop app doesn't run your statusLine command. This mod does: it picks the command the way Claude Code does (project .claude/settings.local.json, then .claude/settings.json, then ~/.claude/settings.json), pipes it the same JSON the terminal would (model, cwd, cost, context window, rate limits), and draws the first line it prints in the band above the prompt.

  • ANSI colours keep the terminal's palette when ghostty's config (and the files it pulls in with config-file) sets one; otherwise they map to the app's own theme colours.
  • Refreshes on session start, after each tool call and turn, and every 30 s.
  • Stays out of the terminal, which already draws the real status line.
  • Only the input side of the context window is known to mods, so a token count your script sums from current_usage reads slightly lower than in the terminal (output tokens come through as 0).

forgejo-issues

/issues opens a pane on the issues of the repo the session runs in. The forge and repo come from git remote get-url origin (ssh or https), so it works for any Forgejo or Gitea host, Codeberg included.

  • Browse: every issue as a card, grouped by milestone; Open, Closed, All; label chips with counts (scoped labels like kind/bug sit under their scope, exclusive scopes pick one); a milestone picker.
  • Search: fzf-style fuzzy matching with highlights, plus label:bug, -label:wontfix, label:kind/, milestone:"Some name", is:closed. Enter opens the top match.
  • Read: the body on a raised panel, comments as cards, #N mentions as chips that open in the pane.
  • Write: comment, close and reopen (behind a Confirm), edit labels, set the milestone, file a new issue. Text goes in a full-width Markdown editor with a toolbar and a preview.

Token. The mod needs an API token with issue read/write and repository read. Put it in ~/.config/claude-mods/secrets/forgejo.env (folder 700, file 600), keyed by host so a token only ever goes to its own forge:

FORGEJO_TOKEN_CODEBERG_ORG=...
FORGEJO_TOKEN_GIT_EXAMPLE_COM=...

Without one, it falls back to fgj's config for that host. The mod also stops Claude's own file and shell tools from reading that secrets folder.

Settings (the plugin's options):

SettingDefaultWhat it does
apiUrlemptyThe API base when it isn't https://<remote host>/api/v1
requireCommentOnCloseonClosing needs a comment, posted before the close
requireMilestoneoffNew issues need a milestone
requireLabelFromemptyNew issues need one of these comma-separated labels
editorCommandemptyAdds Open in editor: e.g. ghostty --gtk-single-instance=false --class=popup.editor -e nvim {file}

Desktop notes. The desktop app currently drops Button and Markdown-link presses from plugin panes, so every control here is a small Client that posts a message instead; and it passes no paste into a Client, so for long or pasted text use Open in editor. The editor command must wait until you close the file; one that returns at once (a launcher that forks) leaves a Use editor text button to pull the file back by hand. Ghostty needs --gtk-single-instance=false for this, or it may hand the window to a running instance and return at once.

work-session

For projects where you start and end each working session with your own slash commands (a "continue working session" and an "end working session", say /cws and /ews), this mod offers them at the right moments and gives each session a title you can tell apart in the session list.

  • Kickoff: when a session starts fresh (launch or /clear, not a resume) in a project that has the start command, a band above the prompt offers it: press 1 to run it, 2 to skip, or just type something else. Set Kickoff to auto to run it straight away, or off.
  • Title: Claude gets a set_session_title tool and a line of context asking it to title the session once its focus is clear: <Project> S<n> · <focus> when the project numbers its sessions, else <Project> · <focus>. The title shows from your next message on. A /rename of your own wins: the mod stops setting it.
  • Wrap: once the context window passes Offer the wrap at (75% by default) or a rate limit passes 90%, the band offers the end command, with Later to dismiss it for the session.

Projects without the start command see nothing. In the desktop app the band draws above the desktop status line rather than in place of it.

Settings (the plugin's options):

SettingDefaultWhat it does
startCommandcwsThe command that kicks a session off, without the slash
endCommandewsThe command that wraps one up
kickoffbandband offers it, auto runs it at start, off never
wrapAtPercent75Context fullness that brings up the wrap offer; 0 turns it off

walkthrough

When Claude asks you to check something by hand (run a demo, resize a window, try a key), it publishes the steps to a Walkthrough pane instead of a list in chat, and you send the results back from there.

  • Steps: each one says what to run, where (directory, terminal, window size) and what you should see. Claude's publish_walkthrough tool refuses a step that doesn't say what to expect. Publishing again replaces the list.
  • The pane opens when Claude publishes, or with /walkthrough. The step you're on is drawn open: its command with Copy, what to expect, Pass / Fail / Skip and a note. Marking it opens the next one; done steps fold to one line you can click to reopen. Pressing a mark again takes it back.
  • Keys (terminal, once the pane has the keyboard): p pass, f fail, s skip, c copy, r send.
  • Send results submits one message: a tally, then each step as PASS, FAIL, SKIP or NOT DONE with your note beside it.

Screenshots don't go through the pane: paste them in the prompt as usual. No settings.

Writing a mod

A mod is a plugin of function hooks: a plugin.json, a hooks/hooks.json naming one TypeScript module, and that module's register(on, options). In a Claude Code session, the bundled plugin-authoring skill holds the full API (types, examples, the test kit); load it before writing one.

Where it lives

KindWhereLoads
General, shareablehere, mods/<name>/ + an entry in .claude-plugin/marketplace.jsoneverywhere, once installed at user scope
Tied to one project, or naming private hoststhat project's .claude/skills/<name>/in that project's sessions only, watched for edits
Just for you, everywhere, private~/.claude/skills/<name>/every session, watched for edits

Anything that names a private host, a person or a machine stays out of this public repo: make it a project mod, or read the value from a setting.

Workflow

  1. Sketch in the session's dev folder. The plugin-authoring skill gives a mods folder that hot-reloads when each turn ends: the quickest loop.
  2. Move it here as mods/<name>/, list it in the marketplace file, and install it from this clone (below). From then on, edits are live after /reload-plugins.
  3. Test it on the surface you'll use. The terminal and the desktop app draw the same tree differently (see the notes below); try both when it matters.
  4. Before each commit: claude plugin validate mods/<name>, claude plugin test mods/<name>, and a type-check. Bump version in the mod's plugin.json when behaviour changes, so GitHub installs update.

Conventions shared by these mods

  • Pure logic in its own files, with tests; the hooks module stays thin. claude plugin test runs *.test.ts(x) against the engine itself; a test stands in for engine calls with on('<event>', () => ({ value: … })).
  • Settings are the manifest's userConfig (shown in the plugin's options); nothing personal is hard-coded.
  • Secrets go in ~/.config/claude-mods/secrets/<service>.env (folder 700, file 600), keyed by host where a token belongs to a host. A mod that reads one also keeps Claude's own tools out of that folder (see forgejo-issues/hooks/guard.ts).
  • Saved state ($.state) outlives reloads and upgrades: read and update it through helpers that fill in defaults, or a field a new version adds arrives undefined.

Desktop app notes (Claude Code 2.1.29x)

  • Button and Markdown-link presses from a plugin pane don't arrive (the app logs ui_press not handled in ~/.config/Claude/logs/claude.ai-web.log). Input, Select and Client messages do. Make each pressable control its own small Client that posts a message (forgejo-issues/hooks/pill.tsx).
  • Set a Client's pointer and key listeners once, on its first draw. Each set is a message to the page, and many Clients re-setting them on every redraw get unmounted for flooding it.
  • Don't map pointer rows by arithmetic. Borders are thin lines, not rows, and text is proportional (about 1.2 characters per cell). One Client per clickable item makes hit-testing unnecessary.
  • No paste into a Client, and the native Input is one line at a fixed width. For long text, hand off to an external editor (forgejo-issues's Open in editor).
  • A "Nothing to show yet" pane means the drawing threw; a transcript line <mod>: <event> hook skipped: threw … names the handler that did.

Developing

Clone, then add the clone itself as your marketplace so edits are live after /reload-plugins with no reinstall:

claude plugin marketplace add ~/repos/claude-mods
claude plugin install <mod>@claude-mods --scope user

Check a mod before committing:

claude plugin validate mods/<mod>
claude plugin test mods/<mod>
Source 4 files
hooks/register.tsx 198 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as $, Register } from 'claude-code'
3
4import type { Kickoff, Title, Wrap } from '../types'
5import {
6  cleanTitle,
7  commandName,
8  hasCommand,
9  isFreshStart,
10  kickoffMode,
11  startContext,
12  TITLE_TOOL_DESCRIPTION,
13  wrapReason,
14} from './session'
15import type { KickoffMode } from './session'
16
17const PLUGIN = 'work-session'
18const TOOL = 'set_session_title'
19// Press ids carry the plugin's name: the band is shared with other plugins' Clients.
20const P = {
21  kickoff: `${PLUGIN}:kickoff`,
22  skip: `${PLUGIN}:skip`,
23  wrap: `${PLUGIN}:wrap`,
24  later: `${PLUGIN}:later`,
25} as const
26
27const EMPTY_KICKOFF: Kickoff = { phase: 'none', command: '' }
28const EMPTY_WRAP: Wrap = { phase: 'none', reason: '' }
29const EMPTY_TITLE: Title = { applied: null }
30
31const kickoff = atom({ plugin: 'work-session', key: 'kickoff' } as const, EMPTY_KICKOFF)
32const wrap = atom({ plugin: 'work-session', key: 'wrap' } as const, EMPTY_WRAP)
33const title = atom({ plugin: 'work-session', key: 'title' } as const, EMPTY_TITLE)
34
35let start = 'cws'
36let end = 'ews'
37let mode: KickoffMode = 'band'
38let wrapAt = 75
39
40// Saved state from an older version lacks newer fields: fill defaults on reads and updates.
41const readKickoff = async ($: $): Promise<Kickoff> => ({ ...EMPTY_KICKOFF, ...(await read($, kickoff)) })
42const readWrap = async ($: $): Promise<Wrap> => ({ ...EMPTY_WRAP, ...(await read($, wrap)) })
43const readTitle = async ($: $): Promise<Title> => ({ ...EMPTY_TITLE, ...(await read($, title)) })
44const setKickoff = ($: $, fn: (k: Kickoff) => Kickoff) => update($, kickoff, (x): Kickoff => fn({ ...EMPTY_KICKOFF, ...x }))
45const setWrap = ($: $, fn: (w: Wrap) => Wrap) => update($, wrap, (x): Wrap => fn({ ...EMPTY_WRAP, ...x }))
46const setTitle = ($: $, fn: (t: Title) => Title) => update($, title, (x): Title => fn({ ...EMPTY_TITLE, ...x }))
47
48async function runCommand($: $, name: string): Promise<void> {
49  try {
50    await $.command.run({ command: name, args: '' })
51  } catch (err) {
52    $.ui.log(`${PLUGIN}: could not run /${name}: ${err instanceof Error ? err.message : String(err)}`)
53  }
54}
55
56async function press($: $, id: string): Promise<void> {
57  if (id === P.kickoff) {
58    await setKickoff($, k => ({ ...k, phase: 'started' }))
59    // Not awaited: the command runs once the session is idle, after this press returns.
60    void runCommand($, start)
61  } else if (id === P.skip) {
62    await setKickoff($, k => ({ ...k, phase: 'skipped' }))
63  } else if (id === P.wrap) {
64    await setWrap($, w => ({ ...w, phase: 'started' }))
65    void runCommand($, end)
66  } else if (id === P.later) {
67    await setWrap($, w => ({ ...w, phase: 'dismissed' }))
68  }
69}
70
71export const register: Register = (on, options) => {
72  start = commandName(options?.startCommand, 'cws')
73  end = commandName(options?.endCommand, 'ews')
74  mode = kickoffMode(options?.kickoff)
75  wrapAt = typeof options?.wrapAtPercent === 'number' ? options.wrapAtPercent : 75
76
77  on('session.start', async ($, e, next) => {
78    const result = await next(e)
79    await $.tool.register({
80      name: TOOL,
81      description: TITLE_TOOL_DESCRIPTION,
82      inputSchema: {
83        type: 'object',
84        properties: { title: { type: 'string', description: 'The session title, e.g. "Atlas S12 · Search filters"' } },
85        required: ['title'],
86      },
87      isDeferred: false,
88    })
89    return result
90  })
91
92  // The classic event carries the start's source: only a fresh session is kicked off.
93  on('classic.SessionStart', async ($, e, next) => {
94    const result = await next(e)
95    if (!isFreshStart(e.source)) return result
96    await Promise.all([
97      setKickoff($, () => EMPTY_KICKOFF),
98      setWrap($, () => EMPTY_WRAP),
99      setTitle($, () => EMPTY_TITLE),
100    ])
101    if (mode === 'off' || !hasCommand(await $.command.list(), start)) return result
102    if (mode === 'auto') {
103      await setKickoff($, () => ({ phase: 'started', command: start }))
104      void runCommand($, start)
105    } else {
106      await setKickoff($, () => ({ phase: 'offered', command: start }))
107    }
108    return { ...result, additionalContext: [...(result.additionalContext ?? []), startContext(start)] }
109  })
110
111  // Re-sent with every prompt, so the session keeps the title it was given.
112  on('classic.UserPromptSubmit', async ($, e, next) => {
113    const result = await next(e)
114    const t = await readTitle($)
115    return t.applied ? { ...result, sessionTitle: t.applied } : result
116  })
117
118  on('tool.call', { tool: 'mcp__work-session__set_session_title' }, async ($, e) => {
119    const cleaned = cleanTitle((e as unknown as { title?: unknown }).title)
120    if ('error' in cleaned) return { deny: `${TOOL}: ${cleaned.error}` }
121    await setTitle($, () => ({ applied: cleaned.title }))
122    return { result: `Session title set to "${cleaned.title}"; it shows from the user's next message.` }
123  })
124
125  // Anything the person types instead of pressing 1 means this session needs no kickoff.
126  on('prompt.submit', async ($, e, next) => {
127    if (e.origin?.kind === 'composer' && (await readKickoff($)).phase === 'offered') {
128      await setKickoff($, k => ({ ...k, phase: 'skipped' }))
129    }
130    return next(e)
131  })
132
133  on('command.run', async ($, e, next) => {
134    if (e.command === start) await setKickoff($, k => ({ ...k, phase: 'started' }))
135    if (e.command === end) await setWrap($, w => ({ ...w, phase: 'started' }))
136    // A /rename by hand wins: stop re-sending this mod's title.
137    if (e.command === 'rename') await setTitle($, () => EMPTY_TITLE)
138    return next(e)
139  })
140
141  on('turn.complete', async ($, e, next) => {
142    const result = await next(e)
143    if (e.agentId) return result
144    if ((await readWrap($)).phase !== 'none') return result
145    const reason = wrapReason(await $.session.usage(), wrapAt)
146    if (reason && hasCommand(await $.command.list(), end)) {
147      await setWrap($, () => ({ phase: 'offered', reason }))
148    }
149    return result
150  })
151
152  on('ui.message', { component: 'AbovePrompt' }, async ($, e, next) => {
153    const id = (e.data as { press?: unknown } | null)?.press
154    if (typeof id !== 'string' || !id.startsWith(`${PLUGIN}:`)) return next(e)
155    await press($, id)
156    return {}
157  })
158
159  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
160    // The band's controls need a terminal (Buttons) or the desktop (Clients).
161    if (e.props.hasSurvey || e.props.isWorking) return next(e)
162    if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
163    const [k, w] = await Promise.all([readKickoff($), readWrap($)])
164    const offer =
165      k.phase === 'offered'
166        ? { glyph: '▸', text: `Kick off this session with /${start}`, yes: P.kickoff, yesLabel: `Run /${start}`, no: P.skip, noLabel: 'Skip' }
167        : w.phase === 'offered'
168          ? { glyph: '◐', text: `Time to wrap? ${w.reason}`, yes: P.wrap, yesLabel: `Run /${end}`, no: P.later, noLabel: 'Later' }
169          : null
170    if (!offer) return next(e)
171    const { Box, Text, Button, Client } = $.ui.resolve({ ...e, surface: e.surface })
172    // The terminal draws Buttons for their digit hotkeys; the desktop drops Button
173    // presses from plugin UI, so it gets Client pills posting { press }.
174    const control = (id: string, label: string, hotkey: string) =>
175      e.surface === 'terminal' ? (
176        <Button key={id} label={label} hotkey={hotkey} plain onPress={() => press($, id)} />
177      ) : (
178        <Client key={id} module="./pill.tsx" props={{ id, label, hotkey }} />
179      )
180    const row = (
181      <Box key={PLUGIN} flexDirection="row" gap={2} paddingX={1}>
182        <Text color="suggestion">{offer.glyph}</Text>
183        <Text>{offer.text}</Text>
184        {control(offer.yes, offer.yesLabel, '1')}
185        {control(offer.no, offer.noLabel, '2')}
186      </Box>
187    )
188    // Stacked over what the plugins beneath draw (a desktop status line), not instead of it.
189    const below = await next(e)
190    return (
191      <Box flexDirection="column">
192        {row}
193        {below}
194      </Box>
195    )
196  })
197}
198
hooks/session.ts 78 lines
1/** Pure decisions behind the hooks: what to offer, when, and what a title may be. */
2
3export type KickoffMode = 'band' | 'auto' | 'off'
4export type StartSource = 'startup' | 'resume' | 'clear' | 'compact' | 'fork'
5
6export const TITLE_MAX = 80
7// A rate limit this used up is worth wrapping before it runs out mid-task.
8export const RATE_LIMIT_WRAP = 90
9
10export function kickoffMode(raw: unknown): KickoffMode {
11  return raw === 'auto' || raw === 'off' ? raw : 'band'
12}
13
14/** A command name as typed in settings: no slash, no spaces; empty when unusable. */
15export function commandName(raw: unknown, fallback: string): string {
16  const name = (typeof raw === 'string' ? raw : fallback).trim().replace(/^\//, '')
17  return /^[\w:.-]+$/.test(name) ? name : ''
18}
19
20export function hasCommand(list: readonly { name: string }[], name: string): boolean {
21  return name !== '' && list.some(c => c.name === name)
22}
23
24/** A fresh working session starts at launch or after /clear; a resume or compact continues one. */
25export function isFreshStart(source: StartSource): boolean {
26  return source === 'startup' || source === 'clear'
27}
28
29/** The title as it will be shown, or why it is refused. */
30export function cleanTitle(raw: unknown): { title: string } | { error: string } {
31  if (typeof raw !== 'string') return { error: 'title must be a string' }
32  const title = raw.replace(/\s+/g, ' ').trim()
33  if (title === '') return { error: 'title is empty' }
34  if (title.length > TITLE_MAX) return { error: `title is ${title.length} characters; keep it under ${TITLE_MAX}` }
35  return { title }
36}
37
38type Usage = {
39  context: { percent?: number; tokens?: number; window: number }
40  rateLimits: readonly { kind: string; percentUsed: number }[]
41}
42
43export function contextPercent(usage: Usage): number | null {
44  const { percent, tokens, window } = usage.context
45  if (typeof percent === 'number') return Math.round(percent)
46  if (typeof tokens === 'number' && window > 0) return Math.round((tokens / window) * 100)
47  return null
48}
49
50/** Why a wrap is worth offering now, worded for the band; null when it is not. */
51export function wrapReason(usage: Usage, atPercent: number): string | null {
52  if (atPercent <= 0) return null
53  const limit = [...usage.rateLimits].sort((a, b) => b.percentUsed - a.percentUsed)[0]
54  if (limit && limit.percentUsed >= RATE_LIMIT_WRAP) return `${limit.kind} limit ${Math.round(limit.percentUsed)}% used`
55  const percent = contextPercent(usage)
56  if (percent !== null && percent >= atPercent) return `context ${percent}% full`
57  return null
58}
59
60/** What the model is told at a fresh start in a project that has the start command. */
61export function startContext(start: string): string {
62  return [
63    `This project kicks off working sessions with /${start}.`,
64    `Once a session's focus is known (after /${start} presents and the user picks what to work on,`,
65    'or once the task is clear), call the set_session_title tool with a short title:',
66    '"<Project> S<n> · <focus>" when the project numbers its sessions (e.g. "Atlas S12 · Search filters"),',
67    'else "<Project> · <focus>". Call it again when the focus changes.',
68  ].join(' ')
69}
70
71export const TITLE_TOOL_DESCRIPTION = [
72  "Sets this session's title, shown in the session list and the terminal tab, so the user can tell sessions apart.",
73  'Call it once the focus of the session is clear, and again when it changes.',
74  'Format: "<Project> S<n> · <focus>" when the project numbers its sessions (e.g. "Atlas S12 · Search filters"),',
75  `else "<Project> · <focus>". Under ${TITLE_MAX} characters.`,
76  'The title appears from the user\'s next message on.',
77].join(' ')
78
hooks/pill.tsx 42 lines
1import type { ClientModule } from 'claude-code'
2
3/**
4 * A pressable control drawn as a Client: a click, Enter or Space posts
5 * `{ press: id }` to the hooks module. The desktop app drops Button presses
6 * from plugin UI, while Client messages arrive (claude-mods' desktop rules);
7 * the terminal draws real Buttons instead, for their digit hotkeys.
8 */
9export type PillProps = { id: string; label: string; hotkey: string }
10
11type Local = { hover: boolean }
12
13const Pill: ClientModule<PillProps, Local> = (props, surface) => {
14  const { Box, Text } = surface.elements
15  const state = surface.state ?? { hover: false }
16  // Listeners are set once, on the first draw: each set is a message to the page.
17  if (surface.state === undefined) {
18    const id = props.id
19    surface.onPointer(e => {
20      const cur = surface.state ?? { hover: false }
21      if (e.type === 'leave') {
22        if (cur.hover) surface.setState({ hover: false })
23      } else if (!cur.hover) {
24        surface.setState({ hover: true })
25      }
26      if (e.type === 'down' && (e.button ?? 'left') === 'left') surface.post({ press: id })
27    })
28    surface.onKey(e => {
29      if (e.key === 'return' || e.key === ' ') surface.post({ press: id })
30    })
31    surface.setState(state)
32  }
33  return (
34    <Box flexDirection="row" borderStyle="round" borderColor={state.hover ? 'suggestion' : 'subtle'} paddingX={1}>
35      <Text color="suggestion">{props.hotkey}</Text>
36      <Text>: {props.label}</Text>
37    </Box>
38  )
39}
40
41export default Pill
42
types/index.d.ts 14 lines
1export type KickoffPhase = 'none' | 'offered' | 'started' | 'skipped'
2export type WrapPhase = 'none' | 'offered' | 'dismissed' | 'started'
3
4export type Kickoff = { phase: KickoffPhase; command: string }
5export type Wrap = { phase: WrapPhase; reason: string }
6/** `applied`: re-sent with every prompt so the session keeps it; null hands titling back to Claude Code. */
7export type Title = { applied: string | null }
8
9declare module 'claude-code' {
10  interface PluginState {
11    'work-session': { kickoff: Kickoff; wrap: Wrap; title: Title }
12  }
13}
14