SLOPSHOPPER

Waiting on You

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

guardtoastpromptprocesstimer
A shopper browsing a rack in a slop shop
README

dotfiles

GNU Stow-based dotfiles for macOS and Linux. One stow package, home/, mirrors $HOME.

Quickstart

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.

How it works

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.

Structure

~/.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/

Commands

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

Documentation

Source 2 files
hooks/register.ts 212 lines
1/**
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}
212
hooks/waits.ts 160 lines
1/**
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