Releases: followLemmi/cowork-deck
Release list
cowork-deck v0.5.0
The first release since 0.4.1 that carries anything: that one was a single cherry-picked fix, and
dev had been more than a hundred commits ahead of main ever since. Three things ship, each an
epic of its own — a workspace can leave the window, your setup can follow you to another machine, and
the interface is a new one.
A workspace in a window of its own
Pull a workspace out — the button on its row, or drag it out of the window on macOS — and it gets a
window holding that workspace and nothing else. Its sessions go with it, alive, with their scrollback:
the new window claims each one before the old lets go, so a failure at any point leaves them where
they were rather than nowhere.
In the main window the workspace does not vanish. Its row stays, marked as being elsewhere, with its
sessions still listed under it and still counted; clicking either raises the window that has them.
Closing that window hands the workspace back, and there is an explicit way to send it back without
closing anything.
A pulled-out window is now that project's and only that project's: no rail, one workspace in the
panel, and no app-wide settings inside a window that is one repository. It also stops opening on
whichever workspace the main window had active — that answer lives in a file the main window owns.
The same setup on your other machine
Workspaces, their GitHub bindings, scenarios and the run journal can live in a private GitHub
repository of your own. Off until you switch it on, from Memory sync… in the command palette;
switching it on needs gh and a connected account, and offers both halves of the job — create a
private repository, or connect one you already have, which is what every machine after the first does.
What travels is an allowlist, and a test asserts the tracked set equals it exactly: session layout,
window state and terminal drawers stay on the machine they belong to. Absolute paths never travel
either — a workspace arriving from elsewhere has no folder here until you point it at one, and a
schedule arrives switched off, so a job that fires at 03:00 does not start firing at 03:00 on two
machines at once. Conflicts are not resolved for you: notes are prose, and an automatic merge
produces a plausible paragraph nobody wrote.
The repository is private, and worth keeping that way.
A new interface
True Ink replaces Slate & Ember, for two reasons neither of which is taste: the warm cast read as
brown beside the working green, and height was declared and not delivered — a large drop shadow has
nowhere to go on a ground that is already nearly black. The cast is cool and almost absent now,
elevation is lightness rather than shadow, and the accent is light itself. Hue still belongs to state,
and only to state: green working, amber waiting on you, red broken. The terminal deliberately keeps
One Dark — it is a window onto another program.
And there are no screens. The window is a rail, one panel and a deck that never leaves the row;
when a page needs width the deck yields into its filmstrip rather than disappearing. Workspaces and
sessions are one tree, so creation is positional — the last row inside each group is New session in
<name>, at the place the new session will appear. The board and the pull requests left the rail for
the workspace they belong to, because they are one repository's rather than the app's. The four tabs
in the top bar became a ledger — "1 waiting for a decision", "1 stopped on an error" — each reading
opening one of the sessions it counted, and a crumb beside it says which workspace and which account
the window is on.
A session row is two lines now: the name on a line of its own, the state and the git branch under it.
The state chip used to lead every row, and it is the widest thing there, so a dozen sessions read as a
column of pills rather than as a list of names.
Every colour pair the interface claims is measured by npm run contrast, which fails the build when
one falls under its threshold.
Also in this release
- Settings became a window of its own at the foot of the rail, with a rail of sections inside it.
- Every panel takes a width from the person using it, and remembers it.
- A zoomed session gets its own tools, scoped to its own checkout.
- The last Russian strings left the interface; it is English throughout.
Fixed
- Pressing a session that belongs to another workspace moved the deck and nothing else — the panel,
the crumb, the board and the pull requests all stayed on the workspace you had just left. - Two windows could lose each other's tiles:
sessions.jsonhad no owner, and each window wrote the
whole file. Rust now refuses a write or a resize from a window that no longer holds the session. - The first sync could not work at all, and a pull could destroy records.
- A window that had closed was not let go of, so its workspace stayed marked as elsewhere and
unselectable. - The sidebar could re-render itself in a loop.
- The panel clipped its own pages at a narrow window.
- Sessions get the login shell's
PATHon every route, not only some of them (this one shipped in
0.4.1 and is here for completeness).
Not in this release
Project memory is the next thing being built. Its sidecar is in the bundle and tested, and nothing in
the app calls it yet — what travels in sync today is a place kept for the corpus rather than a corpus.
Install
macOS — download the .dmg for your architecture (aarch64 for Apple Silicon, x64 for Intel),
drag the app to Applications, then clear the quarantine flag once:
xattr -cr /Applications/cowork-deck.app
That wording Gatekeeper uses — "the app is damaged" — means the bundle is not notarized, not that
anything is wrong with it. There is no paid Apple Developer account behind this project yet.
Linux — download the .AppImage, chmod +x it and run, or install the .deb.
The app checks this page for updates on launch and installs them in place; the xattr step is
first-install only.
Upgrading from 0.4.x — nothing to do. Your workspaces, scenarios and sessions are read as they
are; sync is off until you switch it on, and no existing setting changes meaning.
cowork-deck v0.4.1
A patch release carrying one fix, lifted out of dev on its own because it makes
the app unusable with any MCP server and nothing else in this release is worth
waiting for.
Sessions get the login shell's PATH (#332)
A session launched from the app ran with launchd's
/usr/bin:/bin:/usr/sbin:/sbin and nothing else, so everything the agent then
spawned failed to resolve: stdio MCP servers (npx …, #!/usr/bin/env node
shims), hooks, the Bash tool. What that looked like from the outside was a set of
MCP servers that simply never came up —
Connection failed (ENOENT): Executable not found in $PATH: "npx"
Server stderr: env: node: No such file or directory
— while the same servers, run by hand from a terminal, answered normally.
The nesting is why it went unnoticed for so long. The app captured a PATH only
when finding claude had to go through the login shell, which reads as the right
condition and is not: the PATH is needed less by claude itself than by
everything it starts. An npm-installed claude happened to work, because it
fails --version under launchd's PATH, falls through to the login shell and
picks up an environment on the way. A natively installed claude is a
self-contained binary, passes that check, and short-circuits the only stage that
captured anything — so its sessions found no node at all.
The login shell's PATH is now asked for on every route, once per run and warmed
while a workspace is being prepared, so no launch pays for a login shell on a path
anybody is watching. A claude found through the login shell keeps the exact
environment it was validated under.
One deliberate change of behaviour: COWORK_CLAUDE_PATH now gets the login
PATH too. The override names a binary, not an environment.
Nothing else changed
No feature, no settings, no on-disk format. This is 0.4.0 with node back on the
session's PATH, and everything listed as still open there is still open.
Install
macOS — download the .dmg for your architecture (aarch64 for
Apple Silicon, x64 for Intel), drag the app to Applications, then
clear the quarantine flag once:
xattr -cr /Applications/cowork-deck.app
Linux — download the .AppImage, chmod +x it and run, or
install the .deb.
The app checks this page for updates on launch and installs them
in place; the xattr step is first-install only.
cowork-deck v0.4.0
Until now the app had no terminal in it. Working on a repository through the deck
meant alt-tabbing to a separate window, standing in a directory the app could not
know about and under whatever git identity the machine happened to have. This
release puts a shell in the app — and, because a shell breaks every assumption the
PTY layer was quietly built on, fixes the eight defects that finding out produced.
The terminal is also no longer the slow part of the window: it draws from a glyph
atlas rather than by rebuilding DOM rows, its output crosses into the webview
batched and as bytes, and neither a drag nor a resize asks a running agent to
repaint itself a hundred and fifty times on the way.
What people get
An embedded terminal, per workspace (#225). A drawer under the deck holding
ordinary interactive shells. Cmd+J (Ctrl+Shift+J on Windows and Linux) opens
it, and opening it empty opens a shell — a strip with a + and nothing in it is a
worse answer to "give me a terminal" than a terminal. Tabs, +, double-click to
rename, drag the top edge to resize.
- A drawer, not another tile kind. A deck tile is one unit of agent work: a
state chip driven by hooks, a restart that resumes a conversation, a broadcast
checkbox, a name read out of a transcript. A shell has none of that and would
carry four controls that mean nothing while competing for the space the sessions
are the point of. What a terminal needs is tabs, and to be out of the way when it
is not in use. - It is your own
$SHELL, started in the active workspace's folder, carrying
that workspace's GitHub account — a login shell on macOS, where an.app
otherwise inherits launchd's minimalPATH. - One line before the first prompt names the folder, the branch, the account and
the git identity it will commit as. That line is the only way to check the last of
those: the binding is injected asGIT_AUTHOR_*, which outranks.git/config, so
git config user.emailinside the shell reports the value that loses. Output is
held until the banner is written, because a prompt that got there first would push
the explanation below the thing it explains. - A terminal belongs to its workspace the way a tile does. Switch projects and
you get that project's terminals, on the tab you left it on; a project you have
never opened one in has no drawer at all, rather than an empty strip shortening
its deck. Nothing is closed by switching — the shells keep running and their
scrollback is where you left it, which is also what makes coming back instant. A
terminal whose workspace was deleted stays visible from everywhere, exactly as
an orphaned tile is: a terminal nobody can switch to is a terminal nobody can
close. - Tabs survive a restart as new shells in the same folders under the same names.
That is the honest most a shell can offer — there is no conversation to resume. - Eight at once, and closing one that is running a job asks first. A shell has
no hooks, so its state would readidlefour minutes into a release build;
the process table is read instead.
Nothing is torn down without asking (#230). Quitting the app now refuses the
first gesture and names the sessions with something running in them — a build, a
test run, a command in the drawer. A second gesture goes through regardless, so the
app can never become unquittable. A session sitting at a prompt, and an agent's own
long-lived helpers, are not counted: a question that is always there is one people
learn to click through.
And "killed" now means the process session (#226), not the one process the app
started. A shell puts each command it is given into a process group of its own, so a
npm run build was never reachable by signalling the shell — it outlived the
session that started it. Teardown is now SIGTERM to the foreground group, SIGHUP and
SIGTERM to the leader, a grace period, then SIGKILL to whatever is still in that
process session, guarded so it can never match the app's own. The same teardown runs
however the app ends: closing the window, Cmd+Q, or the updater relaunching it.
A terminal that draws at the display's rate (#261, in part; #186, in part).
- The renderer. Only
fit,searchandunicode11were loaded, so xterm ran
its DOM renderer and every refresh of a dirty row rebuilt all of that row's spans.
@xterm/addon-webgldraws from a glyph atlas instead. A cap comes with it and is
not optional — WebKit keeps a process-wide ceiling on live WebGL contexts and
force-loses the oldest rather than refusing the next, so an uncapped tile scrolled
out of sight would blank the terminal someone is reading. Eight contexts, handed to
whichever panels are on screen, a freed slot going to whoever waited longest, and a
lost context falling back to the DOM renderer rather than to nothing. - The cursor stops strobing. It was a DOM-renderer defect, not a font one:
blinking is a CSS animation on the cursor's span, and the renderer rebuilds that
span on every repaint of its row, restarting the animation at 0%. Under a TUI that
repaints its input line continuously the cycle never completed. The WebGL renderer
blinks off a 600 ms timer no repaint touches, socursorBlinkfollows the
renderer: on with it, off without. A cursor that does not blink is a small loss; a
cursor that strobes is the complaint. - Typing no longer queues behind output. One PTY read was one
evaluateJavaScripton the webview's main thread — the same thread that delivers
keystrokes — and on Darwin the tty caps a single read at 1024 bytes, four times
smaller than Linux's 4096, so the same agent output cost four times the calls on a
Mac. Reads landing within 4 ms of each other are now passed on as one, capped at
64 KB. 4 ms sits under a frame at 240 Hz, and it is also the worst case added to a
keystroke echo, which is the one place the latency would be felt. - Output crosses as bytes.
app.emitembeds its payload as a JSON literal in a
JS source string, so terminal output was base64'd (+33%), pasted into JavaScript,
parsed by the JS parser,atob'd, and walked a byte at a time by a callback. A
per-sessionChannelsends binary over the custom protocol instead. None of those
four steps remain, and the frontend no longer walks the tile map once per chunk to
work out whose bytes it is holding. - A drag reads layout once and writes once a frame (#262, in part). Both pointer
drags wrote a size and read layout straight back insidepointermove, forcing a
synchronous layout, so the engine never got to coalesce two events into one frame
and every event paid the whole cost. Against a 2500-row diff with five pointer
events in a single frame: 85.9 ms of blocking and 15 layout reads, down to 0.2 ms
and none.src/drag.tsnow holds the rule so the next grip is born with it. - A resize reaches the PTY once, when it is over (#263, in part). A resize is an
ioctl, aSIGWINCHand a full-screen redraw by whatever is running —claude
answers one by drawing its entire interface again. Nothing throttled it. A window
resized in 32 steps with five live terminals went from 10 calls, all of them
mid-gesture, to 0 mid-gesture and then 5: one per terminal, at the final size.
Trailing edge, because the size that reaches the child has to be the one the
gesture ended on.
Defects the shell uncovered
All eight were found by asking what an ordinary shell needs that claude never did.
pty.rs now states its four assumptions at the top and enforces them.
- #227 — respawning under a live session id let a dead process paint into its
successor's terminal.spawnrefuses a live id unless the restart button passes an
explicit flag, and each spawn carries a generation its reader and waiter check. - #228 — exit was one boolean, which made "your build failed", "we hung you up at
shutdown" and "the wait itself failed" the same value. It now carries an exit code,
a signal name and an unknown flag; a signalled process reads as ended rather than
errored, and the tile prints what happened. - #229 —
CloseRequestedwas handled for any window whileAppStateis
app-level, so oneclose()on the status pill would have killed every session in
the app. Scoped to the main window. - #231 —
start_sessionresolvedclaude's location and the workspace'sgh
token inline, on the thread that paints the window: up to ten seconds of freeze per
launch, while the doctrine at the top ofcommands.rsclaimed the session commands
could not block. Both are resolved off that thread now, before each launch.
Keystrokes typed before the process exists are held and flushed in order rather
than discarded. - #232 —
GIT_SSH_COMMAND's key path was unquoted, and git hands that variable
to/bin/sh, so a path with a space in it made ssh read the rest as a hostname.
IdentitiesOnly=yesis dropped with it: it is not scoped per remote, so inside the
app it broke every push to any host but the bound one. - #233 — the shared
ghno-auth directory was writable, so thegh auth login
that "you are not logged in" invites could turn one account into app-wide state. It
is read-only now, and a degraded session setsGIT_TERMINAL_PROMPT=0so git fails
rather than soliciting a credential that would be cached globally. - #226 and #230 are above, under what people get — both change behaviour
people will notice. - A channel cannot outlive the process it was opened for, found while replacing
the transport. Tauri sends{ end: true }when the Rust half is dropped and the JS
half unregisters its callback id, but the object survives and still serialises to
that id — so handing it to a second spawn invoked cleanly and every write was
dropped with a console warning. Since the restart button reuses the panel, a
restarted session would ...
cowork-deck v0.3.0
A scenario used to leave nothing behind. sessions.json dropped the entry when
the tile closed, so a schedule that fired at 03:00 into a session nobody was
there to watch was unrecoverable by morning — the only surviving trace anywhere
was one row per scenario in schedule_state.json, written by the scheduler and
only for scheduled fires. This release gives scenario runs a journal and a
screen to read it on.
What people get
Scenario run history (#217, in three parts: #218, #219, #220). Every run is
journalled — how it started, how long it took, and the final assistant message —
and a History screen beside Terminals / Board / Pull requests lists them
newest first, filterable by scenario and by trigger.
- The journal is
runs.jsonlin the app data dir, one appended line per event.
Append-only, because a store that truncates before it writes cannot be
trusted with a file written on every launch; versioned per line, because
after an upgrade the file physically holds lines from several app versions and
a header would lie about the ones below it. - Rows carry snapshots, not lookups: the name, icon, expanded prompt and
parameter values as they were. Renaming a scenario, deleting it, or deleting
its workspace cannot rewrite the past. - A row that produced nothing says which nothing it was — still running, no
session started (with the scheduler's reason), no transcript ever reported, or
the transcript is gone. Never an empty box: the run happening and the run
producing nothing are different facts. A conversation that was/cleared
since says so out loud rather than presenting a stranger's tail as its result. - Three actions on a row, and deliberately no fourth. Go to the session
while it is still live, switching workspace first if it is running in another
one. Re-run, through the usual "Launch parameters" form with the recorded
values pre-filled and selected — never silent, because a scenario's parameters
may name a branch, a target or a person — and reconciled against the scenario
as it stands now, so a placeholder the prompt has since lost is dropped and
one it has gained comes up empty. Reveal the transcript in the file
manager, reveal only, neveropen: a.jsonlis not handed to whatever is
registered for it. - Where an action cannot work the control says why, on itself and in its
accessible name — a scenario that was deleted, a workspace that was, a
transcript that was never written.aria-disabledrather thandisabled, so
the reason is reachable without a mouse. - History is immutable. No row can be edited or deleted; a journal whose
rows can be revised answers nothing. Erasing exists at one granularity —
a scenario's history wholesale, from the head of the screen while it is
narrowed to that scenario, so the rows it erases are the ones on screen. - Recording can be switched off, and the screen says so rather than looking
empty. Retention is the last 100 runs per scenario, pruned at app start —
a time window would keep three records for a monthly job and two thousand for
an hourly one. - The scenario's sidebar row grows a state dot for how its last run went, which
opens the history narrowed to that scenario. An indicator and nothing else —
that row has learned this lesson once already, when one ⏰ was both a status
badge and a real launch, and reaching for information started a session.
Shift+Enter writes a second line (#234). It used to submit the
half-written message. xterm maps Enter to a bare CR whatever modifiers are
held, so Shift+Enter reached claude byte-identical to Enter and claude
did the only thing a bare CR allows. The app is the terminal here, so it now
makes the binding every real terminal makes — the one claude's own
/terminal-setup writes into iTerm2 and VS Code: ESC + CR. Alt/Option+Enter
and Ctrl+Enter map to the same newline, since neither can mean anything else
and they are what a person tries next. Plain Enter still submits, and zoom
keeps its binding — Cmd+Enter on macOS, Ctrl+Shift+Enter on Windows and
Linux.
Two defects fixed
- The status pill took the keyboard every five seconds (#203) — and did it
precisely while a session sat waiting for an answer, which is the state the
pill exists to report. On macOSshow()ismakeKeyAndOrderFront:, and a
focusable window duly became the key window on every poll tick. The pill is
now built non-focusable, and the count is only emitted when it actually
changes. Typing into a terminal is no longer interrupted. Open in browserdid nothing (#252) — no browser, no navigation, no
feedback. A Tauri v2 webview has nowhere to open atarget="_blank"link, so
the navigation was simply dropped. Five other links were dead the same way:
the pull request row, the GitHub screen, links and images in rendered
markdown, the diff drawer, and a card. All six now go through one gate that
openshttp/httpsin the system browser and refuses everything else. The
capability grants URL-opening scoped to those two schemes and nothing more —
a URL out of a pull request description must never be able to name a file to
run.
Housekeeping, no behaviour attached
- 44 spec and plan documents retired (#253) — the record of a workflow this
project no longer uses. Work is scoped in GitHub issues and decisions are
recorded indocs/adr/; a standalone spec was a third, unmaintained account
of intent. Four code comments that cited one for a measurement now state the
measurement inline. - The first two ADRs (#216) — how connected social accounts will hold
credentials, and who uses them over what. Both are inputs to #205; the
"the app stores no tokens" invariant is narrowed rather than deleted. - Where agents look things up (#202) — the issue tracker, the five canonical
triage labels, and the domain-doc layout, written down so a skill does not
guess and open a pull request againstmain. - The README gains the Features bullet the history screen shipped without.
Known, and tracked
Found while building this, left out on purpose:
- #235 — the history screen reads an unreadable journal as an empty one.
- #236 — no test covers the history screen's boot-level wiring.
- #249 — an unpinned scheduled scenario runs in whichever workspace happened
to be active. The form says so; saying so is not the fix. - #255 — every modifier+key pair other than the three now translated still
collapses. The real answer is negotiating the kitty keyboard protocol.
Still open from v0.2.0: #199 (after /clear, restart and auto-restore
resume the pre-clear conversation), #195 (a non-UTF-8 transcript reports
zero tokens and no name, silently), #194 (a renamed card tile loses the ☑
that says which card it is on).
Install
macOS — download the .dmg for your architecture (aarch64 for
Apple Silicon, x64 for Intel), drag the app to Applications, then
clear the quarantine flag once:
xattr -cr /Applications/cowork-deck.app
Linux — download the .AppImage, chmod +x it and run, or
install the .deb.
The app checks this page for updates on launch and installs them
in place; the xattr step is first-install only.
cowork-deck v0.2.0
Install
macOS — download the .dmg for your architecture (aarch64 for
Apple Silicon, x64 for Intel), drag the app to Applications, then
clear the quarantine flag once:
xattr -cr /Applications/cowork-deck.app
Linux — download the .AppImage, chmod +x it and run, or
install the .deb.
The app checks this page for updates on launch and installs them
in place; the xattr step is first-install only.
cowork-deck v0.1.1
Install
macOS — download the .dmg for your architecture (aarch64 for
Apple Silicon, x64 for Intel), drag the app to Applications, then
clear the quarantine flag once:
xattr -cr /Applications/cowork-deck.app
Linux — download the .AppImage, chmod +x it and run, or
install the .deb.
The app checks this page for updates on launch and installs them
in place; the xattr step is first-install only.
cowork-deck v0.1.0
Install
macOS — download the .dmg for your architecture (aarch64 for
Apple Silicon, x64 for Intel), drag the app to Applications, then
clear the quarantine flag once:
xattr -cr /Applications/cowork-deck.app
Linux — download the .AppImage, chmod +x it and run, or
install the .deb.
The app checks this page for updates on launch and installs them
in place; the xattr step is first-install only.