Toasts in every Claude Code session when another session is blocked on a permission prompt, a question, or an MCP form.

GNU Stow-based dotfiles for macOS and Linux. One stow package, home/, mirrors $HOME.
git clone --recursive https://github.com/sustinbebustin/dotfiles.git ~/.dotfiles
~/.dotfiles/dot init
dot init installs Homebrew packages, symlinks configs, and sets zsh as the default shell. Open a new terminal when it finishes.
Everything under home/ maps 1:1 to $HOME -- home/.config/zsh/ becomes ~/.config/zsh/. Stow is run with --no-folding so each file gets its own symlink rather than the directory being linked wholesale.
Claude Code skills are the exception. The tree at home/.claude/skills/ is skipped by stow and published to ~/.claude/skills by dot stow itself, one folder symlink per skill. Skills may be grouped under a one-level category dir for clarity; that layer is flattened away when linking, since Claude Code only reads ~/.claude/skills/<name>/SKILL.md.
A category dir holding .claude-plugin/plugin.json doubles as a Claude Code plugin, listed in the root .claude-plugin/marketplace.json. dot ignores the hidden manifest dir, so local linking is unchanged. The meta skills install without cloning this repo:
claude plugin marketplace add sustinbebustin/.dotfiles --sparse .claude-plugin
claude plugin install meta@sustinbebustin
Claude Code mods in home/.claude/mods/ are published the same way, one folder symlink per mod into ~/.claude/mods, because the plugin loader rejects per-file links. settings.base.json points CLAUDE_CODE_PLUGIN_DIRS at that folder, so every mod there loads in every session.
The Go hooks in home/.claude/hooks/ (PreToolUse guards, PostToolUse format/lint) are stowed as built *-bin binaries only -- dot stow builds them first, since they are per-platform and gitignored -- and registered in home/.claude/settings.base.json, which dot stow merges into the gitignored home/.claude/settings.json.
~/.dotfiles/
├── dot # CLI (v4.0.0)
├── packages/
│ └── Brewfile # Homebrew packages
├── home/ # the stow package -> $HOME
│ ├── .zshenv
│ ├── .claude/ # Claude Code config
│ │ ├── skills/ # agent skills (published by dot, not stow)
│ │ └── hooks/ # Go guard hooks (sources ignored, *-bin stowed)
│ └── .config/
│ ├── zsh/ # shell (ZDOTDIR)
│ ├── starship.toml # prompt
│ ├── git/ # git config + aliases
│ ├── tmux/ # tmux + TPM (submodule)
│ ├── ripgrep/ # rg defaults
│ └── karabiner/ # key remapping (macOS)
└── docs/
dot init Full bootstrap (packages, stow, shell)
dot stow Re-stow dotfiles
dot update Pull, update packages, re-stow
dot doctor Health check with remediation hints
dot package add Install and track in Brewfile
dot package remove Remove from package list
dot package list Show packages with install status
dot claude add Add a Claude account (~/.claude-<name>, run as <name>)
dot claude remove Unlink a Claude account, keeping its login
dot claude list Show Claude accounts
dot retry-failed Retry failed package installations
dot link Symlink dot into ~/.local/bin
dot edit Open dotfiles in $EDITOR
dot gen-ssh-key Generate ed25519 SSH key
dot completions Generate zsh completions
hooks/register.ts 212 lines1/**
2 * Waiting on You: a toast in every Claude Code session when another session is
3 * blocked on the person (a permission prompt, an AskUserQuestion, an MCP form).
4 *
5 * Each session tracks its own waits from the hooks below and publishes them as
6 * a beacon file, `<dir>/<sessionId>.json`, rewritten on every change and every
7 * tick as a heartbeat. Each tick reads the other sessions' fresh beacons and
8 * toasts each wait it has not seen. A toast is local to its session, and mods
9 * have no channel between sessions, hence the files.
10 *
11 * Known gap: no event marks a permission prompt answered, so an approved call
12 * counts as blocked until the tool finishes.
13 */
14
15import type { EngineInterface, Register, Timer } from 'claude-code'
16import { Beacon, diffNewWaits, toastText, Wait } from './waits'
17
18const TICK_MS = 2_000
19const QUESTION_TOOL = 'AskUserQuestion'
20/** Long enough to notice from another pane; the engine's 4 s default is easy to miss. */
21const TOAST_MS = 15_000
22/** A beacon this old is a crashed session's leftover and is deleted. */
23const BEACON_PRUNE_MS = 60 * 60 * 1_000
24
25type Running = {
26 dir: string
27 project: string
28 waits: Wait[]
29 /** Wait ids already toasted; `undefined` until the first tick seeds it, so a (re)load toasts nothing old. */
30 seen: Set<string> | undefined
31 timer: Timer
32 isTicking: boolean
33 hasWriteFailed: boolean
34}
35
36/** This session's state; `undefined` before `session.start` and after the session ends. */
37let running: Running | undefined
38
39const beaconDirOf = async ($: EngineInterface): Promise<string> => {
40 const base = (await $.env.get('XDG_RUNTIME_DIR')) ?? (await $.env.get('TMPDIR')) ?? '/tmp'
41 return `${base.replace(/\/+$/, '')}/claude-waiting-on-you`
42}
43
44const basename = (path: string): string => path.replace(/\/+$/, '').split('/').pop() || path
45
46const publish = async ($: EngineInterface, state: Running): Promise<void> => {
47 const sessionId = await $.session.id()
48 const path = `${state.dir}/${sessionId}.json`
49 const beacon: Beacon = { sessionId, project: state.project, waits: state.waits, seenAt: await $.clock.now() }
50 try {
51 await $.fs.write(path, Beacon.serialize(beacon))
52 state.hasWriteFailed = false
53 } catch (error) {
54 // Said once per failing streak: the heartbeat would repeat it every tick.
55 if (!state.hasWriteFailed) {
56 $.ui.log(
57 `could not write ${path} (${error instanceof Error ? error.message : String(error)}); other sessions will not see this one blocked until it can. Check that ${state.dir} is writable.`,
58 )
59 }
60 state.hasWriteFailed = true
61 }
62}
63
64const setWaits = async ($: EngineInterface, waits: Wait[]): Promise<void> => {
65 if (running === undefined) return
66 running.waits = waits
67 await publish($, running)
68}
69
70const addWait = ($: EngineInterface, wait: Wait): Promise<void> =>
71 setWaits($, [...(running?.waits ?? []).filter(held => held.key !== wait.key), wait])
72
73const removeWait = async ($: EngineInterface, key: string): Promise<void> => {
74 const waits = running?.waits ?? []
75 if (waits.some(held => held.key === key)) await setWaits($, waits.filter(held => held.key !== key))
76}
77
78/**
79 * Ends the permission wait for a finished tool call: the one whose input
80 * matches, else the oldest for the same tool, since a hook above this one may
81 * have rewritten the input the prompt was shown for.
82 */
83const endPermission = async ($: EngineInterface, tool: string, key: string): Promise<void> => {
84 const waits = running?.waits ?? []
85 const match =
86 waits.find(held => held.key === key) ??
87 waits.find(held => held.kind === 'permission' && held.key.startsWith(Wait.toolPrefix(tool)))
88 if (match !== undefined) await setWaits($, waits.filter(held => held !== match))
89}
90
91const clearWaits = async ($: EngineInterface): Promise<void> => {
92 if ((running?.waits.length ?? 0) > 0) await setWaits($, [])
93}
94
95const readBeacons = async ($: EngineInterface, state: Running, ownId: string, now: number): Promise<Beacon[]> => {
96 let entries
97 try {
98 entries = await $.fs.list(state.dir)
99 } catch {
100 // The directory went away (a tmp cleaner): make it again and read next tick.
101 await $.process.run(['mkdir', '-p', state.dir]).catch(() => undefined)
102 return []
103 }
104 const names = entries.filter(entry => entry.kind === 'file' && entry.name.endsWith('.json') && entry.name !== `${ownId}.json`)
105 const stale = names.filter(entry => now - entry.mtimeMs > BEACON_PRUNE_MS).map(entry => `${state.dir}/${entry.name}`)
106 if (stale.length > 0) await $.process.run(['rm', '-f', ...stale]).catch(() => undefined)
107
108 const read = await Promise.all(
109 names
110 .filter(entry => now - entry.mtimeMs <= BEACON_PRUNE_MS)
111 .map(entry => $.fs.read(`${state.dir}/${entry.name}`).then(Beacon.parse, () => undefined)),
112 )
113 return read.filter((beacon): beacon is Beacon => beacon !== undefined && beacon.sessionId !== ownId && Beacon.isFresh(beacon, now))
114}
115
116const tick = async ($: EngineInterface, state: Running): Promise<void> => {
117 if (state.isTicking) return
118 state.isTicking = true
119 try {
120 await publish($, state)
121 const ownId = await $.session.id()
122 const beacons = await readBeacons($, state, ownId, await $.clock.now())
123 const { fresh, current } = diffNewWaits(state.seen ?? new Set(), beacons)
124 if (state.seen !== undefined) for (const sighting of fresh) $.ui.toast(toastText(sighting), { timeoutMs: TOAST_MS })
125 state.seen = current
126 } finally {
127 state.isTicking = false
128 }
129}
130
131export const register: Register = on => {
132 on('session.start', async ($, e, next) => {
133 const result = await next(e)
134 running?.timer.cancel()
135 const dir = await beaconDirOf($)
136 await $.process.run(['mkdir', '-p', dir]).catch(() => undefined)
137 const state: Running = {
138 dir,
139 project: basename(e.cwd),
140 waits: [],
141 seen: undefined,
142 timer: $.clock.every(TICK_MS, () => void tick($, state)),
143 isTicking: false,
144 hasWriteFailed: false,
145 }
146 running = state
147 await tick($, state)
148 return result
149 })
150
151 on('session.end', async ($, e, next) => {
152 const state = running
153 if (state !== undefined) {
154 state.waits = []
155 await $.process.run(['rm', '-f', `${state.dir}/${e.sessionId}.json`]).catch(() => undefined)
156 // A /clear goes on under a new session id with no session.start: keep ticking.
157 if (e.reason !== 'clear') {
158 state.timer.cancel()
159 running = undefined
160 }
161 }
162 return next(e)
163 })
164
165 on('classic.PermissionRequest', async ($, e, next) => {
166 const result = await next(e)
167 // A settings hook beneath that decided means no prompt is shown. AskUserQuestion
168 // asks permission too, but its question wait (tool.call) already covers the dialog.
169 if (result.decision === undefined && e.tool_name !== QUESTION_TOOL) await addWait($, Wait.permission(e.tool_name, e.tool_input, await $.clock.now()))
170 return result
171 })
172
173 on('tool.call', async ($, e, next) => {
174 const { tool, tool_use_id, agentId: _agentId, requestMeta: _requestMeta, ...args } = e
175 if (tool === QUESTION_TOOL) {
176 const wait = Wait.question(tool_use_id, args, await $.clock.now())
177 await addWait($, wait)
178 try {
179 return await next(e)
180 } finally {
181 await removeWait($, wait.key)
182 }
183 }
184 try {
185 return await next(e)
186 } finally {
187 await endPermission($, tool, Wait.toolKey(tool, args))
188 }
189 })
190
191 on('classic.Elicitation', async ($, e, next) => {
192 await addWait($, Wait.elicitation(e.mcp_server_name, e.elicitation_id, await $.clock.now()))
193 return next(e)
194 })
195
196 on('classic.ElicitationResult', async ($, e, next) => {
197 await removeWait($, Wait.elicitationKey(e.mcp_server_name, e.elicitation_id))
198 return next(e)
199 })
200
201 // Backstops: a new prompt or a finished turn means nothing is waiting on the person.
202 on('prompt.submit', async ($, e, next) => {
203 await clearWaits($)
204 return next(e)
205 })
206
207 on('turn.complete', async ($, e, next) => {
208 await clearWaits($)
209 return next(e)
210 })
211}
212hooks/waits.ts 160 lines1/**
2 * What a session is blocked on, and the beacon file each session publishes so
3 * every other session can see it. Pure: no `$`.
4 */
5
6export type WaitKind = 'permission' | 'question' | 'elicitation'
7
8/** One thing a session waits on the person for. `key` matches the wait's start to its end. */
9export type Wait = {
10 kind: WaitKind
11 key: string
12 label: string
13 since: number
14}
15
16const isRecord = (value: unknown): value is Record<string, unknown> =>
17 typeof value === 'object' && value !== null && !Array.isArray(value)
18
19const isWaitKind = (value: unknown): value is WaitKind =>
20 value === 'permission' || value === 'question' || value === 'elicitation'
21
22const isWait = (value: unknown): value is Wait =>
23 isRecord(value) &&
24 isWaitKind(value.kind) &&
25 typeof value.key === 'string' &&
26 typeof value.label === 'string' &&
27 typeof value.since === 'number'
28
29/** JSON with object keys sorted, so equal inputs give equal keys whatever their key order. */
30const canonicalJson = (value: unknown): string =>
31 JSON.stringify(value, (_key, inner: unknown) =>
32 isRecord(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : inner,
33 ) ?? 'undefined'
34
35const LABEL_DETAIL_MAX = 60
36
37const clip = (text: string): string => {
38 const line = text.replace(/\s+/g, ' ').trim()
39 return line.length > LABEL_DETAIL_MAX ? `${line.slice(0, LABEL_DETAIL_MAX - 1)}…` : line
40}
41
42/** The argument that says most about a tool call, for the toast. */
43const toolDetail = (input: unknown): string | undefined => {
44 if (!isRecord(input)) return undefined
45 for (const field of ['command', 'file_path', 'notebook_path', 'url', 'pattern', 'query']) {
46 const value = input[field]
47 if (typeof value === 'string' && value.trim() !== '') return clip(value)
48 }
49 return undefined
50}
51
52const firstQuestion = (input: unknown): string | undefined => {
53 if (!isRecord(input) || !Array.isArray(input.questions)) return undefined
54 const [first] = input.questions
55 return isRecord(first) && typeof first.question === 'string' ? clip(first.question) : undefined
56}
57
58export const Wait = {
59 /** What every `toolKey` of `tool` starts with. */
60 toolPrefix: (tool: string): string => `tool:${tool}:`,
61
62 /** The key a permission prompt and the tool call it gates share. */
63 toolKey: (tool: string, input: unknown): string => `${Wait.toolPrefix(tool)}${canonicalJson(input)}`,
64
65 permission: (tool: string, input: unknown, since: number): Wait => {
66 const detail = toolDetail(input)
67 return {
68 kind: 'permission',
69 key: Wait.toolKey(tool, input),
70 label: detail === undefined ? `approve ${tool}` : `approve ${tool}: ${detail}`,
71 since,
72 }
73 },
74
75 question: (toolUseId: string, input: unknown, since: number): Wait => {
76 const question = firstQuestion(input)
77 return {
78 kind: 'question',
79 key: `question:${toolUseId}`,
80 label: question === undefined ? 'answer a question' : `answer: ${question}`,
81 since,
82 }
83 },
84
85 /** The result carries the server and id but not the message, so those alone match the two. */
86 elicitationKey: (server: string, id: string | undefined): string => `elicitation:${server}:${id ?? ''}`,
87
88 elicitation: (server: string, id: string | undefined, since: number): Wait => ({
89 kind: 'elicitation',
90 key: Wait.elicitationKey(server, id),
91 label: `MCP ${server} input`,
92 since,
93 }),
94} as const
95
96/** One session's published state: the file `<dir>/<sessionId>.json`. `seenAt` is its heartbeat. */
97export type Beacon = {
98 sessionId: string
99 project: string
100 waits: readonly Wait[]
101 seenAt: number
102}
103
104/** A beacon whose heartbeat is older than this belongs to a session that is gone. */
105export const BEACON_MAX_AGE_MS = 10_000
106
107export const Beacon = {
108 /** `undefined` for anything that is not a whole beacon: another process may be mid-write. */
109 parse: (text: string): Beacon | undefined => {
110 let value: unknown
111 try {
112 value = JSON.parse(text)
113 } catch {
114 return undefined
115 }
116 if (
117 !isRecord(value) ||
118 typeof value.sessionId !== 'string' ||
119 typeof value.project !== 'string' ||
120 typeof value.seenAt !== 'number' ||
121 !Array.isArray(value.waits) ||
122 !value.waits.every(isWait)
123 ) {
124 return undefined
125 }
126 return { sessionId: value.sessionId, project: value.project, waits: value.waits, seenAt: value.seenAt }
127 },
128
129 serialize: (beacon: Beacon): string => JSON.stringify(beacon),
130
131 isFresh: (beacon: Beacon, now: number): boolean => now - beacon.seenAt <= BEACON_MAX_AGE_MS,
132} as const
133
134/** Identifies one wait across ticks: a wait that ends and starts again is a new one. */
135const waitId = (beacon: Beacon, wait: Wait): string => `${beacon.sessionId}|${wait.key}|${wait.since}`
136
137export type Sighting = { beacon: Beacon; wait: Wait }
138
139/**
140 * The waits in `beacons` not in `seen`, and the ids of every wait in `beacons`
141 * to carry into the next tick (ended waits drop out, so `seen` stays small).
142 */
143export const diffNewWaits = (
144 seen: ReadonlySet<string>,
145 beacons: readonly Beacon[],
146): { fresh: Sighting[]; current: Set<string> } => {
147 const fresh: Sighting[] = []
148 const current = new Set<string>()
149 for (const beacon of beacons) {
150 for (const wait of beacon.waits) {
151 const id = waitId(beacon, wait)
152 current.add(id)
153 if (!seen.has(id)) fresh.push({ beacon, wait })
154 }
155 }
156 return { fresh, current }
157}
158
159export const toastText = ({ beacon, wait }: Sighting): string => `Claude blocked in ${beacon.project}: ${wait.label}`
160