Holds the shell blocks Claude prints for you to run, and copies them on a keypress or /copy-commands

Small mods for Claude Code.
Claude Code caches your conversation prefix between requests. While the cache is warm, the next request reads it cheaply; once it expires, the whole context is written again at the higher cache-write rate. cache-timer shows how long you have left.
cache ███████░░░░░ 35m
The bar is green with half or more of the time left, yellow from 20%, red below that, and reads cold once the cache has expired. Under two minutes the label counts seconds.
Each API response reports how many tokens it wrote to the cache at each TTL (ephemeral_1h_input_tokens, ephemeral_5m_input_tokens). cache-timer reads the newest main-thread response that wrote to the cache and uses that TTL, so it follows what Claude Code actually did: 1 hour on a subscription within its usage limits, 5 minutes on an API key, Bedrock, Vertex or Foundry, after a promptCacheTtl setting, or once a subscription goes into overage. Subagent requests keep their own cache and are ignored.
To force a TTL instead, set the plugin's ttl option to 5m or 1h (/config), and for the status line script set CACHE_TIMER_TTL=5m or 1h. Until a TTL is known it assumes CLAUDE_CODE_PROMPT_CACHE_TTL if set, otherwise 1 hour.
plugins/cache-timer/statusline/cache-bar.sh prints the bar for a Claude Code status line, in ANSI color. It needs jq and runs on macOS and Linux.
Use it on its own in ~/.claude/settings.json:
{
"statusLine": {
"type": "command",
"command": "bash /path/to/claude-code-mods/plugins/cache-timer/statusline/cache-bar.sh",
"refreshInterval": 1
}
}
Or add it to a status line script you already have, which reads the same JSON on stdin:
input=$(cat)
cache=$(printf '%s' "$input" | bash /path/to/cache-bar.sh)
refreshInterval makes the countdown move while you are idle; without it the status line only redraws on activity.
The plugin draws the same bar in the row above the prompt while Claude is idle. It uses Claude Code's plugin hooks module API (hooks/register.tsx), so it needs a Claude Code version that has it.
claude plugin marketplace add ya8282/claude-code-mods
claude plugin install cache-timer@claude-code-mods
Or load a local clone for one session with claude --plugin-dir plugins/cache-timer.
claude plugin validate plugins/cache-timer
claude plugin test plugins/cache-timer
When Claude ends a turn with commands for you to run (closing beads, git commands it cannot run itself), copy-commands holds them so you can paste them into another tmux pane or terminal.
It takes the fenced blocks tagged sh, bash, zsh, shell or console, or untagged, from the main thread's final answer, and keeps them until you copy them or a later answer brings new ones. A line above the prompt shows how many command lines are waiting:
/copy-commands copies them too.Each session holds its own, so several can finish before you paste. The copy goes through Claude Code's clipboard path (pbcopy, wl-copy, xclip, tmux's buffer, or OSC 52 over SSH).
claude plugin install copy-commands@claude-code-mods
Development: claude plugin validate plugins/copy-commands and claude plugin test plugins/copy-commands.
MIT
hooks/register.tsx 70 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface } from 'claude-code'
3
4const SHELL = new Set(['', 'sh', 'bash', 'zsh', 'shell', 'console'])
5const FENCE = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```[ \t]*$/gm
6
7const pending = atom({ plugin: 'copy-commands', key: 'pending' } as const, null)
8
9// Untagged blocks count too: printed commands are often fenced without a language.
10export const shellBlocks = (text: string): string[] =>
11 [...text.matchAll(FENCE)]
12 .filter(([, lang = '']) => SHELL.has(lang.toLowerCase()))
13 .map(([, , body = '']) => body)
14 .filter(body => body.trim() !== '')
15
16export const commandLines = (text: string): number =>
17 text.split('\n').filter(l => l.trim() && !l.trimStart().startsWith('#')).length
18
19const copyPending = async ($: EngineInterface, surface?: RenderSurface): Promise<string> => {
20 const text = await read($, pending)
21 if (text === null) return 'copy-commands: nothing waiting to copy'
22
23 const copied = await $.ui.copy({ text, surface })
24 if (!copied.isCopied) return `copy-commands: not copied (${copied.reason})`
25
26 await update($, pending, () => null)
27 return `Copied ${commandLines(text)} command line(s) to the clipboard`
28}
29
30export const register: Register = on => {
31 on('session.start', async ($, e, next) => {
32 await $.command.register({ name: 'copy-commands', description: 'Copy the commands Claude last printed for you to run' })
33 return next(e)
34 })
35
36 on('command.run', { command: 'copy-commands' }, async $ => ({ text: await copyPending($) }))
37
38 // Held until copied or replaced, so a session that finishes while you are elsewhere keeps them.
39 on('turn.complete', async ($, e, next) => {
40 const result = await next(e)
41 if (e.agentId !== undefined || e.reason !== 'answer') return result
42
43 const blocks = shellBlocks(e.answer)
44 if (blocks.length > 0) await update($, pending, () => blocks.join('\n'))
45
46 return result
47 })
48
49 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
50 const text = await read($, pending)
51 if (text === null || e.props.hasSurvey) return next(e)
52
53 const { Box, Button, Text } = $.ui.resolve(e)
54
55 return (
56 <Box>
57 <Text color="yellow">{commandLines(text)} command line(s) ready </Text>
58 <Button key="copy" hotkey="c" plain onPress={async press => $.ui.toast(await copyPending($, press.surface))}>
59 Copy
60 </Button>
61 <Text> </Text>
62 <Button key="dismiss" hotkey="x" plain onPress={() => update($, pending, () => null)}>
63 Dismiss
64 </Button>
65 <Text dimColor> (ctrl+x tab, then c)</Text>
66 </Box>
67 )
68 })
69}
70types/index.d.ts 8 lines1export type Pending = string | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 'copy-commands': { pending: Pending }
6 }
7}
8