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

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.
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.
| Mod | What it does |
|---|---|
statusline-desktop | Draws your terminal status line above the prompt in the desktop app |
forgejo-issues | Browse, search and manage the current repo's Forgejo or Gitea issues in a pane |
work-session | Kicks off and wraps up working sessions with your own start and end commands, and titles each session |
walkthrough | A checklist pane for testing by hand: mark each step, add notes, send the results back in one message |
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.
config-file) sets one; otherwise they map to the app's own theme colours.current_usage reads slightly lower than in the terminal (output tokens come through as 0)./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.
kind/bug sit under their scope, exclusive scopes pick one); a milestone picker.label:bug, -label:wontfix, label:kind/, milestone:"Some name", is:closed. Enter opens the top match.#N mentions as chips that open in the pane.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):
| Setting | Default | What it does |
|---|---|---|
apiUrl | empty | The API base when it isn't https://<remote host>/api/v1 |
requireCommentOnClose | on | Closing needs a comment, posted before the close |
requireMilestone | off | New issues need a milestone |
requireLabelFrom | empty | New issues need one of these comma-separated labels |
editorCommand | empty | Adds 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.
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.
/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.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.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):
| Setting | Default | What it does |
|---|---|---|
startCommand | cws | The command that kicks a session off, without the slash |
endCommand | ews | The command that wraps one up |
kickoff | band | band offers it, auto runs it at start, off never |
wrapAtPercent | 75 | Context fullness that brings up the wrap offer; 0 turns it off |
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.
publish_walkthrough tool refuses a step that doesn't say what to expect. Publishing again replaces the list./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.p pass, f fail, s skip, c copy, r send.Screenshots don't go through the pane: paste them in the prompt as usual. No settings.
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.
| Kind | Where | Loads |
|---|---|---|
| General, shareable | here, mods/<name>/ + an entry in .claude-plugin/marketplace.json | everywhere, once installed at user scope |
| Tied to one project, or naming private hosts | that 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.
plugin-authoring skill gives a mods folder that hot-reloads when each turn ends: the quickest loop.mods/<name>/, list it in the marketplace file, and install it from this clone (below). From then on, edits are live after /reload-plugins.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.claude plugin test runs *.test.ts(x) against the engine itself; a test stands in for engine calls with on('<event>', () => ({ value: … })).userConfig (shown in the plugin's options); nothing personal is hard-coded.~/.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).$.state) outlives reloads and upgrades: read and update it through helpers that fill in defaults, or a field a new version adds arrives undefined.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).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.Client per clickable item makes hit-testing unnecessary.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).<mod>: <event> hook skipped: threw … names the handler that did.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>hooks/register.tsx 198 lines1import { 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}
198hooks/session.ts 78 lines1/** 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(' ')
78hooks/pill.tsx 42 lines1import 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
42types/index.d.ts 14 lines1export 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