Skip to content
PyinvPublic

About

A live wall for your tmux Claude Code sessions — see, answer and drive every session from your phone over Tailscale

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

Office — a live wall for your tmux Claude Code sessions

A self-hosted web dashboard that shows every tmux-based Claude Code session as a named "team" you can glance at, answer, and manage — from your phone or laptop — over your private Tailscale network. Nothing is exposed to the public internet; your tailnet is the trust boundary.

It reads tmux + Claude Code transcripts to tell, per session, whether Claude is working, waiting on you, or finished (your move) — so a wall of parallel sessions becomes glanceable.

Features

  • Live floor — rooms (ventures / clients / personal) of desks, one per tmux session: face, name and project, the most recently active first (pinned before all). Its state is what Claude Code itself writes (its status file under ~/.claude/sessions: busy, idle or waiting, and for what), never a guess from the screen or the reply's wording: a session waiting on you gets an amber note, Answer for an open question (with its choices), Allow for a permission prompt (with the command or file it is for), Waiting for any other dialog, and a chip in the Your move row. The look is written down in DESIGN.md.
  • Questions and permissions — Claude Code writes an open question to its transcript only once it is answered, so a hook hands it over while it is open: hooks/ask.py keeps the JSON of PreToolUse(AskUserQuestion) and PermissionRequest in ~/.claude/office-asks/<session id>.json. Install it once with python3 hooks/install.py (adds three entries to ~/.claude/settings.json, keeps a backup). Without it a question still shows as waiting, answered in the terminal.
  • A person's panel — tap a desk: the last ten exchanges from its transcript (GET /api/turns?session=…, read only while the panel is open), each of your messages followed by everything Claude wrote after it, whole and in order, an open question's choices as buttons (each presses that choice's key in the terminal; a permission prompt gets Allow / Deny), a box to answer (typed in via tmux send-keys) and one ⋯ menu for terminal, pin, mute, focus note, move, icon, rename, close and erase. A desk's icon comes from its project name (keywords in emoji.local.json); Change icon… sets your own for that desk only (icons.json), Reset goes back to the guess. On a laptop a desk can also be dragged to another room. A side panel on a laptop, a full-screen sheet on the phone; double-click a desk to go straight to its terminal.
  • Full terminal — a real interactive terminal per session (via ttyd), with seamless select-to-copy, an open-in-new-tab button and, on a laptop, a rail of faces to hop between desks. Each terminal has its own address (/<session-name>), so a refresh, a bookmark or a shared link lands straight in it.
  • Phone terminal — on touch devices, swipe to scroll and a key bar above the keyboard with esc, tab, arrows, enter and the Ctrl keys (^B works as the tmux prefix). Paste uses the clipboard when the browser allows it and falls back to a paste box when it doesn't.
  • Organize — pin, categorize (clients / ventures / personal), and attach a focus note (main task + sub-tasks) that stays on the desk.
  • Manage — rename a session (tmux and its Office state move together), kill it, or spawn a new one from a form (person, project, folder, category) confined to your dev root. Sessions that died or were killed stay in history, so "New desk" can bring one back by name with its old folder and category — and resume its last Claude conversation. When Claude exits inside a session, the pane drops to a shell instead of closing.
  • Erase (in a desk's ⋯ menu) — removes an agent completely: the tmux session, its folder, its Office entries, and everything Claude keeps about that folder (transcripts, memory directory, file-history snapshots, prompt-history lines). It is a dry run first: the dialog lists what would go and, read from the agent's own transcripts, every file it wrote outside its folder and every shell command that touched outside paths — you tick what should go too, type the session name, then it runs. A folder shared with another session is kept.
  • ⟳ Restore / Update agents — the server keeps live.json (which sessions are alive, with folder, group and conversation id). After a reboot or a tmux crash the header offers Restore N agents: every session comes back in one click, each on its own conversation (claude --resume). When a new tmux server starts, the old server's set is kept as a restore baseline, so restoring one agent by hand or starting a new one first doesn't drop the others from the list; an agent closed or erased through the Office is never offered again (restore.py). When Claude Code installs a new version ("Restart to update"), the same button offers Update N agents: each idle agent is quit (/exit) and started again in the same pane on the same conversation, with the same permission mode and any unsent draft typed back in; agents that are working or showing a question are left alone and listed. GET /api/restart is the plan, POST /api/restart {restore:[…], update:[…]} runs it, and the plan's job.log is the report.
  • Settings (⚙) — per device, kept in the browser: appearance (system / dark / light — the terminal itself stays dark so the agents' own colours stay readable), terminal text size (also reachable from the Aa key on the phone's key bar, applies to the open terminal at once), notifications, and the agents' restore/update row. After a reboot a banner under the header offers to bring the lost agents back in one tap. The Team/Status chips fold out behind a Filters button next to the search box.
  • Share from the phone — GET /api/drop lists the live agents ("Demo App | Max": "max-demo-app", most recent first) and POST /api/drop?session=<s>&name=<file> with the file as the body saves it to that agent's inbox/ and tells the agent in its terminal; POST /api/drop?session=<s>&text=1 types the body as a message. The iPhone app's Share Sheet entry (ios/) sends a whole share in one request (POST /api/share); /api/drop serves anything else that can post a file, e.g. an iOS Shortcut. Several items shared at once (WhatsApp messages and photos, a set of screenshots) arrive as one message, in order: each upload joins a batch for that agent, which goes out when item n of n arrives (&i=<index>&n=<count>), or after a few quiet seconds when the Shortcut doesn't send a count. A batch waits while the agent has a question open or an unsent draft, and pushes a notification if it waits long.
  • iPhone app (ios/) — an optional native shell around the same page, for one reason: iOS puts a keyboard accessory bar above every web text field and only a native app can remove it. Build it once with sh ios/make.sh on a Mac with Xcode; the Office keeps working the same for everyone without it. See ios/README.md.
  • Secretary — a chief of staff at the front desk (the bottom of the floor on a laptop, the top on the phone): type "ask Lena to run the risk file, then have Mara draft the section" and it finds those sessions and relays the instruction (real send-keys).
  • Usage dashboard — tokens, spend, and rate-limit meters at /status/.
  • Phone push — optional: when a session turns to you with a question, the server posts "Name needs you" plus the question to an ntfy topic, with a tap target straight into that terminal; also when it finishes, when a Secretary follow-up fires, and when a session disappears. Mute phone pushes in a desk's ⋯ menu mutes them for that session. Point it at your own ntfy server and nothing leaves your network (iOS still gets its wake-up through ntfy.sh, carrying only the topic name and a message id). See OFFICE_NTFY_* in .env.example.

How it works

File Role
server.py Python stdlib HTTP server: serves the app + JSON API, reverse-proxies the terminal. Binds the Tailscale IP only; refuses to start without one.
team.py The detector: reads tmux panes + ~/.claude/projects/*/*.jsonl transcripts and classifies each session.
app.html The entire client — one file, inline CSS + JS.
status/ Usage dashboard: build.py aggregates transcripts → data.json; index.html renders it.
serve.sh / serve-term.sh Launch the web server / the ttyd terminal.
keepalive.sh Cron watchdog — keeps both running (tailnet-only), self-heals on reboot/crash.
reload Hot-reload the running server in place (SIGHUP re-exec).

Person + project come from the tmux session name (lena-payments → Lena / payments). tmux is the single source of truth for identity — there is no roster file.

Requirements

  • Python 3.10+ — standard library only, no pip dependencies.
  • tmux — your sessions live in tmux.
  • ttyd — for the interactive terminal: sudo apt install -y ttyd.
  • Tailscale — the app binds your tailnet IP and is reachable only from your own devices.

Setup

  1. Configure (all personal/instance settings live in .env, which is gitignored):

    cp .env.example .env
    $EDITOR .env          # set OFFICE_TITLE; optionally OFFICE_EMAIL / OFFICE_TAILNET_HOST

    Optional: create emoji.local.json — a { "project-keyword": "emoji" } map for per-project avatars, e.g. { "web": "🌐", "api": "🔌" }.

  2. Tailscale up: tailscale up. Optionally front it with a clean HTTPS URL (valid cert, no port):

    tailscale serve --bg <port>      # serves https://<machine>.<tailnet>.ts.net/
  3. Run it (both bind tailnet/localhost only):

    ./serve.sh &          # web + API  (binds your Tailscale IP)
    ./serve-term.sh &     # ttyd terminal (private unix socket, proxied at /terminal)

    Or let the watchdog run them from cron so they survive reboots and crashes:

    @reboot        /path/to/office/keepalive.sh
    * * * * *      /path/to/office/keepalive.sh
  4. Open http://<machine>.<tailnet>.ts.net:<port>/ (or your tailscale serve HTTPS URL) from any device on your tailnet.

Configuration — .env

Key Default Purpose
OFFICE_TITLE Office Header + browser-tab title
OFFICE_EMAIL (empty) Optional, shown on the status page
OFFICE_TAILNET_HOST (auto) Optional; the machine's tailnet name is auto-detected from tailscale status
OFFICE_DEV_ROOT ~/develop Where "New desk" may create project folders
OFFICE_CLAUDE_BIN $(which claude) Path to the claude binary
OFFICE_PORT 8899 Port for the web server
OFFICE_STATUS_URL /status/ Header "Usage & limits" link; empty hides it
OFFICE_FILES_URL (off) Header "Files" link, e.g. a /dufs/ mount; empty hides it
OFFICE_SECRETARY_NAME Secretary Display name for the dispatcher panel
OFFICE_ELEVENLABS_KEY (off) ElevenLabs API key: the secretary reads What's new aloud
OFFICE_SECRETARY_VOICE (off) ElevenLabs voice id she speaks with
OFFICE_TTS_MODEL eleven_multilingual_v2 ElevenLabs model
OFFICE_BRIEF_LANGUAGE Egyptian Arabic Language of the spoken briefing (the rows on screen stay English)
OFFICE_BRIEF_OPENER Egyptian "let me see what the team is up to" Said the moment you tap, while she reads the floor; {name} is your name, empty turns it off
OFFICE_OPERATOR_NAME (none) What she calls you when she speaks, title included; vowel marks (e.g. Arabic tashkeel) steer how the voice says it and are kept
OFFICE_DEMO (off) 1 (or a path) → serve demo.json fixtures instead of live tmux

Demo mode

Want a populated floor without wiring up real tmux sessions — for a screenshot, a talk, or just to try it? Turn on demo mode; it serves the fictional team in demo.json:

OFFICE_DEMO=1 ./serve.sh

Set OFFICE_DEMO=1 in .env to make it stick, or point it at your own fixture (OFFICE_DEMO=/path/to/my-demo.json). demo.json is just a list of session cards — copy it and edit the names, messages, and group (clients / ventures / personal). An ago_s field on a card sets "how long ago" it last replied, so the demo always looks live. All bundled demo data is fictional.

Secretary

The Secretary sits at the front desk — your chief of staff. Type a plain-language instruction and it routes it to the right session:

ask Lena to run the risk file, then have Mara draft the key-usage section

It matches the names against the live team, relays a polite instruction (Please run the risk file) into that session via tmux send-keys, and confirms who it went to. In demo mode there's no real tmux, so it simulates the dispatch and the target desk visibly reacts on the floor. Rename the assistant with OFFICE_SECRETARY_NAME.

🔊 What's new next to Ask catches you up: first whoever waits on you (a question, a permission, a dialog), then every finished reply you haven't read, newest first, then who is still working. Each row opens that desk. Who is in it, in what order and why comes from the floor alone; the model only writes one line per row and the spoken text, and ElevenLabs reads it out (OFFICE_ELEVENLABS_KEY, OFFICE_SECRETARY_VOICE). The same floor gets the same briefing back without a second model call.

A reply counts as not read (a green dot on the desk) when Claude finished it after the last time you looked: opened its panel, sat in its terminal (tmux says attached), or sent it something. Those times live in seen.json.

Security model

  • Tailnet-only. The server binds your Tailscale IP and refuses to start without one; never the LAN or the public internet. ttyd listens on a private unix socket (no TCP port) and is reverse-proxied at /terminal. A browser cannot open a websocket to a unix socket, so no foreign page can reach the shell.
  • CSRF Origin allowlist gates every state-changing POST and the terminal websocket; the machine's own MagicDNS name is detected live, so a tailnet rename self-heals.
  • Static serving is allowlisted — only the dashboard's own assets are served as files; .env, runtime state, source, and notes are never web-served.
  • No app-layer auth, by design. Anyone on your tailnet can drive the sessions, and the terminal is a real shell. Treat tailnet access as full trust: keep your tailnet private and don't share the node casually.

What stays local (gitignored)

.env, emoji.local.json, and the runtime state (groups.json, pins.json, tasks.json, history.json, mutes.json, status/data.json) hold your own config and session data — they are gitignored and never committed.

Tests

Pure-stdlib, no dependencies and no tmux required — the detector's helpers are tested against synthetic transcripts in a temp dir:

python -m unittest discover -s tests -v

CI runs the same suite on Python 3.10–3.13 for every push and pull request (see .github/workflows/ci.yml).

The browser tests in tests/ui/ (settings, the phone header, the terminal key bar) run against a live Office and are skipped unless both are present:

pip install -r tests/requirements-ui.txt && playwright install chromium   # once
OFFICE_URL=http://<your-tailscale-ip>:8899 python -m unittest discover -s tests -v

Versioning

This project follows semantic versioning. The current version is exposed as server.__version__ and in the /api/floor response. Releases are git-tagged (vMAJOR.MINOR.PATCH).

License

MIT — see LICENSE.

About

A live wall for your tmux Claude Code sessions — see, answer and drive every session from your phone over Tailscale

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages