I run several Claude Code sessions in parallel — one fixing a bug, one writing tests, one sweeping dependencies. The failure mode was always the same: a session hits a permission prompt, sits blocked for twenty minutes, and I only notice when I alt-tab through my terminals. Tmux helped with the layout, but it couldn't answer the only question I actually had: who needs me right now?
So I built cowork-deck — a desktop app where every session is a tile, and every tile is a real terminal. This post is about the design decisions that turned out to matter most: tracking session state with hooks instead of parsing output, letting sessions file their own tickets, a scheduler that respects a desktop app's reality, one GitHub account per workspace without ever switching, and a demo recorded against a fixture harness so no real data ever appears in the README.
What it is, briefly
Each tile is a PTY-backed claude process with xterm.js in front — not a wrapper, not a re-implementation of the UI. The bar down the left edge of each tile is the session's state: green working, amber waiting on you, red broken. Twelve sessions read in one sweep. A floating always-on-top pill counts the sessions blocked on a decision, so you can leave the app entirely and still know when one needs you.
The rest grew from daily use: scheduled prompts, a task board that reads either markdown cards or the repository's actual GitHub issues, and a GitHub account per workspace — the last two get their own sections below.
Tauri v2 with a Rust backend, vanilla TypeScript in front, under ~100 MB of RAM. Free, open source, MIT.
Decision 1: hooks, not output parsing
My first instinct was to parse the terminal output to figure out what a session is doing. Don't. Claude Code has a hooks system, and it is enough — the app never reads a byte of the terminal stream for state.
When cowork-deck starts a session, it passes a per-session settings JSON on the command line:
claude --settings '{"hooks": {
"SessionStart": [...report start],
"UserPromptSubmit": [...report working],
"PreToolUse": [...report working],
"Stop": [...report done],
"PermissionRequest": [...report waiting],
"SessionEnd": [...report ended]
}}'
Each hook entry runs a tiny companion binary that pings a localhost listener with the event kind and the session id. That's the whole protocol. The Rust side turns those pings into the tile's state label and, optionally, a desktop notification.
Two things fell out of this design that I didn't fully appreciate up front:
"Finished a turn" and "waiting for a decision" are different states. Stop fires when an interactive session parks at its prompt — the work is done, nothing is blocked. PermissionRequest fires when the session cannot proceed without you. Most tools collapse these into one "idle" state, and that collapse is exactly why people babysit terminals. Splitting them changed how I work more than any other feature: "done" gets a notification I can ignore for an hour; "waiting" goes into the pill I never ignore.
Graceful degradation is free. If a hook doesn't fire — an older CLI, a broken environment — the terminal is completely unaffected. You can still type, scroll, interact. The only symptom is a state label stuck on idle. A state channel that sits beside the terminal instead of inside it cannot take the terminal down.
Decision 2: sessions file their own tickets
Anyone who works with coding agents knows the scope-creep move: the session finds a real problem halfway through an unrelated task and either fixes it (now your diff is two things) or forgets it (now it's nobody's problem).
cowork-deck bundles a small CLI, cowork_task, that sessions can call to file a card into the workspace's board: cowork_task new "Rate limiter counts preflight requests" --kind bug. A side finding becomes a card instead of scope creep.
The interesting part is keeping the board honest without trusting the agent. When a session is launched from a card, the launch itself moves the card into the working column — before the session starts, so the board is right whether or not the agent cooperates. And a Stop-hook guard refuses the session's first attempt to finish while its card is still open, naming the exact call that would move it. Only the first attempt: a reminder the session cannot get past is a trap, not a reminder — and a card that genuinely belongs where it is stays there by the session saying so.
A workspace's board can also read the repository's actual GitHub issues instead of local cards. Press play on an issue and the app cuts a branch from the default branch, makes a worktree beside the workspace, and starts a session there — the workspace's own working copy is never touched.
Decision 3: the scheduler runs where you already pay
A scheduled scenario is a saved prompt with a schedule — hourly, daily at a given time, weekly — that fires unattended into a fresh session. The nightly dependency sweep, the morning "summarise what merged yesterday", the recurring release-notes draft: all of it runs on your machine, through your own Claude Code, with your local context and your local permissions. No cloud agents, no second bill, no wondering what environment the job saw.
Scheduling inside a desktop app means respecting an inconvenient truth cron never has to face: the app is not always open. So instead of pretending otherwise, the scheduler makes three promises:
- A run missed while the app was closed catches up once on the next launch, however long it has been.
- A scenario whose previous run is still working — or still waiting for your decision — skips the new run rather than stacking a second session onto the same job.
- A run that produced nothing (no workspace, a skipped overlap,
claudemissing) is recorded rather than swallowed: the scenario's row says what happened and when it last succeeded.
There's also a run-now button, drawn as a clock face with a play triangle — deliberately not a skip-forward glyph, because the run it starts does not consume the upcoming scheduled one.
Decision 4: a GitHub account per workspace — without ever switching
Some of my workspaces must push and open PRs as different GitHub accounts. The usual answer is gh auth switch, and it's the wrong one: it mutates global state, so every other session — and your own terminal — silently becomes someone else mid-flight.
cowork-deck never switches. The account is a workspace setting. When a session starts, the token is read from gh's keyring at that moment and handed to the child process through environment variables — GH_TOKEN, GIT_AUTHOR_*, and GIT_SSH_COMMAND where it's needed. The app stores no tokens and never touches ~/.config/gh, so different workspaces run as different accounts at the same time, and the terminal you have open outside the app stays on whatever account was active there. Better still: with GH_TOKEN set, gh itself refuses to change accounts — a session cannot spoil its neighbours' environment even if it tries.
Failure is designed too. If the account can't be attached — gh missing, logged out, keyring locked — the session still starts, but with an empty GH_CONFIG_DIR, so gh says "not logged in" honestly instead of quietly working as somebody else. The tile carries a badge naming the reason.
Decision 5: the demo is recorded against fixtures
The README needed a GIF, and my screen was full of real repositories, real accounts, real branch names. Blurring is fragile, and staging a fake project by hand is exactly the kind of chore that never gets redone after the UI changes.
The screenshot harness was already there: the real frontend booted against a mocked Tauri backend — every view renders from fixture data (invented workspaces, invented issues, invented terminal scrollback), and the terminals are real xterm instances fed fake bytes. The demo recording is a Playwright script driving that harness through one continuous take: the deck, a live state change (the mock can emit events the way the Rust side would), zoom, the issues board, a pull request's diff. A synthetic cursor is drawn on top, because Playwright's video has none and a demo where things happen with no visible cause reads as a slideshow.
Re-recording after a UI change is one command. Nothing real can leak, by construction — the fixture file's header literally forbids copying anything from a real machine. If you ship screenshots or demos of a dev tool, I recommend the pattern without reservation.
The honest parts
The macOS build isn't notarized — there's no paid Apple developer account behind this yet, so Gatekeeper calls the download "damaged". The README documents the one-line xattr fix, and building from source works everywhere. Sessions are children of the app window: close it and they end — there's deliberately no daemon mode. And the app was built with Claude Code, mostly inside itself: the sessions that develop cowork-deck run as tiles in cowork-deck, filing their findings through the same cowork_task CLI they'd tell you about.
Repo: https://github.com/followLemmi/cowork-deck — bug reports very welcome; the issues board reads them from inside the app, so filing one literally feeds the demo.

Top comments (0)