Stash prompts for later and pop them back into the input box. Instant, no model call.

Stash prompts for later and pop them back into the input box. A Claude Code mod: instant, no model call, works while Claude is busy, and the stashed text never reaches the model.
A message you type while Claude is working is queued. Claude reads it during the current turn or right after it, so it becomes part of the work in progress. That's right for "also update the docs", and Claude often does those things anyway.
Stash is for prompts you don't want sent yet:
In Claude Code:
/plugin install pstash --marketplace maxsoldev/pstash
| Command | Does |
|---|---|
/stash <text> | Saves the text |
/stash pop | Pastes the newest stash into the input box |
/stash | Opens the picker |
1. Stash a prompt. Type /stash and the prompt you want to send later.

2. It's saved. Press Enter: the prompt is stashed and the footer count goes up. The stashed text stays out of the chat and out of the model's context.

3. Pop it back. /stash pop pastes the newest stash into the input box, ready to edit or send.

Or pick one. Bare /stash lists everything: Enter or 1-9 pastes, ↑↓ move, x deletes the highlighted prompt, Esc closes.

One JSON file per project, shared by all sessions: ~/.pstash/<project path with non-alphanumerics as ->.json. Set PSTASH_DIR to move it.
{
"version": 1,
"project": "/Users/max/app",
"items": [
{
"id": "lx2k9a3f",
"text": "how does the retry logic decide when to give up...",
"at": "2026-10-11T14:20:00.000Z",
"from": "claude"
}
]
}
Items are newest first, capped at 100, without duplicates. Each change re-reads the file, so sessions don't overwrite each other, and a file that doesn't parse is never overwritten. Any tool that follows this format can share the stash.
What it sends, and where. Nothing. Stashes stay on your machine in ~/.pstash/. The mod makes no network calls and no model calls.
Which programs it runs. None.
What it changes in your prompts. Nothing. It submits no prompts and adds nothing to yours.
What it changes in the chat. In the record of a /stash run it blanks the command's arguments, so the stashed text never reaches Claude. Claude only sees /stash and the short output line, like stashed · 3.
What it reads. The HOME and PSTASH_DIR environment variables, to find the stash folder, and the session's project root, to pick the file.
What each hook does.
session.start: adds the /stash command and loads the stash count.command.run for stash: saves, pops or opens the picker.session.append for command rows: blanks the arguments of a /stash run.prompt.submit, turn.start, turn.complete: notice a /stash run outside a turn, so its spinner can be hidden. Prompts pass through unchanged.ui.render for Spinner: draws the spinner blank while /stash runs outside a turn.ui.render for SessionMode: adds stash n to the footer.ui.render for its pane: draws the picker.ui.focus for its pane: keeps the highlight on the prompts, so x always knows which one to delete.claude --plugin-dir .
claude plugin validate .
claude plugin test .hooks/register.tsx 236 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Item, StashFile } from '../types'
5import { add, empty, fileName, parse, preview, remove, serialize } from './stash'
6
7const PANE = 'pstash'
8const SHOWN = 12
9const NOT_PASTED = 'could not paste into the prompt; kept it'
10const items = atom({ plugin: 'pstash', key: 'items' } as const, [] as Item[])
11// True while a /stash run is under way, so its spinner can be blanked.
12const isRunning = atom({ plugin: 'pstash', key: 'isRunning' } as const, false)
13// The id of the picker row that holds the focus ring, for `x` to delete.
14const focused = atom({ plugin: 'pstash', key: 'focused' } as const, null as string | null)
15
16// The transcript row of a `/stash ...` run, and the args in it to blank out.
17const STASH_ROW = /<command-name>\/?(?:pstash:)?stash<\/command-name>/
18const STASH_PROMPT = /^\s*\/(?:pstash:)?stash(?:\s|$)/
19const ARGS = /<command-args>[\s\S]*?<\/command-args>/g
20
21type $ = EngineInterface
22
23/** `$PSTASH_DIR`, else `~/.pstash`, plus this project's file name. */
24async function location($: $): Promise<{ path: string; project: string }> {
25 const dir = (await $.env.get('PSTASH_DIR')) ?? `${await $.env.get('HOME')}/.pstash`
26 const project = await $.session.root()
27 return { path: `${dir.replace(/\/$/, '')}/${fileName(project)}`, project }
28}
29
30/** Reads the file fresh each time, so other sessions' changes are never lost. */
31async function load($: $): Promise<StashFile> {
32 const { path, project } = await location($)
33 if (!(await $.fs.exists(path))) return empty(project)
34 try {
35 return parse(await $.fs.read(path), project)
36 } catch (error) {
37 throw new Error(`${path} is unreadable (${String(error)}); fix or delete it`)
38 }
39}
40
41async function change($: $, edit: (file: StashFile) => StashFile): Promise<StashFile> {
42 const { path } = await location($)
43 const file = edit(await load($))
44 await $.fs.write(path, serialize(file))
45 await show($, file.items)
46 return file
47}
48
49async function show($: $, list: Item[]): Promise<void> {
50 await update($, items, () => list)
51}
52
53// Each command answers with one short line: a run that prints nothing has its
54// row removed again, which flickers the screen.
55
56async function stash($: $, text: string): Promise<string> {
57 const now = await $.clock.now()
58 const item: Item = {
59 id: `${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`,
60 text,
61 at: new Date(now).toISOString(),
62 from: 'claude',
63 }
64 const file = await change($, f => add(f, item))
65 return `stashed · ${file.items.length}`
66}
67
68/** Pastes at the cursor first and removes only once the box took it. */
69async function pop($: $, item: Item): Promise<string> {
70 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
71 if (isOpen) await $.ui.close({ id: PANE })
72 const filled = await $.prompt.fill({ text: item.text, mode: 'insert' })
73 if (!filled.isFilled) return NOT_PASTED
74 const file = await change($, f => remove(f, item.id))
75 return `pasted · ${file.items.length} left`
76}
77
78async function popNewest($: $): Promise<string> {
79 const newest = (await load($)).items[0]
80 return newest === undefined ? 'nothing stashed' : pop($, newest)
81}
82
83async function openPicker($: $): Promise<string> {
84 const file = await load($)
85 await show($, file.items)
86 if (file.items.length === 0) return 'nothing stashed · /stash <text> to add'
87 const rows = Math.min(file.items.length, SHOWN) + 1
88 await $.ui.open({ id: PANE, title: 'Stash', focus: true, closeOnEscape: true, rows })
89 return `${file.items.length} stashed`
90}
91
92/** Deletes the highlighted row (else the first) and moves the ring to the row now in its place. */
93async function deleteFocused($: $): Promise<void> {
94 const list = await read($, items)
95 const id = await read($, focused)
96 const at = Math.max(0, list.findIndex(item => item.id === id))
97 const target = list[at]
98 if (target === undefined) return
99 const file = await change($, f => remove(f, target.id))
100 const next = file.items[Math.min(at, file.items.length - 1)]
101 if (next === undefined) return $.ui.close({ id: PANE })
102 await update($, focused, () => next.id)
103 // A hotkey press leaves the ring on its own button once the press ends.
104 $.clock.after(30, () => void $.ui.focus({ requestId: PANE, key: `pop-${next.id}` }))
105}
106
107function message(error: unknown): string {
108 return `pstash: ${error instanceof Error ? error.message : String(error)}`
109}
110
111function failed($: $, error: unknown): void {
112 $.ui.toast(message(error), { timeoutMs: 8000 })
113}
114
115export const register: Register = on => {
116 on('session.start', async ($, e, next) => {
117 await $.command.register({
118 name: 'stash',
119 description: 'Stash a prompt for later; bare /stash picks one to paste back',
120 argumentHint: '[text | pop]',
121 immediate: true,
122 })
123 $.ui.status(undefined) // clears the line 0.1.0 pinned
124 await update($, isRunning, () => false) // a reload mid-run leaves it set
125 await load($).then(file => show($, file.items), error => failed($, error))
126 return next(e)
127 })
128
129 // Claude Code draws its "thinking" spinner while any command runs; the
130 // flag is set as early as the prompt is seen, so the first frame is blank too.
131 let isTurn = false
132 on('turn.start', async ($, e, next) => {
133 isTurn = true
134 return next(e)
135 })
136 on('turn.complete', async ($, e, next) => {
137 isTurn = false
138 return next(e)
139 })
140 on('prompt.submit', async ($, e, next) => {
141 if (STASH_PROMPT.test(e.text)) await update($, isRunning, () => true)
142 return next(e)
143 })
144
145 on('command.run', { command: 'stash' }, async ($, e) => {
146 const args = e.args.trim()
147 await update($, isRunning, () => true)
148 try {
149 if (args === '') return { text: await openPicker($) }
150 if (args === 'pop') return { text: await popNewest($) }
151 return { text: await stash($, args) }
152 } catch (error) {
153 return { text: message(error) }
154 } finally {
155 await update($, isRunning, () => false)
156 }
157 })
158
159 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
160 if (isTurn || !(await read($, isRunning))) return next(e)
161 const { Text } = $.ui.resolve(e)
162 return <Text> </Text>
163 })
164
165 // Keep stashed text out of the conversation (the screen draws the same row).
166 on('session.append', { door: 'command' }, ($, e, next) => {
167 const textOf = (b: { type: string; text?: unknown }) =>
168 b.type === 'text' && typeof b.text === 'string' ? b.text : undefined
169 const isStash = e.message.content.some(b => STASH_ROW.test(textOf(b) ?? ''))
170 if (!isStash) return next(e)
171 const content = e.message.content.map(b => {
172 const text = textOf(b)
173 return text === undefined ? b : { ...b, text: text.replace(ARGS, '<command-args></command-args>') }
174 })
175 return next({ ...e, message: { ...e.message, content } })
176 })
177
178 // The count, as a dim mode label at the right of the prompt footer.
179 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
180 const count = (await read($, items)).length
181 if (count === 0) return next(e)
182 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, `stash ${count}`] } })
183 })
184
185 // The ring stays on the rows: never on the delete control or the close mark.
186 on('ui.focus', { requestId: PANE }, async ($, e, next) => {
187 const element = e.element ?? ''
188 if (!element.startsWith('pop-')) {
189 const id = await read($, focused)
190 return id === null ? { deny: 'rows only' } : next({ ...e, element: `pop-${id}` })
191 }
192 await update($, focused, () => element.slice('pop-'.length))
193 return next(e)
194 })
195
196 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
197 const { Box, Button, Text } = $.ui.resolve(e)
198 const list = await read($, items)
199 const width = Math.max(10, e.props.bodyColumns - 4)
200 if (list.length === 0) return <Text dimColor>Nothing stashed.</Text>
201
202 return (
203 <Box flexDirection="column">
204 {list.map((item, i) => (
205 <Button
206 key={`pop-${item.id}`}
207 plain
208 {...(i < 9 ? { hotkey: String(i + 1) } : {})}
209 {...(i === 0 ? { autoFocus: true as const } : {})}
210 onPress={() =>
211 pop($, item).then(
212 text => text === NOT_PASTED && $.ui.toast(text),
213 error => failed($, error),
214 )
215 }
216 >
217 {preview(item.text, width)}
218 </Button>
219 ))}
220 <Box>
221 <Text dimColor>enter or 1-9 paste · ↑↓ move · </Text>
222 <Button
223 key="delete"
224 plain
225 dimColor
226 hotkey="x"
227 label="delete"
228 onPress={() => deleteFocused($).catch(error => failed($, error))}
229 />
230 <Text dimColor> · esc close</Text>
231 </Box>
232 </Box>
233 )
234 })
235}
236hooks/stash.ts 63 lines1// The stash file format and the operations on it. No engine calls here:
2// any harness can read and write the same files.
3import type { Item, StashFile } from '../types'
4
5export const LIMIT = 100
6
7/** `/Users/max/app` -> `-Users-max-app.json`, one file per project. */
8export function fileName(project: string): string {
9 return `${project.replace(/[^A-Za-z0-9]/g, '-')}.json`
10}
11
12export function empty(project: string): StashFile {
13 return { version: 1, project, items: [] }
14}
15
16/** Parses a stash file; throws on anything it does not recognise, so a bad file is never overwritten. */
17export function parse(text: string, project: string): StashFile {
18 const data: unknown = JSON.parse(text)
19 if (!isRecord(data) || data.version !== 1 || !Array.isArray(data.items)) {
20 throw new Error('not a pstash v1 file')
21 }
22 const items = data.items.filter(isItem)
23 return { version: 1, project: typeof data.project === 'string' ? data.project : project, items }
24}
25
26export function serialize(file: StashFile): string {
27 return `${JSON.stringify(file, null, 2)}\n`
28}
29
30/** Puts `item` first; an item with the same text moves up instead of repeating. */
31export function add(file: StashFile, item: Item): StashFile {
32 const rest = file.items.filter(one => one.text !== item.text)
33 return { ...file, items: [item, ...rest].slice(0, LIMIT) }
34}
35
36export function remove(file: StashFile, id: string): StashFile {
37 return { ...file, items: file.items.filter(one => one.id !== id) }
38}
39
40/** First line of `text`, cut to `width` cells with an ellipsis. */
41export function preview(text: string, width: number): string {
42 const lines = text.trim().split('\n')
43 const first = lines[0] ?? ''
44 const more = lines.length > 1 ? ' …' : ''
45 const room = Math.max(1, width - more.length)
46 const cut = first.length > room ? `${first.slice(0, room - 1)}…` : first
47 return cut + more
48}
49
50function isRecord(value: unknown): value is Record<string, unknown> {
51 return typeof value === 'object' && value !== null && !Array.isArray(value)
52}
53
54function isItem(value: unknown): value is Item {
55 return (
56 isRecord(value) &&
57 typeof value.id === 'string' &&
58 typeof value.text === 'string' &&
59 typeof value.at === 'string' &&
60 typeof value.from === 'string'
61 )
62}
63types/index.d.ts 12 lines1/** One stashed prompt, as kept in the stash file. */
2export type Item = { id: string; text: string; at: string; from: string }
3
4/** The stash file of one project: `~/.pstash/<encoded project path>.json`. */
5export type StashFile = { version: 1; project: string; items: Item[] }
6
7declare module 'claude-code' {
8 interface PluginState {
9 pstash: { items: Item[]; isRunning: boolean; focused: string | null }
10 }
11}
12