SLOPSHOPPER

waiting

Shows, in every open Claude Code session, which other sessions are waiting for you, and sends a macOS notification when one starts waiting.

newbandguardpromptprocesstimer
v0.1.0MITupdated 2026-10-20alexandrstudio/claude-sessions/waiting
A shopper browsing a rack in a slop shop
README

claude-sessions: Claude Code mods for many sessions

The /sessions pane: toolbar buttons and every session across projects

Two mods for people who keep several Claude Code sessions going across different projects:

  • sessions: /sessions lists every session across all projects, with the command to resume any of them.
  • waiting: every session shows which other sessions are waiting for you, and you get a notification when one starts waiting.

Install either or both. At the prompt of a terminal session:

/plugin install sessions --marketplace alexandrstudio/claude-sessions
/plugin install waiting --marketplace alexandrstudio/claude-sessions

Answer y to add the marketplace, then pick a scope (user scope loads the mod in every session). From a shell:

claude plugin install sessions --marketplace alexandrstudio/claude-sessions
claude plugin install waiting --marketplace alexandrstudio/claude-sessions

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with alexandrstudio/claude-sessions, then Add plugin.


sessions

/sessions opens a pane listing every Claude Code session you have had, across all projects, newest first. /resume only shows the folder you are in; this shows them all, and gives you the command to continue any of them.

Each row: how long ago, the project folder, the session's title (or first prompt), and how many prompts you typed. Blue folders are on this machine, yellow ones exist only in the archive (see below), and ● marks a session that is running right now.

