/stash: a git-stash-like stack of prompts, kept between sessions, popped back into the prompt box.

A Claude Code mod (a plugin built on function hooks) that gives prompts a git-stash-like stack:
/stash <prompt> pushes a prompt. Entry 1 is the newest./stash list lists entries by number and first line./stash pop [n] puts entry n (default 1) back in the prompt box, editable, and removes it from the stash only once the box took it.Every subcommand answers locally: no model turn, nothing in the model's context. The stash lives in the plugin's $.store, so it survives sessions.
Function hooks are early access (anthropics/claude-code#91870). The API can change between releases.
Claude Code has a built-in prompt stash on Ctrl+S (the chat:stash keybinding). With text in the input, Ctrl+S stashes it and clears the prompt. On an empty prompt, Ctrl+S restores the stashed text, cursor position and pasted content (interactive mode docs).
The main difference: Ctrl+S holds one prompt, /stash holds a stack.
| Ctrl+S | /stash | |
|---|---|---|
| Entries | One | A stack, as many as the 4 MiB store holds |
| Stash | One key, takes the current draft | Type /stash before the prompt |
| Restore | One key, on an empty prompt | /stash pop [n], any entry |
| See what is stashed | No | /stash list |
| Restores cursor, pastes | Yes | No: text only, cursor at the end |
| Across sessions | Not documented | Yes: a JSON file in the plugin's store |
| Needs function hooks | No | Yes (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1) |
Use Ctrl+S to park one draft for a moment. Use /stash to keep several prompts, or to keep them after you quit.
The two do not share state: /stash pop does not see what Ctrl+S stashed.
This repository is its own plugin marketplace. In Claude Code:
/plugin marketplace add gonzaloserrano/cc-prompt-stash /plugin install prompt-stash@cc-prompt-stash
Or from a shell:
claude plugin marketplace add gonzaloserrano/cc-prompt-stash claude plugin install prompt-stash@cc-prompt-stash
Mods load only where function hooks are enabled. Start Claude Code with the flag set, for example by exporting it in your shell profile:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir .
The type declarations are not in this repository. Generate them first: in a Claude Code session started in this folder with function hooks enabled, run /plugin-types. It writes them to .claude/types/, which git ignores.
npx -p typescript@5 tsc -p tsconfig.json CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .
Built against Claude Code 2.1.280. Typecheck passes. 12 of 12 tests pass. Stash, list and pop work in a live session.
The engine skips a hook that throws and runs what is beneath it (fail open). So the /stash hook catches every error and shows it, with the arguments and the offending value, as its own output.
Open:
$.store write: a test hook that throws is skipped too, so it cannot simulate one.list or pop runs that subcommand.hooks/register.ts 193 lines1import type { EngineInterface, On } from 'claude-code'
2
3/**
4 * `/stash`: a stack of prompts, newest first, numbered from 1 as `/stash list`
5 * shows them. Every answer is a hook's own `{ text }` with no `context`, so
6 * no subcommand starts a model turn or puts anything in the model's context.
7 */
8
9export const COMMAND_NAME = 'stash'
10
11export const STORE_KEY = 'stack'
12
13export const PREVIEW_CHARS = 72
14
15export const USAGE_TEXT =
16 'usage: /stash <prompt> | /stash list | /stash pop [n]'
17
18export const EMPTY_TEXT = 'stash is empty'
19
20const COMMAND_SPEC = {
21 name: COMMAND_NAME,
22 description:
23 'Stash a prompt, list stashed prompts, or pop one into the prompt box',
24 argumentHint: '<prompt> | list | pop [n]',
25 immediate: true,
26} as const
27
28/**
29 * The stack as `$.store` holds it: the store is a JSON file on disk, so its
30 * value is checked here and a malformed one throws, naming what was found.
31 */
32export function stackOf(value: unknown): string[] {
33 if (value === undefined) {
34 return []
35 }
36
37 if (
38 !Array.isArray(value) ||
39 !value.every(entry => typeof entry === 'string')
40 ) {
41 throw new Error(
42 `prompt-stash: store key "${STORE_KEY}" is not an array of strings: ${JSON.stringify(value)}`,
43 )
44 }
45
46 return value
47}
48
49/**
50 * One `/stash list` row: the entry's number, right-aligned to `width`, and
51 * its first line, cut to PREVIEW_CHARS, with the count of lines left out.
52 */
53export function previewOf(prompt: string, n: number, width: number): string {
54 const [first = '', ...more] = prompt.split('\n')
55 const cut =
56 first.length > PREVIEW_CHARS
57 ? `${first.slice(0, PREVIEW_CHARS - 1)}…`
58 : first
59 const extra = more.length > 0 ? ` (+${more.length} lines)` : ''
60
61 return ` ${String(n).padStart(width)} ${cut}${extra}`
62}
63
64/**
65 * `/stash list`'s output for a non-empty stack. The engine prints the plugin's
66 * name before the first line, so that line is a header and every entry starts
67 * a line of its own.
68 */
69export function listTextOf(stack: readonly string[]): string {
70 const width = String(stack.length).length
71 const noun = stack.length === 1 ? 'prompt' : 'prompts'
72 const rows = stack.map((prompt, i) => previewOf(prompt, i + 1, width))
73
74 return [`${stack.length} stashed ${noun}, newest first`, ...rows].join('\n')
75}
76
77/**
78 * The 0-based index `/stash pop [n]` names: `n` counts from 1, newest first,
79 * and defaults to 1. Returns an error text for anything else.
80 */
81export function indexOf(
82 arg: string | undefined,
83 size: number,
84): { index: number } | { error: string } {
85 if (size === 0) {
86 return { error: EMPTY_TEXT }
87 }
88
89 if (arg === undefined) {
90 return { index: 0 }
91 }
92
93 if (!/^[1-9][0-9]*$/.test(arg)) {
94 return {
95 error: `stash entry must be a number from 1 to ${size}, got "${arg}"`,
96 }
97 }
98
99 const n = Number(arg)
100
101 if (n > size) {
102 return { error: `no stash entry ${n}: the stash holds ${size}` }
103 }
104
105 return { index: n - 1 }
106}
107
108export function register(on: On) {
109 on('session.start', async ($, e, next) => {
110 await $.command.register(COMMAND_SPEC)
111
112 return next(e)
113 })
114
115 // The engine skips a hook that throws and runs what is beneath it, so every
116 // failure is caught here and shown as /stash's own output instead.
117 on('command.run', { command: COMMAND_NAME }, async ($, e) => {
118 try {
119 return await answer($, e.args)
120 } catch (err) {
121 return { text: failureTextOf(e.args, err) }
122 }
123 })
124}
125
126/**
127 * The output /stash shows when a subcommand throws: the arguments as typed
128 * and the error's message.
129 */
130export function failureTextOf(args: string, err: unknown): string {
131 const message = err instanceof Error ? err.message : String(err)
132
133 return `/stash ${args.trim()} failed: ${message}`
134}
135
136async function answer(
137 $: EngineInterface,
138 typed: string,
139): Promise<{ text: string }> {
140 const args = typed.trim()
141 const [sub, ...rest] = args.split(/\s+/)
142 const stack = stackOf(await $.store.get(STORE_KEY))
143
144 if (args === '') {
145 return { text: USAGE_TEXT }
146 }
147
148 if (sub === 'list' && rest.length === 0) {
149 if (stack.length === 0) {
150 return { text: EMPTY_TEXT }
151 }
152
153 return { text: listTextOf(stack) }
154 }
155
156 if (sub === 'pop' && rest.length <= 1) {
157 const picked = indexOf(rest[0], stack.length)
158
159 if ('error' in picked) {
160 return { text: picked.error }
161 }
162
163 const prompt = stack[picked.index]
164
165 if (prompt === undefined) {
166 throw new Error(
167 `prompt-stash: index ${picked.index} checked against ${stack.length} entries but missing`,
168 )
169 }
170
171 const { isFilled } = await $.prompt.fill({ text: prompt, mode: 'replace' })
172
173 if (!isFilled) {
174 return {
175 text: `the prompt box did not take stash entry ${picked.index + 1}; it stays in the stash`,
176 }
177 }
178
179 await $.store.set(
180 STORE_KEY,
181 stack.filter((_, i) => i !== picked.index),
182 )
183
184 return {
185 text: `popped stash entry ${picked.index + 1} into the prompt box`,
186 }
187 }
188
189 await $.store.set(STORE_KEY, [args, ...stack])
190
191 return { text: `stashed as entry 1 (${stack.length + 1} in the stash)` }
192}
193