SLOPSHOPPER

away

Away mode: /away shows that you are away, and each waiting prompt counts down to an automatic deny

newrowsguardcommandstatustimer
★ 3v0.1.0no licenseupdated 2026-10-09StanAngeloff/nix-meridian/home/apps/claude-code/away/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · away
› fix the failing auth test and add an audit log call ⏺ 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 › /away ⎿ away: Away mode is on: unanswered prompts are denied after 5:00. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ away: AWAY · unanswered prompts are denied after 5:00 · /away off
README

nix-meridian

A declarative NixOS system and home configuration, focused on reproducibility and maintainability.

Overview

This repository contains my personal NixOS system configuration and home environment setup, managed through Nix flakes. It represents a transition from my previous Ansible-based setup (StanAngeloff/longitude) to a fully declarative system configuration.

The source tree is the source of truth. Rather than maintaining a parallel inventory here, look at:

  • home/apps/ — every user-space program I install, one directory per app.
  • system/components/ — every system-level component (audio, networking, podman, …).
  • home/gnome-shell/extensions/ — every GNOME Shell extension that's enabled.

Highlights

  • Desktop: GNOME Shell with a curated set of extensions.
  • Editor: NeoVim via nixvim.
  • Terminal: Ghostty + tmux + zsh.
  • Containers: rootless Podman (no Docker socket — sudo is required by design).
  • AI tooling: my own flemma.nvim — an AI workspace inside Neovim where every conversation is a .chat file you own — plus Claude Code.

How it actually feels to use

  • <Super>t opens Ghostty, or focuses the existing window if one's already up. home/apps/ghostty/launch/
  • <Super>s opens Voxize for voice input — see StanAngeloff/voxize. home/apps/voxize/
  • <Ctrl><Shift>e opens unipicker in a Ghostty overlay window. The chosen codepoint lands in the clipboard via wl-copy. home/gnome-shell/keybindings.nix
  • Screenshots saved under ~/Pictures/Screenshots open in Swappy automatically (systemd user service + inotifywait). home/apps/swappy/launch/
  • In eog, a Swappy plugin adds an "annotate" action so you can mark up an image you're already viewing. home/apps/eog/
  • less is replaced by ov everywhere, including git's pager. An ov-less.yaml keeps the less-style keybinds. home/apps/ov/
  • git diff and git show use tig as the pager (with a perl ANSI stripper in front). home/apps/tig/
  • In tmux, prefix C-g toggles a tig status popup; once it's open, Alt+z toggles its size between 90% and 100% (outside the popup it falls back to resize-pane -Z). home/apps/tmux/abilities/tmux.tig.conf
  • In tmux copy-mode, d on a /nix/store/... path runs nix-diff /run/current-system <path> | ov in a popup. home/apps/tmux/abilities/tmux.nix-diff.conf
  • nsx <pkg> -- <cmd> is npx for nixpkgs — fetches <pkg> from the chosen channel and runs <cmd>. home/apps/nsx/
  • preview <file.md> renders Markdown in Brave via headless nvim + the (revived) markdown-preview.nvim. The browser tab survives nvim's exit. home/apps/nixvim/plugins/markdown-preview-nvim/
  • The zsh cursor reflects whether the prompt is editable: beam means you can type, block means a command is running. home/apps/zsh/default.nix

Structure

.
├── home/              # Home Manager configuration
│   ├── apps/          # User-space applications (one directory per app)
│   ├── essentials/    # Core user environment, fonts, GTK, XDG
│   ├── gnome-shell/   # GNOME Shell extensions, settings, keybindings
│   └── services/      # User services (Bluetooth, GPG, SSH)
├── machines/          # Per-machine hardware configuration
├── modules/           # Custom Nix modules and shared options
├── pkgs/              # Local package overlays (e.g. Ghostty)
├── system/            # NixOS system configuration
│   ├── apps/          # System-wide applications
│   └── components/    # System components
├── contrib/           # Helper scripts (e.g. nixfmt-pretty.awk)
├── flake.nix          # Flake inputs and outputs
├── Makefile           # `make switch`, `make format`
└── treefmt.toml       # Formatter wiring (nixfmt + shfmt + stylua + ruff/black)

