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

Two mods for people who keep several Claude Code sessions going across different projects:
/sessions lists every session across all projects, with the command to resume any of them.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 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.
: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.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 ].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).
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.
/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.Don't put the archive in a git repository you push: transcripts hold whatever the sessions saw, file contents and command output included.
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.
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.
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 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:
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.
MIT
Workflow stage: solo
hooks/register.tsx 167 lines1import { 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}
167types/index.d.ts 21 lines1export 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