This session's pager name and new mail above the prompt; its pager conversation and peers in a pane

Personal configuration files managed via ~/.config/ and synced with git.
| Directory | Tool | Key files |
|---|---|---|
ai/ | Shared (Claude + Codex) | PERSONAL.md, guardrails.md, rules/, memory/, skills/, AGENTS.manifest |
claude/ | Claude Code | CLAUDE.md, DEVGUARD.md, settings.json, hooks/, agents/, commands/, skills/ (Claude-only) |
codex/ | Codex CLI | config.toml.template, skills/ (Codex-only), scripts/ |
notifier/ | Shared AI notifier | macOS Swift app, Linux Go daemon, Go sender, shared icon |
ghostty/ | Ghostty | config, shaders/ (cursor animation) |
aerospace/ | AeroSpace (macOS) | aerospace.toml (shipped default trimmed to digit workspaces so Option stays free for terminal Meta keys; float rules, Omarchy gaps) |
brave/ | Brave Origin | vimium-options.json (Vimium export; restore by hand from its Options page when Brave Sync has not carried it over) |
nvim/ | Neovim | init.lua, lua/plugins/ |
tmux/ | tmux | tmux.conf, scripts/, status/ |
zsh/ | Zsh | .zshrc, custom/plugins/ |
homebrew/ | Homebrew Bundle | Brewfile, bootstrap.sh (macOS package set + shell env) |
arch/ | Arch Linux / Omarchy | packages.sh (pacman + AUR, the Brewfile mapping), bootstrap.sh (packages + mise + shell env) |
lib/ | Shared bootstrap | shell-env.sh (~/.zshenv ZDOTDIR + oh-my-zsh, used by both Unix package layers) |
windows/ | Native Windows | bootstrap.ps1, packages.ps1 (winget), profile.ps1, starship.toml, terminal/, notifier/, Cowork skill packaging |
.ideavimrc | IdeaVim (JetBrains) | Standalone file |
ai/ is the single source of truth for tool-agnostic content (rules, memory, most skills). claude/ and codex/ hold per-tool deploy logic and tool-only files. notifier/ is shared runtime infrastructure for Claude/Codex desktop notifications. The orchestrator at ai/scripts/bootstrap.sh calls each tool's bootstrap. See ai/README.md, claude/README.md, codex/README.md.
Everything else under
~/.config/is gitignored. See.gitignorefor the allowlist.
Anything true of one machine and not another stays out of the tracked files. Each config loads an optional private file last, so it overrides without being edited into the shared one:
| Config | Hook in the tracked file | Machine-local file |
|---|---|---|
| zsh | source $ZDOTDIR/private.sh | zsh/private.sh |
| ghostty | config-file = ?"…/private.conf" | ghostty/private.conf |
| tmux | if-shell '[ -r … ]' 'source-file …' | tmux/private.conf |
| nvim | plugins/99_private.lua → require("private.plugins") | nvim/lua/private/plugins.lua |
All four are absent by default and silent when missing, so a fresh clone works with none of them. On Omarchy they are where the theme wiring lives, which is why macOS keeps Catppuccin while that machine follows the system theme.
One case the seam cannot cover: Omarchy's display text size slider rewrites font-size in ghostty/config at a path it hardcodes, so moving the line out would make the slider silently do nothing. That one line is normalized on its way into the index by the clean filter in lib/git-filter-machine-local.sh (wired in .gitattributes, registered by ai/scripts/bootstrap.sh) — the working tree keeps this monitor's size and git never sees it. The same filter drops a # >>> machine-local … # <<< machine-local block whole; aerospace/aerospace.toml keeps its work-machine float rules there, since AeroSpace has no include to put them in a private file.
| Path | Repo |
|---|---|
zsh/custom/plugins/zsh-autosuggestions | zsh-users/zsh-autosuggestions |
zsh/custom/plugins/zsh-history-substring-search | zsh-users/zsh-history-substring-search |
zsh/custom/plugins/zsh-syntax-highlighting | zsh-users/zsh-syntax-highlighting |
zsh/custom/plugins/fzf-tab | aloxaf/fzf-tab |
ghostty/shaders | sahaj-b/ghostty-cursor-shaders |
git clone --recurse-submodules <repo> ~/.config
~/.config/bootstrap.sh # packages + shell env, then deploys Claude + Codex config
bootstrap.sh picks the package layer from the machine, then hands off to the same ai/scripts/bootstrap.sh in every case:
| Machine | Package layer | Source of claude / codex |
|---|---|---|
| macOS | homebrew/bootstrap.sh → brew bundle | Homebrew casks |
| Arch / Omarchy | arch/bootstrap.sh → pacman + AUR | mise (~/.config/mise/config.toml, machine-local) |
| Linux with Homebrew, no pacman | homebrew/bootstrap.sh | Homebrew |
On Omarchy the package step uses omarchy-pkg-add / omarchy-pkg-aur-add when they exist, so packages installed here look the same as ones added through the Omarchy menu. arch/packages.sh --dry-run prints the plan without installing; --skip-optional leaves out desktop apps and AUR.
~/.configis usually not empty on a fresh Omarchy install —ghostty/,nvim/andtmux/already exist. The tracked versions here replace them; move the originals aside first if you want them back.
winget install --id Git.Git --exact
git clone --recurse-submodules <repo> $HOME\.config
pwsh -File $HOME\.config\windows\bootstrap.ps1 # winget packages + shell/terminal, then the SAME ai/scripts/bootstrap.sh under Git Bash
Requires Developer Mode (Settings > System > For developers) so the deploy can create symlinks unelevated. windows/bootstrap.ps1 replaces bootstrap.sh only at the package layer — the Claude/Codex deploy is delegated to ai/scripts/bootstrap.sh verbatim, so there is one implementation of the 3-tier merge, not two. See windows/README.md.
hooks/register.tsx 470 lines1// pager-view: this session's pager name and its new mail as one line above the
2// prompt, and a /pager pane with the session's conversation and its peers.
3// Read-only: it lists and exports; it never sends, marks or delivers anything.
4//
5// I/O happens in events and timers only; ui.render reads $.state and nothing else.
6
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface as Engine, On, RenderElement, RenderInput } from 'claude-code'
9
10import type { Entry, PagerState, Tab } from '../types'
11import {
12 agoOf,
13 conversationOf,
14 exportedOf,
15 flatOf,
16 inboundOf,
17 isMissing,
18 liveOf,
19 nameOf,
20 newCountOf,
21 newestOf,
22 peersOf,
23 rosterOf,
24 stampOf,
25} from './pager'
26import type { Run } from './pager'
27
28export const PANE = 'pager'
29const POLL_MS = 5_000
30const REFRESH_DEBOUNCE_MS = 300
31const SHOWN_ENTRIES = 200
32
33const EMPTY: PagerState = {
34 sessionId: '',
35 name: null,
36 entries: [],
37 peers: [],
38 newCount: 0,
39 live: 0,
40 lastAgo: null,
41 now: 0,
42 error: null,
43}
44
45const pager = atom({ plugin: 'pager-view', key: 'pager' } as const, EMPTY)
46const tab = atom({ plugin: 'pager-view', key: 'tab' } as const, 'messages')
47const pick = atom({ plugin: 'pager-view', key: 'pick' } as const, null)
48
49const baselineKey = (sessionId: string) => `baseline:${sessionId}`
50
51async function run($: Engine, argv: string[]): Promise<Run> {
52 try {
53 return await $.process.run(argv, { timeoutMs: 10_000 })
54 } catch (error) {
55 return { exitCode: -1, stdout: '', stderr: error instanceof Error ? error.message : String(error) }
56 }
57}
58
59// Module state: a reload starts it over, as it does $.clock timers. `generation`
60// moves when the session id changes under the process (/clear, /resume, /branch);
61// a refresh begun under an older one is dropped. The baseline lives in $.store,
62// keyed by session id, so a reload keeps it and a new session starts its own.
63let generation = 0
64let isRefreshing = false
65let isDirty = false
66let pending: { cancel: () => void } | null = null
67
68async function isPaneOpen($: Engine): Promise<boolean> {
69 return (await $.ui.panes()).some(one => one.id === PANE)
70}
71
72/** Moves the baseline to `newest` when that is later; answers the baseline in force. */
73async function baselineAt($: Engine, sessionId: string, newest: number, isSeen: boolean): Promise<number> {
74 const stored = await $.store.get(baselineKey(sessionId))
75 const current = typeof stored === 'number' ? stored : null
76 // No baseline yet: what arrived before this session's first look is not new.
77 const next = current === null || (isSeen && newest > current) ? newest : current
78 if (next !== current) await $.store.set(baselineKey(sessionId), next)
79 return next
80}
81
82async function refreshOnce($: Engine): Promise<void> {
83 const started = generation
84 const sessionId = await $.session.id()
85 const isStale = async () => started !== generation || sessionId !== (await $.session.id())
86 const settle = async (next: (prev: PagerState) => PagerState) => {
87 if (await isStale()) return
88 await update($, pager, next)
89 }
90 const failed = (error: string) =>
91 settle(prev => (prev.sessionId === sessionId ? { ...prev, error } : { ...EMPTY, sessionId, error }))
92
93 const whoami = await run($, ['pager', 'whoami', '--session', sessionId])
94 if (isMissing(whoami)) return settle(() => ({ ...EMPTY, sessionId }))
95 const name = nameOf(whoami)
96 if (name !== null && typeof name === 'object') return failed(name.error)
97 if (name === null) return settle(() => ({ ...EMPTY, sessionId }))
98
99 const inbound = inboundOf(await run($, ['pager', 'ls', '--session', sessionId]))
100 if ('error' in inbound) return failed(inbound.error)
101 const rows = exportedOf(await run($, ['pager', 'export']))
102 if ('error' in rows) return failed(rows.error)
103 const isSeen = await isPaneOpen($)
104 // The band counts live peers too, so `who` runs on every refresh (~13 ms measured).
105 const who = peersOf(await run($, ['pager', 'who']))
106 if ('error' in who) return failed(who.error)
107 const now = await $.clock.now()
108 const home = (await $.env.get('HOME')) ?? ''
109 if (await isStale()) return
110
111 const baseline = await baselineAt($, sessionId, newestOf(inbound), isSeen)
112 const entries = conversationOf(rows, inbound, sessionId).slice(-SHOWN_ENTRIES)
113 const last = entries.at(-1)
114 await settle(() => ({
115 sessionId,
116 name,
117 entries,
118 peers: rosterOf(who, home),
119 newCount: newCountOf(inbound, baseline),
120 live: liveOf(who, name),
121 lastAgo: last ? agoOf(last.at, now) : null,
122 now,
123 error: null,
124 }))
125}
126
127async function refresh($: Engine): Promise<void> {
128 if (isRefreshing) {
129 isDirty = true
130 return
131 }
132 isRefreshing = true
133 try {
134 do {
135 isDirty = false
136 await refreshOnce($)
137 } while (isDirty)
138 } finally {
139 isRefreshing = false
140 }
141}
142
143function soon($: Engine): void {
144 pending?.cancel()
145 pending = $.clock.after(REFRESH_DEBOUNCE_MS, () => {
146 pending = null
147 void refresh($)
148 })
149}
150
151/** /pager and a click on the band's name: open the pane, and count the look as seen. */
152async function openPane($: Engine): Promise<void> {
153 await $.ui.open({ id: PANE, title: 'pager', focus: true })
154 // Clear the badge now; the next refresh moves the stored baseline (the pane is open).
155 const state = await read($, pager)
156 const sessionId = await $.session.id()
157 if (state.sessionId === sessionId && state.newCount > 0) {
158 await update($, pager, prev => ({ ...prev, newCount: 0 }))
159 }
160 soon($)
161}
162
163async function switched($: Engine): Promise<void> {
164 generation += 1
165 await update($, pager, () => EMPTY)
166 await update($, pick, () => null)
167}
168
169/** `j`, `k` and a press on a message's time: which message the reader shows. */
170async function pickMessage($: Engine, id: number | null): Promise<void> {
171 await update($, pick, () => id)
172}
173
174export function register(on: On): void {
175 on('session.start', async ($, e, next) => {
176 await $.command.register({
177 name: PANE,
178 description: "Show this session's pager conversation and peers",
179 immediate: true,
180 })
181 const result = await next(e)
182 $.clock.every(POLL_MS, () => void refresh($))
183 soon($)
184 return result
185 })
186
187 // These only observe; if one fails, .catch hands back what next settled to (or
188 // runs it once when it had not run), so a failure never blocks or repeats work.
189 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
190 const result = await next(e)
191 await switched($)
192 soon($)
193 return result
194 }).catch(($, e, next) => next(e))
195
196 on('classic.SessionEnd', async ($, e, next) => {
197 generation += 1
198 return next(e)
199 }).catch(($, e, next) => next(e))
200
201 on('command.run', { command: PANE }, async $ => {
202 await openPane($)
203 return {}
204 }).catch(($, e, next) => next(e))
205
206 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
207 if (e.props.hasSurvey) return next(e)
208 const state = await read($, pager)
209 const below = await next(e)
210 const line = bandLine($, e, state)
211 if (line === null) return below
212 const { Box } = $.ui.resolve(e)
213 return (
214 <Box flexDirection="column">
215 {line}
216 {below}
217 </Box>
218 )
219 })
220
221 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
222 const state = await read($, pager)
223 const shown = await read($, tab)
224 const picked = await read($, pick)
225 return paneOf($, e, state, shown, picked)
226 })
227}
228
229const PREVIEW_CHARS = 60
230
231function bandLine($: Engine, e: RenderInput<'AbovePrompt'>, state: PagerState): RenderElement | null {
232 if (state.name === null) return null
233 const { Box, Text, Button } = $.ui.resolve(e)
234 const last = state.entries.at(-1)
235 const flat = last ? flatOf(last.body) : ''
236 const preview = flat.length > PREVIEW_CHARS ? `${flat.slice(0, PREVIEW_CHARS - 1)}…` : flat
237 return (
238 <Box flexDirection="row">
239 {/* The name is the way into /pager: a click (or ctrl+x tab, Enter) opens it. */}
240 <Button
241 key="open-pager"
242 label={`✉ ${state.name}`}
243 plain
244 onPress={() => openPane($)}
245 />
246 <Text wrap="truncate-end">
247 {state.newCount > 0 ? <Text color="warning" bold>{` ● ${state.newCount} new`}</Text> : ''}
248 {last ? (
249 <Text>
250 {' '}
251 <Text color={last.direction === 'in' ? 'suggestion' : 'success'}>
252 {last.direction === 'in' ? '←' : '→'} {last.peer}
253 </Text>
254 <Text dimColor>
255 {state.lastAgo !== null ? ` ${state.lastAgo}` : ''}
256 {' '}
257 {preview}
258 </Text>
259 </Text>
260 ) : (
261 ''
262 )}
263 {state.live > 0 ? <Text dimColor>{` · ${state.live} live`}</Text> : ''}
264 {state.error !== null ? <Text dimColor> ?</Text> : ''}
265 </Text>
266 </Box>
267 )
268}
269
270const TABS: { tab: Tab; label: string; hotkey: string }[] = [
271 { tab: 'messages', label: 'messages', hotkey: '1' },
272 { tab: 'peers', label: 'peers', hotkey: '2' },
273]
274
275function paneOf(
276 $: Engine,
277 e: RenderInput<'Pane'>,
278 state: PagerState,
279 shown: Tab,
280 picked: number | null,
281): RenderElement {
282 const { Box, Text, Button } = $.ui.resolve(e)
283 const tabs = (
284 <Box flexDirection="row" columnGap={3}>
285 {TABS.map(one => (
286 <Button
287 key={`tab-${one.tab}`}
288 label={one.label}
289 hotkey={one.hotkey}
290 plain
291 dimColor={one.tab !== shown}
292 onPress={() => update($, tab, () => one.tab)}
293 />
294 ))}
295 </Box>
296 )
297 const status = state.error !== null ? <Text dimColor>⚠ pager: {state.error}</Text> : ''
298 if (state.name === null) {
299 return (
300 <Box flexDirection="column">
301 {tabs}
302 <Text dimColor>This session has no pager name yet.</Text>
303 {status}
304 </Box>
305 )
306 }
307 const body =
308 shown === 'messages'
309 ? state.entries.length === 0
310 ? <Text dimColor>No messages to or from {state.name} yet.</Text>
311 : messagesBody($, e, state, picked)
312 : peersBody($, e, state)
313 return (
314 <Box flexDirection="column">
315 {tabs}
316 {body}
317 {status}
318 </Box>
319 )
320}
321
322const arrowOf = (entry: Entry) => (entry.direction === 'in' ? '←' : '→')
323const toneOf = (entry: Entry) => (entry.direction === 'in' ? 'suggestion' : 'success')
324
325/**
326 * The messages tab: an index of recent messages over a reader that shows the
327 * picked one (the newest when none is picked) in full.
328 */
329function messagesBody($: Engine, e: RenderInput<'Pane'>, state: PagerState, picked: number | null): RenderElement {
330 const { Box, Text, Button } = $.ui.resolve(e)
331 const entries = state.entries
332 const found = picked === null ? -1 : entries.findIndex(entry => entry.id === picked)
333 const at = found < 0 ? entries.length - 1 : found
334 const entry = entries[at]!
335 // The index keeps the picked row in its window, the newest at the bottom.
336 const size = Math.min(entries.length, Math.max(3, Math.floor(e.props.scroll.bodyRows * 0.35)))
337 const first = Math.max(0, Math.min(entries.length - size, at - Math.floor(size / 2)))
338 const windowed = entries.slice(first, first + size)
339 const older = at > 0 ? entries[at - 1]!.id : null
340 // Stepping onto the newest returns to following new mail.
341 const newer = at < entries.length - 1 ? (at + 1 === entries.length - 1 ? null : entries[at + 1]!.id) : undefined
342 const age = agoOf(entry.at, state.now)
343 const header = `── #${entry.id} · ${arrowOf(entry)} ${entry.peer}${entry.isHuman ? ' (human)' : ''} · ${stampOf(entry.at)}${
344 age === null ? '' : age === 'now' ? ' · just now' : ` · ${age} ago`
345 } `
346 const isTerminal = e.surface === 'terminal'
347 const rule = isTerminal ? '─'.repeat(Math.max(2, e.props.bodyColumns - header.length)) : '──'
348 const left = entry.length - entry.body.length
349 return (
350 <Box flexDirection="column">
351 {windowed.map(one => {
352 const isPicked = one === entry
353 return (
354 <Box flexDirection="row">
355 <Text color="claude">{isPicked ? '▌' : ' '}</Text>
356 <Button
357 key={`msg-${one.id}`}
358 label={stampOf(one.at)}
359 plain
360 dimColor={!isPicked}
361 onPress={() => pickMessage($, one === entries.at(-1) ? null : one.id)}
362 />
363 <Box flexShrink={1}>
364 <Text wrap="truncate-end">
365 {' '}
366 <Text color={toneOf(one)}>
367 {arrowOf(one)} {one.peer.padEnd(5)}
368 </Text>
369 {one.isHuman ? <Text color="claude"> (human)</Text> : ''}
370 <Text bold={isPicked}> {flatOf(one.body)}</Text>
371 </Text>
372 </Box>
373 </Box>
374 )
375 })}
376 <Text> </Text>
377 <Text color="subtle" wrap="truncate-end">
378 {header}
379 {rule}
380 </Text>
381 {entry.body.split('\n').map(line => (
382 <Text wrap="wrap">{line === '' ? ' ' : line}</Text>
383 ))}
384 {left > 0 ? (
385 <Text dimColor wrap="wrap">
386 … {left.toLocaleString('en-US')} more characters · full text: pager export (#{entry.id})
387 </Text>
388 ) : (
389 ''
390 )}
391 <Text> </Text>
392 <Box flexDirection="row" columnGap={2}>
393 <Button
394 key="older"
395 label="older"
396 hotkey="j"
397 plain
398 dimColor={older === null}
399 onPress={() => (older === null ? undefined : pickMessage($, older))}
400 />
401 <Button
402 key="newer"
403 label="newer"
404 hotkey="k"
405 plain
406 dimColor={newer === undefined}
407 onPress={() => (newer === undefined ? undefined : pickMessage($, newer))}
408 />
409 <Text dimColor wrap="truncate-end">
410 {isTerminal ? '· 1 2 tabs · ↑↓ scroll' : '· click a time to read it'}
411 </Text>
412 </Box>
413 </Box>
414 )
415}
416
417const COLUMNS = { name: 6, tool: 7, host: 8, last: 10 }
418
419const startCut = (text: string, room: number) => (text.length > room ? `…${text.slice(text.length - room + 1)}` : text)
420
421/** The peers tab: aligned columns, live hosts first, this session marked. */
422function peersBody($: Engine, e: RenderInput<'Pane'>, state: PagerState): RenderElement {
423 const { Box, Text } = $.ui.resolve(e)
424 if (state.peers.length === 0) return <Text dimColor>loading…</Text>
425 const cell = (width: number, text: string, extra: Record<string, boolean> = {}) => (
426 <Box width={width + 1} flexShrink={0}>
427 <Text wrap="truncate-end" {...extra}>
428 {text}
429 </Text>
430 </Box>
431 )
432 // The root takes what the fixed columns leave; cut from its start, it keeps
433 // the folder that tells two sessions apart.
434 const fixed = 2 + COLUMNS.name + COLUMNS.tool + COLUMNS.host + COLUMNS.last + 4
435 const room = Math.max(8, e.props.bodyColumns - fixed)
436 return (
437 <Box flexDirection="column">
438 <Box flexDirection="row">
439 <Text dimColor>{' '}</Text>
440 {cell(COLUMNS.name, 'name', { dimColor: true })}
441 {cell(COLUMNS.tool, 'tool', { dimColor: true })}
442 {cell(COLUMNS.host, 'host', { dimColor: true })}
443 {cell(COLUMNS.last, 'last', { dimColor: true })}
444 <Text dimColor>root</Text>
445 </Box>
446 {state.peers.map(peer => {
447 const isLive = peer.host === 'live'
448 const isSelf = peer.name === state.name
449 const tone = { dimColor: !isLive, bold: isSelf }
450 return (
451 <Box flexDirection="row">
452 <Box width={2} flexShrink={0}>
453 <Text color={isLive ? 'success' : undefined} dimColor={!isLive}>
454 {isLive ? '●' : '○'}
455 </Text>
456 </Box>
457 {cell(COLUMNS.name, peer.name, tone)}
458 {cell(COLUMNS.tool, peer.tool, tone)}
459 {cell(COLUMNS.host, peer.host, tone)}
460 {cell(COLUMNS.last, peer.last, tone)}
461 <Text dimColor={!isLive} bold={isSelf}>
462 {startCut(`${peer.root}${isSelf ? ' (this)' : ''}`, room)}
463 </Text>
464 </Box>
465 )
466 })}
467 </Box>
468 )
469}
470hooks/pager.ts 185 lines1// Pure readers for pager-view: what the pager CLI prints, turned into this
2// session's name, its conversation and its peers. register.tsx does the I/O.
3
4import type { Entry, Peer } from '../types'
5
6export type { Entry, Peer }
7
8/** What one `$.process.run` answered, the fields these readers look at. */
9export type Run = {
10 exitCode: number
11 stdout: string
12 stderr: string
13 isStdoutTruncated?: boolean
14}
15
16export type Failure = { error: string }
17
18/** The run's failure, if it failed: a non-zero exit or output cut short. */
19export function failureOf(run: Run): string | undefined {
20 if (run.isStdoutTruncated) return 'output truncated'
21 if (run.exitCode !== 0) {
22 return run.stderr.trim().split('\n').at(-1) || `exit ${run.exitCode}`
23 }
24 return undefined
25}
26
27/** A run that never started because `pager` is not installed. */
28export function isMissing(run: Run): boolean {
29 return run.exitCode === -1 && /ENOENT|not found|No such file/i.test(run.stderr)
30}
31
32/** `pager whoami --session <id>`: the `name:` line, or null when it has none. */
33export function nameOf(run: Run): string | null | Failure {
34 const failure = failureOf(run)
35 if (failure) return { error: failure }
36 const line = run.stdout.split('\n').find(one => /^name:/.test(one))
37 const name = line?.replace(/^name:\s*/, '').trim()
38 return name ? name : null
39}
40
41/**
42 * `pager ls --session <id>`: the ids of the messages addressed to this session,
43 * under any of its names. Only the ID column (`#<n>`) is read.
44 */
45export function inboundOf(run: Run): Set<number> | Failure {
46 const failure = failureOf(run)
47 if (failure) return { error: failure }
48 const lines = run.stdout.split('\n').filter(line => line.trim() !== '')
49 if (lines.length === 0 || (lines.length === 1 && lines[0]!.trim() === 'nothing here')) {
50 return new Set()
51 }
52 if (!/^ID\s+INBOX\b/.test(lines[0]!)) return { error: 'unexpected ls output' }
53 const ids = new Set<number>()
54 for (const line of lines.slice(1)) {
55 const m = /^#(\d+)\s/.exec(line)
56 if (m) ids.add(Number(m[1]))
57 }
58 return ids
59}
60
61type Exported = {
62 v?: number
63 id?: number
64 created_at?: string
65 alias?: string
66 sender_session?: string | null
67 sender_label?: string | null
68 origin?: string
69 body?: string
70}
71
72/** `pager export` JSONL, the rows of version 1. */
73export function exportedOf(run: Run): Exported[] | Failure {
74 const failure = failureOf(run)
75 if (failure) return { error: failure }
76 const rows: Exported[] = []
77 for (const line of run.stdout.split('\n')) {
78 if (line.trim() === '') continue
79 try {
80 const row = JSON.parse(line) as Exported
81 if (row.v === 1 && typeof row.id === 'number') rows.push(row)
82 } catch {
83 return { error: 'unreadable export line' }
84 }
85 }
86 return rows
87}
88
89/** The most of a body kept and drawn; the rest is named, with the way to read it. */
90export const BODY_CAP = 6_000
91
92/** A body on one line, as the band and the message index show it. */
93export function flatOf(body: string): string {
94 return body.trim().replace(/\s*\n\s*/g, ' ⏎ ')
95}
96
97/** This session's conversation: what it received (by id) and what it sent. */
98export function conversationOf(
99 rows: readonly Exported[],
100 inbound: ReadonlySet<number>,
101 sessionId: string,
102): Entry[] {
103 const entries: Entry[] = []
104 for (const row of rows) {
105 const id = row.id!
106 const isOut = row.sender_session === sessionId
107 const isIn = inbound.has(id)
108 if (!isOut && !isIn) continue
109 entries.push({
110 id,
111 at: row.created_at ?? '',
112 direction: isIn ? 'in' : 'out',
113 peer: isIn ? row.sender_label || 'unknown' : row.alias || 'unknown',
114 body: (row.body ?? '').slice(0, BODY_CAP),
115 length: (row.body ?? '').length,
116 isHuman: row.origin === 'human',
117 })
118 }
119 return entries.sort((a, b) => a.at.localeCompare(b.at) || a.id - b.id)
120}
121
122/** `pager who`: NAME TOOL ROOT HOST LAST, HOST being live, gone or unknown. */
123export function peersOf(run: Run): Peer[] | Failure {
124 const failure = failureOf(run)
125 if (failure) return { error: failure }
126 const lines = run.stdout.split('\n').filter(line => line.trim() !== '')
127 if (lines.length === 0) return []
128 if (!/^NAME\s+TOOL\b/.test(lines[0]!)) return { error: 'unexpected who output' }
129 const peers: Peer[] = []
130 for (const line of lines.slice(1)) {
131 const m = /^(\S+)\s+(\S+)\s+(.+?)\s+(live|gone|unknown)\s+(.+)$/.exec(line.trim())
132 if (m) peers.push({ name: m[1]!, tool: m[2]!, root: m[3]!, host: m[4]!, last: m[5]!.trim() })
133 }
134 return peers
135}
136
137/**
138 * The peers as the pane lists them: live hosts first, then the order pager gave
139 * (latest activity first), the home folder shown as `~`.
140 */
141export function rosterOf(peers: readonly Peer[], home: string): Peer[] {
142 const tilde = (root: string) =>
143 home !== '' && (root === home || root.startsWith(`${home}/`)) ? `~${root.slice(home.length)}` : root
144 const live = peers.filter(peer => peer.host === 'live')
145 const rest = peers.filter(peer => peer.host !== 'live')
146 return [...live, ...rest].map(peer => ({ ...peer, root: tilde(peer.root) }))
147}
148
149/** The highest inbound id, the baseline a look at the pane moves to. */
150export function newestOf(inbound: ReadonlySet<number>): number {
151 let max = 0
152 for (const id of inbound) if (id > max) max = id
153 return max
154}
155
156export function newCountOf(inbound: ReadonlySet<number>, baseline: number): number {
157 let count = 0
158 for (const id of inbound) if (id > baseline) count += 1
159 return count
160}
161
162/** `2026-10-07T05:48:19.655Z` → `10-07 14:48` in the host's time zone. */
163export function stampOf(iso: string): string {
164 const date = new Date(iso)
165 if (Number.isNaN(date.getTime())) return '--:--'
166 const two = (n: number) => String(n).padStart(2, '0')
167 return `${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}`
168}
169
170/** Peers other than `self` whose host pager judges live. */
171export function liveOf(peers: readonly Peer[], self: string): number {
172 return peers.filter(peer => peer.host === 'live' && peer.name !== self).length
173}
174
175/** How long before `nowMs` an ISO time was: `now`, `12m`, `3h`, `2d`. */
176export function agoOf(iso: string, nowMs: number): string | null {
177 const then = Date.parse(iso)
178 if (Number.isNaN(then)) return null
179 const minutes = Math.floor(Math.max(0, nowMs - then) / 60_000)
180 if (minutes < 1) return 'now'
181 if (minutes < 60) return `${minutes}m`
182 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`
183 return `${Math.floor(minutes / (24 * 60))}d`
184}
185types/index.d.ts 48 lines1/** One message of this session's pager conversation, oldest first. */
2export type Entry = {
3 id: number
4 at: string
5 direction: 'in' | 'out'
6 peer: string
7 /** The body as sent, line breaks kept, cut at BODY_CAP characters. */
8 body: string
9 /** The body's full length, so a cut one can say how much is left. */
10 length: number
11 isHuman: boolean
12}
13
14export type Peer = { name: string; tool: string; root: string; host: string; last: string }
15
16/**
17 * What the band and the /pager pane draw, for `sessionId`. `name` null: pager
18 * has no name for this session (or no pager at all), so nothing is drawn.
19 * `error` says the latest refresh failed; the rest is the last good read.
20 */
21export type PagerState = {
22 sessionId: string
23 name: string | null
24 entries: Entry[]
25 peers: Peer[]
26 newCount: number
27 /** Peers other than this session whose host is live. */
28 live: number
29 /** How long ago the last entry was, as of the refresh that read it (`3m`). */
30 lastAgo: string | null
31 /** When that refresh ran (ms since the epoch): the ages the pane draws count from it. */
32 now: number
33 error: string | null
34}
35
36export type Tab = 'messages' | 'peers'
37
38declare module 'claude-code' {
39 interface PluginState {
40 'pager-view': {
41 pager: PagerState
42 tab: Tab
43 /** The message the reader shows; null follows the newest. */
44 pick: number | null
45 }
46 }
47}
48