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 a python-telegram-bot Application. 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 the aipager-hook helper, 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 stuck INTERACTIVE sessions (AIPAGER_INTERACTIVE_TIMEOUT).
  • SessionRegistry (aipager/state.py) — in-memory dict of TrackedSession objects keyed by name. Serializes to ~/.claude/aipager-sessions.json on shutdown and on every state transition that matters.
  • ObserverBroadcaster (optional, aipager/bot/observer.py) — if OBSERVER_BOTS is set, also forwards messages to read-only observer bots.
  • Mini App server (aipager/miniapp/server.py) — an aiohttp app bound to 127.0.0.1:8765, published to Telegram through a managed tunnel while enabled (the default). Serves the /app dashboard.

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:

  1. SessionRegistry.load() — reads ~/.claude/aipager-sessions.json, rehydrates TrackedSession objects, drops stale queue entries (>24 h).
  2. TelegramBot.__init__ + bot.start() — verifies token / chat, starts the polling loop.
  3. ObserverBroadcaster.start() — only when configured.
  4. HookReceiver.start() — unlinks any stale $XDG_RUNTIME_DIR/aipager.sock, binds fresh, listens.
  5. bot.recover_sessions() — for every BUSY session whose busy_msg_id exists, edit the Telegram message to reflect the live state. Skips vanished, too_old, flooded cases gracefully.
  6. SessionMonitor.start() — begins the 2 s tick.
  7. Daemon enters its asyncio.Event wait, ready for signals.

Shutdown sequence

From aipager/cli/daemon.py:

  1. SIGINT or SIGTERM sets the stop event.
  2. registry.save() — persist state.
  3. session_monitor.stop() — cancel the tick task.
  4. hook_receiver.stop() — close the datagram transport and os.unlink(config.SOCKET_PATH).
  5. observers.stop() if running.
  6. bot.stop() — cancel per-session animation tasks, stop the Application (which flushes pending edits).
  7. 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

PathPurposeOwner
$XDG_RUNTIME_DIR/aipager.sockUnix datagram for hook events (falls back to /tmp/aipager.sock)aipager daemon (binds)
/tmp/claude-dtach-<name>.sockdtach control socket per sessiondtach
/tmp/claude-status-<name>.jsonStatusline data per sessionaipager-statusline hook
/tmp/claude-notes-<name>/One permission note per not-yet-picked-up Telegram messageaipager daemon (written), aipager-hook (consumed)
/tmp/claude-policy-<name>.jsonCanonical permission snapshot for the running turnaipager-hook (written at pick-up), read by the PreToolUse check
~/.claude/aipager-sessions.jsonDurable registry stateaipager daemon
~/.claude/aipager-audit.jsonlAllow / Deny / answer logaipager daemon (append-only)
~/.claude/settings.jsonClaude Code hook configwritten by aipager config
~/.claude/settings.json.bak.*Backups before each rewriteaipager config
~/.config/aipager/aipager.yamlBot token, chats, members + roles, Mini App settingsaipager config (mode 600)
~/.config/aipager/policy.yamlPer-role rules + safety overridesuser (checked via aipager policy validate)
~/.config/aipager/daemon.envClaude credential for launched sessionsuser / aipager doctor --fix (mode 600)
~/.config/aipager/keyboard.jsonOptional keyboard overridesuser

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