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

A declarative NixOS system and home configuration, focused on reproducibility and maintainability.
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.sudo is required by design)..chat file you own — plus Claude Code.<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~/Pictures/Screenshots open in Swappy automatically (systemd user service + inotifywait). home/apps/swappy/launch/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/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.confd on a /nix/store/... path runs nix-diff /run/current-system <path> | ov in a popup. home/apps/tmux/abilities/tmux.nix-diff.confnsx <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/home/apps/zsh/default.nix.
├── 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)
*.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.
[!IMPORTANT] On a freshly cloned, not-yet-rebuilt system,
make switchwill not work — neithergnumakenornhis onPATHuntil the first rebuild has run home-manager activation. Use the stocknixos-rebuildinvocation 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.
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.
podman+.sudo cache disabled — timestamp_timeout=0 — with narrow NOPASSWD carve-outs for the nh/switch-to-configuration flow only.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>
This configuration draws inspiration from various sources and previous work:
MIT. Feel free to use any parts of this configuration as inspiration for your own setup.
hooks/register.tsx 246 lines1import { 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}
246types/index.d.ts 8 lines1export 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