SLOPSHOPPER

pstash

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

newpanespinnercommandtoaststatus
★ 1v0.1.1MITupdated 2026-10-11maxsoldev/pstash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pstash
│ ┃ pstash ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing stashed. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /stash │ ⎿ pstash: nothing stashed · /stash <text> to add │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pstash
Nothing stashed.
README

pstash

Latest release License: MIT

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.

Why not just type a follow-up?

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:

  • Side questions. "How does the retry logic decide when to give up?" would derail the task Claude is on. Stash it and ask once the task is done.
  • Several ideas at once. Jot down every question or follow-up as it comes to you, then expand on them and send them one at a time, in the order you choose.

Install

In Claude Code:

/plugin install pstash --marketplace maxsoldev/pstash

Use

CommandDoes
/stash <text>Saves the text
/stash popPastes the newest stash into the input box
/stashOpens the picker

1. Stash a prompt. Type /stash and the prompt you want to send later.

Typing /stash with a prompt

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.

The prompt is stashed

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

The prompt is back in the input box

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

The picker

Storage

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 runs and sends

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.

License

MIT

Develop

claude --plugin-dir .
claude plugin validate .
claude plugin test .
Source 3 files
hooks/register.tsx 236 lines
1import { 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}
236
hooks/stash.ts 63 lines
1// 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}
63
types/index.d.ts 12 lines
1/** 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