Architecture
aipager is a single-process asyncio daemon. It bridges three worlds:
your Telegram chat, the Claude Code CLI processes you run under
dtach, and the Claude Code hooks system. Everything funnels through
one event loop — there are no worker pools, no databases, no
inter-process queues.
Component diagram
Loading diagram…
The arrows are all in-process or local — no third-party servers participate beyond Telegram's API. See security for the network surface.
Process model
One asyncio.run invocation in aipager/cli/daemon.py runs the
whole show. Inside that:
TelegramBot(aipager/bot/) — owns apython-telegram-botApplication. Polls for updates, dispatches to message / callback / voice handlers, emits message edits for busy-state animations.HookReceiver(aipager/dtach/hook_receiver.py) — opens a unix datagram socket at$XDG_RUNTIME_DIR/aipager.sock, decodes JSON payloads from theaipager-hookhelper, dispatches by"event"field.SessionMonitor(aipager/session_monitor.py) — wakes every 2 s to scan/tmp/claude-dtach-*.sock, reconcile against the registry, and time out stuckINTERACTIVEsessions (AIPAGER_INTERACTIVE_TIMEOUT).SessionRegistry(aipager/state.py) — in-memory dict ofTrackedSessionobjects keyed by name. Serializes to~/.claude/aipager-sessions.jsonon shutdown and on every state transition that matters.ObserverBroadcaster(optional,aipager/bot/observer.py) — ifOBSERVER_BOTSis set, also forwards messages to read-only observer bots.- Mini App server (
aipager/miniapp/server.py) — an aiohttp app bound to127.0.0.1:8765, published to Telegram through a managed tunnel while enabled (the default). Serves the/appdashboard.
All of these run as async tasks on the same loop. No threads (except the faster-whisper executor for voice transcription, which is fire-and- forget per call).
Boot sequence
From aipager/cli/daemon.py:
SessionRegistry.load()— reads~/.claude/aipager-sessions.json, rehydratesTrackedSessionobjects, drops stale queue entries (>24 h).TelegramBot.__init__+bot.start()— verifies token / chat, starts the polling loop.ObserverBroadcaster.start()— only when configured.HookReceiver.start()— unlinks any stale$XDG_RUNTIME_DIR/aipager.sock, binds fresh, listens.bot.recover_sessions()— for everyBUSYsession whosebusy_msg_idexists, edit the Telegram message to reflect the live state. Skipsvanished,too_old,floodedcases gracefully.SessionMonitor.start()— begins the 2 s tick.- Daemon enters its
asyncio.Eventwait, ready for signals.
Shutdown sequence
From aipager/cli/daemon.py:
- SIGINT or SIGTERM sets the
stopevent. registry.save()— persist state.session_monitor.stop()— cancel the tick task.hook_receiver.stop()— close the datagram transport andos.unlink(config.SOCKET_PATH).observers.stop()if running.bot.stop()— cancel per-session animation tasks, stop theApplication(which flushes pending edits).- Process exits cleanly.
The Telegram-driven self-restart relies on this entire path running to completion before the spawned replacement binds the socket. See bot commands → restart for the user-facing behaviour.
File and socket layout
| Path | Purpose | Owner |
|---|---|---|
$XDG_RUNTIME_DIR/aipager.sock | Unix datagram for hook events (falls back to /tmp/aipager.sock) | aipager daemon (binds) |
/tmp/claude-dtach-<name>.sock | dtach control socket per session | dtach |
/tmp/claude-status-<name>.json | Statusline data per session | aipager-statusline hook |
/tmp/claude-notes-<name>/ | One permission note per not-yet-picked-up Telegram message | aipager daemon (written), aipager-hook (consumed) |
/tmp/claude-policy-<name>.json | Canonical permission snapshot for the running turn | aipager-hook (written at pick-up), read by the PreToolUse check |
~/.claude/aipager-sessions.json | Durable registry state | aipager daemon |
~/.claude/aipager-audit.jsonl | Allow / Deny / answer log | aipager daemon (append-only) |
~/.claude/settings.json | Claude Code hook config | written by aipager config |
~/.claude/settings.json.bak.* | Backups before each rewrite | aipager config |
~/.config/aipager/aipager.yaml | Bot token, chats, members + roles, Mini App settings | aipager config (mode 600) |
~/.config/aipager/policy.yaml | Per-role rules + safety overrides | user (checked via aipager policy validate) |
~/.config/aipager/daemon.env | Claude credential for launched sessions | user / aipager doctor --fix (mode 600) |
~/.config/aipager/keyboard.json | Optional keyboard overrides | user |
The daemon writes nothing outside ~/.config/aipager, ~/.claude/,
and its control socket. It never elevates — see
security.
Why dtach
dtach gives each Claude Code session a real PTY without binding it
to a terminal that has to stay open. The aipager daemon attaches
non-interactively via dtach -a -E to read the output stream and
inject keystrokes; the user can also attach interactively from any
shell via aipager session <name> for direct access.
The result: Claude Code runs as if you typed in a terminal, but
that terminal can come and go without disturbing the running session.
The daemon discovers sessions by scanning /tmp/claude-dtach-*.sock
on each 2 s monitor tick.
See also
- Hook events — what flows in over the control socket.
- Bot commands — what flows in from Telegram.
- Security model — privilege boundary, secrets, audit.
- Troubleshooting —
aipager doctorreference.