Requirements

  • NixOS 26.05 or later
  • Private fonts:
  • Berkeley Mono™ (TX-02) — primary monospace font

Post-Installation Steps

  1. Install Berkeley Mono™ and patch with Nerd Fonts (always use *.ttf fonts for this):
   cd ~/.local/share/fonts
   nix-shell -I nixpkgs=https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz -p nerd-font-patcher -p parallel
   parallel -j8 nerd-font-patcher --no-progressbars --careful --has-no-italic --single-width-glyphs --complete --name postscript {} ::: *.ttf

Be patient! The patching will take a while.

  1. Import GPG secret keys from a backup.

Usage

Initial install (bootstrap)

[!IMPORTANT] On a freshly cloned, not-yet-rebuilt system, make switch will not work — neither gnumake nor nh is on PATH until the first rebuild has run home-manager activation. Use the stock nixos-rebuild invocation for the very first switch, and only then move to the Makefile.

git clone https://github.com/StanAngeloff/nix-meridian.git
cd nix-meridian
sudo nixos-rebuild switch --flake .

That single rebuild brings up the NixOS system and the home-manager profile, which is what puts make, nh, and friends on PATH.

Day-to-day (after the bootstrap above)

Once the initial rebuild has completed at least once, the Makefile is the front door:

make switch   # nh os switch --ask .
make format   # treefmt across nix/python/shell/lua

nh shows a diff and asks before applying, which beats raw nixos-rebuild for routine changes.

Security

  • Full disk encryption (LUKS).
  • Firewall on, with interface-scoped rules for podman+.
  • sudo cache disabled — timestamp_timeout=0 — with narrow NOPASSWD carve-outs for the nh/switch-to-configuration flow only.
  • Rootless Podman; no Docker socket exposed.
  • GPG & SSH agent configuration.

Known Issues

  • Dropbox installation requires manual intervention
  • Some applications may need additional configuration on first run

dropbox

<pre>Dropbox is the easiest way to share and store your files online. Want to learn more? Head to https://www.dropbox.com/ In order to use Dropbox, you must download the proprietary daemon. Note: python3-gpg (python3-gpgme for Ubuntu 16.10 and lower) is not installed, we will not be able to verify binary signatures. [y/n] Traceback (most recent call last): File "[..]/urllib/request.py", line 1344, in do_open h.request(req.get_method(), req.selector, req.data, headers, File "[..]/http/client.py", line 1336, in request self._send_request(method, url, body, headers, encode_chunked) File "[..]/http/client.py", line 1382, in _send_request self.endheaders(body, encode_chunked=encode_chunked) File "[..]/http/client.py", line 1331, in endheaders self._send_output(message_body, encode_chunked=encode_chunked) File "[..]/http/client.py", line 1091, in _send_output self.send(msg) File "[..]/http/client.py", line 1035, in send self.connect() File "[..]/http/client.py", line 1470, in connect super().connect() File "[..]/http/client.py", line 1001, in connect self.sock = self._create_connection( ^^^^^^^^^^^^^^^^^^^^^^^^ File "[..]/socket.py", line 841, in create_connection for res in getaddrinfo(host, port, 0, SOCK_STREAM): ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "[..]/socket.py", line 976, in getaddrinfo for res in _socket.getaddrinfo(host, port, family, type, proto, flags): ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ socket.gaierror: [Errno -2] Name or service not known During handling of the above exception, another exception occurred: Traceback (most recent call last): File "[..]/bin/dropbox", line 568, in download for progress, status in download.copy_data(): ^^^^^^^^^^^^^^^^^^^^ File "[..]/bin/dropbox", line 200, in download_file_chunk with closing(opener.open(url)) as f: ^^^^^^^^^^^^^^^^ File "[..]/urllib/request.py", line 515, in open response = self._open(req, data) ^^^^^^^^^^^^^^^^^^^^^ File "[..]/urllib/request.py", line 532, in _open result = self._call_chain(self.handle_open, protocol, protocol + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "[..]/urllib/request.py", line 492, in _call_chain result = func(*args) ^^^^^^^^^^^ File "[..]/urllib/request.py", line 1392, in https_open return self.do_open(http.client.HTTPSConnection, req, ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "[..]/urllib/request.py", line 1347, in do_open raise URLError(err) urllib.error.URLError: <urlopen error [Errno -2] Name or service not known> Error: Trouble connecting to Dropbox servers. Maybe your internet connection is down, or you need to set your http_proxy environment variable. Starting Dropbox...Traceback (most recent call last): File "[..]/bin/dropbox", line 1587, in <module> ret = main(sys.argv) ^^^^^^^^^^^^^^ File "[..]/bin/dropbox", line 1576, in main result = commands[argv[i]](argv[i+1:]) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "[..]/bin/dropbox", line 1427, in start if not start_dropbox(): ^^^^^^^^^^^^^^^ File "[..]/bin/dropbox", line 763, in start_dropbox subprocess.Popen([DROPBOXD_PATH], preexec_fn=os.setsid, cwd=os.path.expanduser("~"), File "[..]/subprocess.py", line 1026, in __init__ self._execute_child(args, executable, preexec_fn, close_fds, File "[..]/subprocess.py", line 1955, in _execute_child raise child_exception_type(errno_num, err_msg, err_filename) FileNotFoundError: [Errno 2] No such file or directory: '/home/stan/.dropbox-hm'</pre>

Acknowledgments

This configuration draws inspiration from various sources and previous work:

  • My previous Ansible-based setup (StanAngeloff/longitude)
  • The NixOS & Home Manager communities
  • Various dotfiles repositories and configurations from the community

License

MIT. Feel free to use any parts of this configuration as inspiration for your own setup.

Source 2 files
hooks/register.tsx 246 lines
1import { atom, read } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { WaitingPrompt } from '../types'
5
6// Away mode for one session. /away pins a status line, and every permission prompt, question or plan approval that opens while it is on
7// counts down on its tool row, which stays on screen above the dialog.
8// The deny itself comes from the claude-away PermissionRequest command hook (../../claude-away.sh), which waits out the timeout this mod
9// hands it in its input and then denies. A session with away mode off hands it nothing, and the hook passes at once.
10
11const DEFAULT_MINUTES = 5
12const MAXIMUM_MINUTES = 60
13
14// 0 while away mode is off.
15const promptTimeoutMs = atom({ plugin: 'away', key: 'promptTimeoutMs' } as const, 0)
16// A copy of `waiting` below, for the tool rows to draw from.
17const waitingPrompts = atom({ plugin: 'away', key: 'waitingPrompts' } as const, [])
18// The ticker's clock: written every second while a prompt waits, so the rows that read it redraw.
19const now = atom({ plugin: 'away', key: 'now' } as const, 0)
20
21type Timer = ReturnType<EngineInterface['clock']['every']>
22
23// tool_use_ids of the calls whose verdict was ask, by callKey, until their dialog opens or the call ends.
24const askedCalls = new Map<string, string[]>()
25// The prompts counting down, kept here rather than read back from $.state: every $.state.get of one dispatch reads one moment,
26// so the PermissionRequest hook, which waits through the whole dialog, and the ticker it starts would never see /away off.
27let waiting: WaitingPrompt[] = []
28let ticker: Timer | undefined
29
30function formatDuration(milliseconds: number): string {
31  const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000))
32  const minutes = Math.floor(totalSeconds / 60)
33  const seconds = totalSeconds % 60
34
35  return `${minutes}:${String(seconds).padStart(2, '0')}`
36}
37
38function countdownText(prompt: WaitingPrompt, currentTime: number): string {
39  const remainingMs = prompt.deadline - currentTime
40
41  return remainingMs > 0 ? `away: denied in ${formatDuration(remainingMs)} unless you answer` : 'away: no answer, denying'
42}
43
44// Pairs a tool.check verdict, which carries the tool_use_id, with the PermissionRequest event that follows it, which does not.
45function callKey(tool: string, input: unknown): string {
46  return `${tool}\u0000${JSON.stringify(input)}`
47}
48
49function rememberAskedCall(key: string, toolUseId: string): void {
50  const toolUseIds = askedCalls.get(key) ?? []
51  if (!toolUseIds.includes(toolUseId)) {
52    askedCalls.set(key, [...toolUseIds, toolUseId])
53  }
54}
55
56function takeAskedCall(key: string): string | undefined {
57  const [toolUseId, ...rest] = askedCalls.get(key) ?? []
58  if (rest.length === 0) {
59    askedCalls.delete(key)
60  } else {
61    askedCalls.set(key, rest)
62  }
63
64  return toolUseId
65}
66
67function forgetAskedCall(toolUseId: string): void {
68  for (const [key, toolUseIds] of askedCalls) {
69    const rest = toolUseIds.filter(askedId => askedId !== toolUseId)
70    if (rest.length === 0) {
71      askedCalls.delete(key)
72    } else if (rest.length !== toolUseIds.length) {
73      askedCalls.set(key, rest)
74    }
75  }
76}
77
78function showStatus($: EngineInterface, timeoutMs: number): void {
79  $.ui.status(timeoutMs === 0 ? undefined : `AWAY · unanswered prompts are denied after ${formatDuration(timeoutMs)} · /away off`)
80}
81
82async function appendNote($: EngineInterface, text: string): Promise<void> {
83  await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
84}
85
86async function tick($: EngineInterface): Promise<void> {
87  if (waiting.length === 0) {
88    stopTicker()
89    return
90  }
91  await $.state.set({ plugin: 'away', key: 'now' }, await $.clock.now())
92}
93
94function startTicker($: EngineInterface): void {
95  ticker ??= $.clock.every(1000, () => {
96    void tick($).catch(stopTicker)
97  })
98}
99
100function stopTicker(): void {
101  ticker?.cancel()
102  ticker = undefined
103}
104
105async function setWaiting($: EngineInterface, prompts: WaitingPrompt[]): Promise<void> {
106  waiting = prompts
107  await $.state.set({ plugin: 'away', key: 'waitingPrompts' }, prompts)
108}
109
110function isWaiting(toolUseId: string): boolean {
111  return waiting.some(prompt => prompt.toolUseId === toolUseId)
112}
113
114async function forgetPrompt($: EngineInterface, toolUseId: string): Promise<void> {
115  if (isWaiting(toolUseId)) {
116    await setWaiting($, waiting.filter(prompt => prompt.toolUseId !== toolUseId))
117  }
118}
119
120export const register: Register = on => {
121  on('session.start', async ($, e, next) => {
122    await $.command.register({
123      name: 'away',
124      description: 'Away mode: unanswered prompts are denied after a timeout (default 5 minutes)',
125      argumentHint: '[minutes|off]',
126      immediate: true,
127    })
128    showStatus($, await read($, promptTimeoutMs))
129
130    return next(e)
131  })
132
133  on('command.run', { command: 'away' }, async ($, e) => {
134    const argument = e.args.trim()
135
136    if (argument === 'off') {
137      const wasAway = (await read($, promptTimeoutMs)) > 0
138      await $.state.set({ plugin: 'away', key: 'promptTimeoutMs' }, 0)
139      // A prompt still on screen keeps its dialog for the person to answer: its countdown ends here, and the deny its hook sends later is dropped.
140      await setWaiting($, [])
141      askedCalls.clear()
142      stopTicker()
143      showStatus($, 0)
144      if (!wasAway) {
145        return { text: 'Away mode was not on.' }
146      }
147      await appendNote($, 'The user is back: away mode is off, and permission prompts wait for an answer again.')
148
149      return { text: 'Away mode is off.' }
150    }
151
152    const minutes = argument === '' ? DEFAULT_MINUTES : Number(argument)
153    // The hook waits in whole seconds.
154    const timeoutMs = Math.round(minutes * 60) * 1000
155    if (!Number.isFinite(minutes) || timeoutMs < 1000 || minutes > MAXIMUM_MINUTES) {
156      return { text: `Usage: /away [minutes, up to ${MAXIMUM_MINUTES}; ${DEFAULT_MINUTES} when left out] or /away off` }
157    }
158    await $.state.set({ plugin: 'away', key: 'promptTimeoutMs' }, timeoutMs)
159    showStatus($, timeoutMs)
160    await appendNote(
161      $,
162      `The user has stepped away (away mode). Any permission prompt, question or plan approval nobody answers within ` +
163        `${formatDuration(timeoutMs)} is denied automatically. Prefer approaches that need no approval, and collect what needs ` +
164        `the user's sign-off to list when you finish.`,
165    )
166
167    return { text: `Away mode is on: unanswered prompts are denied after ${formatDuration(timeoutMs)}.` }
168  })
169
170  // The waiting call's tool row stays on screen above its dialog: draw the engine's row with the countdown under it.
171  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
172    const prompt = (await read($, waitingPrompts)).find(waiting => waiting.toolUseId === e.requestId)
173    // isRunning turns true the moment the person approves: from then on the row is the command's, not the prompt's.
174    if (prompt === undefined || e.props.isRunning) {
175      return next(e)
176    }
177    const currentTime = await read($, now)
178    const drawing = await next(e)
179    const { Box, Text } = $.ui.resolve(e)
180
181    return (
182      <Box flexDirection="column">
183        {drawing}
184        <Text color="warning">  {countdownText(prompt, currentTime)}</Text>
185      </Box>
186    )
187  })
188
189  // Remember which calls are about to ask, so the dialog that opens for one can be matched to its tool row.
190  on('tool.check', async ($, e, next) => {
191    const verdict = await next(e)
192    if (verdict.decision === 'ask' && e.tool_use_id !== undefined && (await read($, promptTimeoutMs)) > 0) {
193      rememberAskedCall(callKey(e.tool, e.input), e.tool_use_id)
194    }
195
196    return verdict
197  }).catch(($, e, next) => next(e))
198
199  // The dialog has opened and the settings PermissionRequest hooks start beside it: the countdown starts now.
200  on('classic.PermissionRequest', async ($, e, next) => {
201    const timeoutMs = await read($, promptTimeoutMs)
202    const toolUseId = timeoutMs === 0 ? undefined : takeAskedCall(callKey(e.tool_name, e.tool_input))
203    if (toolUseId === undefined) {
204      return next(e)
205    }
206    const deadline = (await $.clock.now()) + timeoutMs
207    await setWaiting($, [...waiting, { toolUseId, tool: e.tool_name, deadline }])
208    startTicker($)
209    await tick($)
210    try {
211      const handedDown = { ...e, away_timeout_seconds: timeoutMs / 1000, away_tool_use_id: toolUseId }
212      const result = await next(handedDown)
213      // /away off emptied the list while the prompt waited: the person answers it, so the hook's deny is dropped.
214      if (result.decision !== undefined && !isWaiting(toolUseId)) {
215        const { decision: _dropped, ...rest } = result
216
217        return rest
218      }
219
220      return result
221    } finally {
222      await forgetPrompt($, toolUseId)
223    }
224  }).catch(($, e, next) => next(e))
225
226  // The call has ended (answered, denied or run): its countdown is over, even while the hook beneath still waits.
227  on('tool.call', async ($, e, next) => {
228    try {
229      return await next(e)
230    } finally {
231      forgetAskedCall(e.tool_use_id)
232      await forgetPrompt($, e.tool_use_id)
233    }
234  }).catch(($, e, next) => next(e))
235
236  // /clear and /resume carry on in this process under another session id, whose state starts over; the pinned status line would stay.
237  on('session.end', async ($, e, next) => {
238    $.ui.status(undefined)
239    askedCalls.clear()
240    waiting = []
241    stopTicker()
242
243    return next(e)
244  })
245}
246
types/index.d.ts 8 lines
1export type WaitingPrompt = { toolUseId: string; tool: string; deadline: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    away: { promptTimeoutMs: number; waitingPrompts: WaitingPrompt[]; now: number }
6  }
7}
8