<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Evgeny Kharetski</title>
    <description>The latest articles on DEV Community by Evgeny Kharetski (@followlemmi).</description>
    <link>https://dev.to/followlemmi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4068165%2F91dc19e9-a9eb-4419-98a9-0f44b70e5868.jpg</url>
      <title>DEV Community: Evgeny Kharetski</title>
      <link>https://dev.to/followlemmi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/followlemmi"/>
    <language>en</language>
    <item>
      <title>I kept losing track of my Claude Code sessions, so I built a desktop deck for them</title>
      <dc:creator>Evgeny Kharetski</dc:creator>
      <pubDate>Fri, 07 Aug 2026 23:57:17 +0000</pubDate>
      <link>https://dev.to/followlemmi/i-kept-losing-track-of-my-claude-code-sessions-so-i-built-a-desktop-deck-for-them-4j9h</link>
      <guid>https://dev.to/followlemmi/i-kept-losing-track-of-my-claude-code-sessions-so-i-built-a-desktop-deck-for-them-4j9h</guid>
      <description>&lt;p&gt;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: &lt;strong&gt;who needs me right now?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/followLemmi/cowork-deck" rel="noopener noreferrer"&gt;cowork-deck&lt;/a&gt; — 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv2i2lasvp3u8r3oh9id2.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv2i2lasvp3u8r3oh9id2.gif" alt="A demo: four Claude Code sessions as tiles, one finishes its turn live, one zooms near-full, then a task board reading GitHub issues and a pull request opening into its diff."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is, briefly
&lt;/h2&gt;

&lt;p&gt;Each tile is a PTY-backed &lt;code&gt;claude&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Tauri v2 with a Rust backend, vanilla TypeScript in front, under ~100 MB of RAM. Free, open source, MIT.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 1: hooks, not output parsing
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;When cowork-deck starts a session, it passes a per-session settings JSON on the command line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;claude&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;--settings&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...report&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;start&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"UserPromptSubmit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...report&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;working&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...report&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;working&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...report&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;done&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"PermissionRequest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...report&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;waiting&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"SessionEnd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...report&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ended&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Two things fell out of this design that I didn't fully appreciate up front:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Finished a turn" and "waiting for a decision" are different states.&lt;/strong&gt; &lt;code&gt;Stop&lt;/code&gt; fires when an interactive session parks at its prompt — the work is done, nothing is blocked. &lt;code&gt;PermissionRequest&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Graceful degradation is free.&lt;/strong&gt; 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 &lt;code&gt;idle&lt;/code&gt;. A state channel that sits &lt;em&gt;beside&lt;/em&gt; the terminal instead of &lt;em&gt;inside&lt;/em&gt; it cannot take the terminal down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 2: sessions file their own tickets
&lt;/h2&gt;

&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;cowork-deck bundles a small CLI, &lt;code&gt;cowork_task&lt;/code&gt;, that sessions can call to file a card into the workspace's board: &lt;code&gt;cowork_task new "Rate limiter counts preflight requests" --kind bug&lt;/code&gt;. A side finding becomes a card instead of scope creep.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Stop&lt;/code&gt;-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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;beside&lt;/em&gt; the workspace, and starts a session there — the workspace's own working copy is never touched.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 3: the scheduler runs where you already pay
&lt;/h2&gt;

&lt;p&gt;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 &lt;strong&gt;on your machine, through your own Claude Code&lt;/strong&gt;, with your local context and your local permissions. No cloud agents, no second bill, no wondering what environment the job saw.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A run missed while the app was closed &lt;strong&gt;catches up once on the next launch&lt;/strong&gt;, however long it has been.&lt;/li&gt;
&lt;li&gt;A scenario whose previous run is still working — or still waiting for your decision — &lt;strong&gt;skips&lt;/strong&gt; the new run rather than stacking a second session onto the same job.&lt;/li&gt;
&lt;li&gt;A run that produced nothing (no workspace, a skipped overlap, &lt;code&gt;claude&lt;/code&gt; missing) is &lt;strong&gt;recorded rather than swallowed&lt;/strong&gt;: the scenario's row says what happened and when it last succeeded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 4: a GitHub account per workspace — without ever switching
&lt;/h2&gt;

&lt;p&gt;Some of my workspaces must push and open PRs as different GitHub accounts. The usual answer is &lt;code&gt;gh auth switch&lt;/code&gt;, and it's the wrong one: it mutates global state, so every other session — and your own terminal — silently becomes someone else mid-flight.&lt;/p&gt;

&lt;p&gt;cowork-deck never switches. The account is a workspace setting. When a session starts, the token is read from &lt;code&gt;gh&lt;/code&gt;'s keyring &lt;strong&gt;at that moment&lt;/strong&gt; and handed to the child process through environment variables — &lt;code&gt;GH_TOKEN&lt;/code&gt;, &lt;code&gt;GIT_AUTHOR_*&lt;/code&gt;, and &lt;code&gt;GIT_SSH_COMMAND&lt;/code&gt; where it's needed. The app stores no tokens and never touches &lt;code&gt;~/.config/gh&lt;/code&gt;, so different workspaces run as different accounts &lt;em&gt;at the same time&lt;/em&gt;, and the terminal you have open outside the app stays on whatever account was active there. Better still: with &lt;code&gt;GH_TOKEN&lt;/code&gt; set, &lt;code&gt;gh&lt;/code&gt; itself refuses to change accounts — a session cannot spoil its neighbours' environment even if it tries.&lt;/p&gt;

&lt;p&gt;Failure is designed too. If the account can't be attached — &lt;code&gt;gh&lt;/code&gt; missing, logged out, keyring locked — the session still starts, but with an empty &lt;code&gt;GH_CONFIG_DIR&lt;/code&gt;, so &lt;code&gt;gh&lt;/code&gt; says "not logged in" honestly instead of quietly working as somebody else. The tile carries a badge naming the reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision 5: the demo is recorded against fixtures
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest parts
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;xattr&lt;/code&gt; 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 &lt;em&gt;inside itself&lt;/em&gt;: the sessions that develop cowork-deck run as tiles in cowork-deck, filing their findings through the same &lt;code&gt;cowork_task&lt;/code&gt; CLI they'd tell you about.&lt;/p&gt;

&lt;p&gt;Repo: &lt;strong&gt;&lt;a href="https://github.com/followLemmi/cowork-deck" rel="noopener noreferrer"&gt;https://github.com/followLemmi/cowork-deck&lt;/a&gt;&lt;/strong&gt; — bug reports very welcome; the issues board reads them from inside the app, so filing one literally feeds the demo.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>rust</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