Everything is a button: click it (Claude Code's fullscreen mode reports clicks) or Tab to it and press Enter.

  • Show: [ All ] [ Active ] [ Archived ]: All lists the sessions on this machine first and the archive-only ones below them, each newest first. Active hides the archive, Archived shows only the archive. The choice is remembered for the next time.
  • [ Filter ] opens a text field: words match title, first prompt and folder; :archived shows archive-only sessions, :local the ones not archived yet, :running the open ones. Enter keeps the filter, [ Clear ] removes it. Outside the field, your typing never lands in it.
  • Click a row for the details and the resume command, cd '<folder>' && claude -r <id>: [ Copy resume command ] (paste it in a new terminal tab), [ Restore to this Mac ] for an archive-only session, [ Move to archive ] to remove the copy on this machine once the archive holds an identical one (press it twice), [ Back ].

One session: details, resume command and actions

  • [ Info ] explains all of this inside the pane; [ Settings ] holds the archive folder and the automatic archive.
  • Without a mouse: ctrl+x tab gives the pane the keys, each button's letter is a shortcut (f filter, i info, s settings, 0 refresh, 1/2/3 all/active/archived, c copy, b back), and Esc hands the keys back to the prompt.

Requires python3 on your PATH (it reads the transcripts and caches what it read in ~/.claude/cache/sessions-mod-index.json).

The archive

Give the mod an archive folder and it keeps a second copy of your sessions there, so /sessions still lists them after they are gone from ~/.claude/projects, and you can move old ones off your machine.

  • Set the folder in the pane ([ Settings ] → [ Set folder ], type it, Enter) or in /config under sessions → Archive folder, for example /Volumes/MyDrive/claude-sessions/projects. It is created on the first run. Leave it empty for no archive.
  • Archiving runs by itself when a session starts and every 6 hours, copying new and changed sessions. It never deletes from the archive. An archive on an external drive waits until the drive is plugged in. Turn it off in the settings and use [ Archive now ] instead.

Don't put the archive in a git repository you push: transcripts hold whatever the sessions saw, file contents and command output included.

Keep your sessions longer

Claude Code deletes transcripts older than 30 days by default. With the archive running, that is fine: the archive keeps the old ones. Without an archive, set cleanupPeriodDays in ~/.claude/settings.json to keep them:

{ "cleanupPeriodDays": 36500 }

Transcripts grow (roughly 0.5–1 GB a month of heavy use), so keep an eye on ~/.claude/projects.

Optional: archive while Claude Code is closed

The mod only archives while a session is open. To archive on a schedule regardless, sessions/extras/archive-sessions.sh does the same copy from a shell, and on macOS sessions/extras/claude-session-archive.plist runs it daily: fill in the two paths and follow the comment at its top. Give the script the folder above projects; it writes into projects/ inside it.


waiting

When you work in several sessions at once, one of them often sits on a permission prompt or a question while you are in another. With waiting installed, every session shows a line above its prompt while another session needs you:

The waiting line above the prompt: two other sessions need you

The line is only there while something waits. A session that has waited for 15 seconds also sends a notification, "Claude is waiting for you: <folder>: <reason>", through your usual notification setting (/config → notifications). A wait you answer within 15 seconds sends nothing.

Settings, in /config under waiting:

  • Notify (on): the notification after 15 seconds.
  • Show finished sessions (off): also list sessions whose turn has finished and that wait for your next prompt.

How it works: each session writes a small status file to ~/.claude/cache/waiting/ and reads the others' every few seconds. A session that is gone (closed, crashed) drops off the line within about 35 seconds. Only sessions that have the mod loaded take part, so install it at user scope and restart the sessions you already have open.


License

MIT

Workflow stage: solo

Source 2 files
hooks/register.tsx 167 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Beacon, SessionState, Waiter } from '../types'
5
6const others = atom({ plugin: 'waiting', key: 'others' } as const, [] as Waiter[])
7
8const HEARTBEAT_MS = 10_000
9const POLL_MS = 3_000
10// A beacon not rewritten for this long belongs to a session that is gone.
11const STALE_MS = 35_000
12// Beacons this old are deleted rather than skipped.
13const PRUNE_MS = 24 * 60 * 60 * 1000
14// Waits answered within this time never notify.
15const NOTIFY_AFTER_MS = 15_000
16
17const folderOf = (cwd: string) => cwd.split('/').filter(Boolean).slice(-2).join('/') || cwd
18
19// One session per module instance: these are this session's own beacon and
20// settings, set again on every (re)load by `register` and `session.start`.
21let shouldNotify = true
22let showYourTurn = false
23let dir = ''
24let me: Beacon | undefined
25let notifyTimer: Timer | undefined
26
27async function write($: EngineInterface) {
28  if (!me || !dir) return
29  me = { ...me, seen: await $.clock.now() }
30  await $.fs.write(`${dir}/${me.sessionId}.json`, JSON.stringify(me))
31}
32
33async function setState($: EngineInterface, state: SessionState, reason: string) {
34  if (!me) return
35  if (me.state === state && me.reason === reason) return
36  me = { ...me, state, reason, since: await $.clock.now() }
37  await write($)
38
39  notifyTimer?.cancel()
40  notifyTimer = undefined
41  if (state === 'waiting' && shouldNotify) {
42    const waitedFor = me.since
43    notifyTimer = $.clock.after(NOTIFY_AFTER_MS, () => {
44      if (me?.state !== 'waiting' || me.since !== waitedFor) return
45      void $.ui.notify(`${folderOf(me.cwd)}: ${me.reason}`, { title: 'Claude is waiting for you' })
46    })
47  }
48}
49
50async function poll($: EngineInterface) {
51  if (!dir) return
52  const now = await $.clock.now()
53  let entries: Awaited<ReturnType<EngineInterface['fs']['list']>> = []
54  try {
55    entries = await $.fs.list(dir)
56  } catch {
57    return
58  }
59  const found: Waiter[] = []
60  for (const entry of entries) {
61    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
62    if (me && entry.name === `${me.sessionId}.json`) continue
63    const path = `${dir}/${entry.name}`
64    let beacon: Beacon
65    try {
66      beacon = JSON.parse(String(await $.fs.read(path))) as Beacon
67    } catch {
68      continue
69    }
70    if (now - beacon.seen > PRUNE_MS || beacon.state === 'closed') {
71      void $.process.run(['rm', '-f', path]).catch(() => undefined)
72      continue
73    }
74    if (now - beacon.seen > STALE_MS) continue
75    const isWaiting = beacon.state === 'waiting' || (showYourTurn && beacon.state === 'idle' && beacon.reason !== '')
76    if (!isWaiting) continue
77    found.push({
78      sessionId: beacon.sessionId,
79      folder: folderOf(beacon.cwd),
80      reason: beacon.reason,
81      minutes: Math.floor((now - beacon.since) / 60_000),
82    })
83  }
84  found.sort((a, b) => b.minutes - a.minutes || a.folder.localeCompare(b.folder))
85  // Rewriting an equal list would redraw the band every poll for nothing.
86  const before = await read($, others)
87  if (JSON.stringify(before) !== JSON.stringify(found)) await update($, others, () => found)
88}
89
90export const register: Register = (on, options) => {
91  shouldNotify = options.notify !== false
92  showYourTurn = options.showYourTurn === true
93
94  on('session.start', async ($, e, next) => {
95    const home = (await $.env.get('HOME')) ?? ''
96    dir = `${home}/.claude/cache/waiting`
97    const now = await $.clock.now()
98    me = { sessionId: await $.session.id(), cwd: await $.session.cwd(), state: 'idle', reason: '', since: now, seen: now }
99    await write($)
100    $.clock.every(HEARTBEAT_MS, () => void write($))
101    $.clock.every(POLL_MS, () => void poll($))
102    void poll($)
103
104    return next(e)
105  })
106
107  on('session.end', async ($, e, next) => {
108    await setState($, 'closed', '')
109
110    return next(e)
111  })
112
113  on('prompt.submit', async ($, e, next) => {
114    await setState($, 'working', '')
115
116    return next(e)
117  })
118
119  on('classic.PermissionRequest', async ($, e, next) => {
120    await setState($, 'waiting', `permission for ${e.tool_name}`)
121
122    return next(e)
123  })
124
125  on('classic.Notification', async ($, e, next) => {
126    if (/permission/.test(e.notification_type) && me?.state !== 'waiting') {
127      await setState($, 'waiting', 'needs your permission')
128    } else if (/elicitation/.test(e.notification_type)) {
129      await setState($, 'waiting', 'has a question')
130    }
131
132    return next(e)
133  })
134
135  on('tool.call', async ($, e, next) => {
136    if (e.tool === 'AskUserQuestion') await setState($, 'waiting', 'has a question')
137    const result = await next(e)
138    // The call is through, so whatever it waited on (a question, a
139    // permission) has been answered.
140    if (me?.state === 'waiting') await setState($, 'working', '')
141
142    return result
143  })
144
145  on('turn.complete', async ($, e, next) => {
146    await setState($, 'idle', 'finished, your turn')
147
148    return next(e)
149  })
150
151  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
152    const list = await read($, others)
153    if (list.length === 0 || e.props.hasSurvey) return next(e)
154
155    const { Text } = $.ui.resolve(e)
156    const parts = list.map(w => `${w.folder}: ${w.reason}${w.minutes > 0 ? ` (${w.minutes}m)` : ''}`)
157
158    return (
159      <Text wrap="truncate">
160        <Text color="yellow">⏳ {list.length} waiting</Text>
161        <Text dimColor> · </Text>
162        {parts.join(' · ')}
163      </Text>
164    )
165  })
166}
167
types/index.d.ts 21 lines
1export type SessionState = 'working' | 'waiting' | 'idle' | 'closed'
2
3// One session's line in the shared folder, rewritten on every change and on
4// a heartbeat, so a reader can tell a live session from one that crashed.
5export type Beacon = {
6  sessionId: string
7  cwd: string
8  state: SessionState
9  reason: string
10  since: number
11  seen: number
12}
13
14export type Waiter = { sessionId: string; folder: string; reason: string; minutes: number }
15
16declare module 'claude-code' {
17  interface PluginState {
18    waiting: { others: Waiter[] }
19  }
20}
21