You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
dot-agent-deck wrap now emits up to three SessionStart events where it emitted one (PROTOCOL_VERSION unchanged)
The wrapper that hosts a Codex pane has always emitted one SessionStart the instant it forks its child, marked session_start_origin: wrapper_fork so a readiness gate can tell "a card should appear" from "this agent can accept input". It now emits one or two more once it has observed the child's interface come up, marked session_start_origin: wrapper_interface_ready when it watched the child take the terminal out of cooked mode and session_start_origin: wrapper_interface_settled when all it saw was the child's output go quiet. A child that goes straight to raw mode emits only the strong one, so its session carries two events; a child behind a launcher — which paints a banner and then computes in silence — emits the weak one first and the strong one seconds later, so its session carries three.
Nothing on the wire moved: metadata is a free-form map by contract, the key already existed, and no field was added, removed or retyped. Both builds report server_version: 7 to dot-agent-deck daemon hello. This is classified as a compatibility break anyway because what changed is the meaning of an existing stream — per this project's rule, a semantic break behind a stable wire is versioned as breaking, which is why this drives a minor bump rather than a patch. Mixed versions behave asymmetrically, and the old-daemon direction is the one to avoid. A new daemon with an old wrapper degrades cleanly to exactly today's behaviour: no interface event ever arrives, so the gate falls back to the agent's native SessionStart or to the 30-second timeout, and nothing else in the daemon reads the new value. An old daemon with a new wrapper does two things with the extra events, and neither is free. First, it releases its readiness gate on the FIRST interface event it sees, which for a launcher-hosted agent is the weak output-settled guess — the very fact this build holds back — so the task is written while the launcher still owns the line discipline and lands in the launcher's line editor rather than in the agent. That is the defect this issue exists to fix, arriving early and pointed the wrong way; it is not the harmless early release an earlier draft of this note claimed. Second, it treats the interface event as a genuine agent session announcing itself, because an old build's only test is "is this the fork marker" — so the delivery binds to the wrapper's session id, and the agent's own native start a moment later reads as a conversation rollover and revokes a delivery target that never moved. The consequence is a prompt that is not re-submitted if its first write went unconfirmed. Upgrade the daemon and the wrapper together, which is the ordinary case; a wrapper is spawned by the daemon that is running, so this combination only arises if you point one build's daemon at another build's wrap deliberately. The extra event does not compound that revocation, and it is worth saying which half is load-bearing. Every event a wrapper session emits — fork, settled and ready alike — carries the same wrapper-derived session id, so on an old build the second interface event only refreshes the high-water mark of the generation the first one bound. Reproduced against v0.37.2 under this build's wrapper: the strong fact arrived 1.26 s after the weak one and the re-submission schedule ran straight through it; what stopped the delivery was the agent's own native start, with a different session id, several seconds later. So the hazard is that a wrapper event binds the generation at all, not that there are now two of them.
On this build every wrapper event is recognised as wrapper provenance rather than as a conversation. They carry the wrapper's own session id rather than the agent's, so none binds a delivery's generation, moves a pane's hook session, or arms a re-submission — while the two interface ones satisfy the readiness gate, and which one satisfied it selects the buffer paid over it. The settled variant is a guess, not an observation: a launcher stalled part-way through its own boot goes quiet exactly like a REPL waiting at its prompt, so for a wrapper-hosted agent it is treated as provisional — the gate holds it for an upgrade window to see whether the raw-mode observation is still coming, and releases on it, with the ordinary buffer, only when that window expires. The window is the 30-second readiness timeout the deck already ships rather than a number of its own, so a settled-only child costs exactly what a signal-less one always cost and nothing more. Three post-readiness buffers now exist where there was one, and which one is paid is the observable half of this change. The strong interface fact on a pane this daemon itself spawned under dot-agent-deck wrap pays 5000 ms, measured against how long a real full-screen Codex goes on discarding keystrokes after it takes the terminal. An agent that declares it emits no pre-prompt signal at all (OpenCode) pays 8000 ms, measured against how long a replacement takes to paint its composer. Everything else keeps the 1000 ms it had — a native SessionStart, the weak fact released by window expiry (or released at once where there is no window — see below), the timeout fallback, and any interface fact this daemon's own launch record cannot vouch for. DOT_AGENT_DECK_DELEGATE_READINESS_BUFFER_MS overrides all three, in both directions. Re-taken across the merge, against v0.37.2, with the line discipline measured at the child. The four combinations were run again on the merged tree, because this entry and #702's both land in the same release and both describe this one delegate path, so the pairing they produce together is what actually ships. A new daemon with a new wrapper held the weak fact for the upgrade window, released on the strong one and paid 5000 ms, putting the task pointer in at 9.0 s with the child in raw mode. A new daemon with an old wrapper and an old daemon with an old wrapper both saw no interface event at all and fell to the 30-second timeout, writing at 30.8 s — identical, which is the clean degradation claimed above. An old daemon with a new wrapper released on the weak fact with the ordinary 1000 ms buffer and wrote at 1.94 s, 2.06 s before the child cleared ICANON/ECHO: the launcher-write described above, confirmed at the child rather than inferred from the gate. A delegate routed and a work-done reached the orchestrator in all four, and a silence report arrived in all four naming something true in each; what differs between them is that report's wording and whether it is submitted, which is #702's entry rather than this one. Note where the two breaking entries pivot differently.#702's says its switch takes effect when the daemon starts on the new build rather than when the binary on disk is replaced. That is true of its report and is not the rule for this entry: wrap is a separate process the daemon execs, so what matters here is the pairing of daemon and wrapper. In the ordinary case they are the same build and the distinction never surfaces — a wrapper is spawned by the daemon that is running — but a role command naming dot-agent-deck wrap … at an explicit path, or a stray DOT_AGENT_DECK_WRAP_BIN, is enough to produce the old-daemon/new-wrapper cell above. The upgrade window is only armed for a pane the deck's own launch record resolves to a wrapper-interface agent, which is the precondition to check before upgrading. That window is selected from the identity the respawn just launched — the role's agent = declaration if it has one, otherwise the command's basename — never from the badge on the arriving event, so that no producer can lengthen a stranger's gate. The consequence is that a role whose command is a hand-written dot-agent-deck wrap --agent codex -- … with noagent = key gets no window at all: the pane is genuinely wrapper-hosted and both interface facts do arrive, but the launch record says nothing, so the gate releases on the weak one exactly as an old daemon does — measured on this build at 1.94 s, into a still-cooked pty. Naming the agent with the agent = key added in the same release (#308) restores the full behaviour: the same config with agent = "codex" held the window, released on the strong fact and wrote at 9.0 s. A bare codex, or any command whose basename the deck already resolves, is unaffected — and so is devbox run codex-big without the key, which is not wrapped at all and keeps the 30-second fallback it always had.
The two facts are also latched separately, which is a second semantic change in the same stream: one wrapper session can now emit wrapper_interface_settled and then wrapper_interface_ready, in that order, where the first build of this feature emitted whichever fired first and nothing afterwards. A consumer that treats the first interface event of a session as final will mis-price a launcher; there is no ordering in which the strong one is followed by the weak one.
The interface origins are deliberately not forwardable through dot-agent-deck hook, which still accepts only the fork value from an incoming payload. Boot provenance is a producer confessing that its child is not up yet, which costs it privilege and is safe to believe from anyone; interface readiness is a producer claiming that its child is up, which buys privilege. That narrowing is not a trust boundary on its own, though — the daemon's hook socket also accepts a raw event whose metadata is free-form — so the privilege is gated where it is used: the 5000 ms interface buffer is applied only for an agent this daemon itself spawned under dot-agent-deck wrap, and an explicitly-set DOT_AGENT_DECK_DELEGATE_READINESS_BUFFER_MS overrides it. That privilege is deliberately small — neither interface value can suppress a buffer, and refusing one costs the LONGER wait rather than the shorter, so the most a forged one can do is release a gate that an unmarked SessionStart already released before this change.
A project can now declare which orchestration a run opens when none is named, and both paths that decide it finally agree (PROTOCOL_VERSION unchanged)
When a project defines more than one [[orchestrations]] block, two things start one without you naming it: dispatch --orchestration= with an empty value, and a scheduled task whose working directory defines orchestrations. Which block they opened was decided by the file's ORDER — and the two disagreed about the rule. The dispatch form took the first block that had roles; the scheduler took the first block, full stop, and fell through to a plain single-agent card if that one happened to be a roleless placeholder. So the same repo could answer the same question two ways, and in the roleless case a scheduled task quietly opened no orchestration at all while dispatch --list-targets was still offering one.
Both now resolve identically, and the answer can be stated rather than inferred: put default = true on the block you want and it wins wherever it sits in the file. Exactly one block may carry it and that block must have roles — dot-agent-deck validate rejects both mistakes. dispatch --list-targets marks the answer with [default]. When you have declared nothing and there is more than one candidate, the deck now tells you which one it took and what else was there, in the dispatch's own reply, in the listing, in validate, and in the daemon log for a scheduled run that nobody is watching. What to expect if you upgrade. With no default = true anywhere the rule is unchanged — the first orchestration with roles wins, exactly as before — so a single-orchestration project sees nothing at all. Two configurations do change behaviour on their own, without you editing anything. A repo whose FIRST [[orchestrations]] block has no roles: a scheduled task rooted there used to open a single-agent card running the task's command, and now opens the first block that does have roles, as a full orchestration. And any repo with several orchestrations where a bare dispatch and a scheduled task were relying on the two old rules landing differently — they no longer can. This is classified as a compatibility break even though nothing on the wire moved.dispatch --list-targets's reply gained one optional field, which an older peer ignores and a newer one defaults to false, so no PROTOCOL_VERSION bump is needed and old and new builds still talk — measured against v0.37.2, where a deck running this build attaches to the older daemon, opens an orchestration tab, delegates, and receives work-done and live status exactly as before. What breaks is a meaning shared across processes: .dot-agent-deck.toml is read independently by the daemon, the TUI and the CLI, and an older build reading a file that uses the new keys reads it as something else. default = true is an unknown key to it, so it silently keeps picking by position. extends (see the accompanying feature note) is worse, and the symptom points at the wrong thing: an older build ignores it too, so an orchestration that inherits its workflow is read as carrying only the roles it restated — v0.37.2's validate reports orchestration must have at least 2 roles and must have exactly one role with start = true about a variant that visibly inherits three. Upgrade the daemon before adopting either key, and if you run a TUI and a daemon from different builds, upgrade both.
See Which orchestration a scheduled task opens.
Closing a dispatched unit that still holds uncommitted work now says so — and where (PROTOCOL_VERSION 7 → 8)
Closing a dispatched unit whose copy of the repo is clean deletes that copy. Closing one that still has uncommitted work keeps it, so the work is recoverable — but until now nothing said so. The keep existed only as a line in the daemon log, so the good outcome and the ordinary one looked identical from the deck: the card vanished either way, and a directory quietly stayed on disk holding work whose location you had no way to learn short of already knowing that dot-agent-deck worktree list exists.
You are now told twice, and the two messages answer different questions. Before you commit to anything, the close confirmation names the directory the work would be kept in, above the Cancel/Close options — so the path arrives while cancelling and going to commit is still an option, rather than after the pane is gone. Then, once the unit has actually stopped, the status line reports what really happened.
Both halves are needed because only the second can be certain. The unit is still running while the dialog is open: it can commit its work in that window, or dirty a copy that was clean when you pressed the key. So the dialog is a forecast, useful because it is early, and the deck checks again after the unit is stopped — when nothing can still be writing — and reports that. A copy that turned out clean is removed and nothing is said at all, because a warning on every close is a warning nobody reads.
The check is a git status --porcelain run by the daemon, which is the only process that knows both the removal policy and, under a remote deck, the filesystem the worktree is on. The dialog's copy of it is time-boxed, because it now sits on a keystroke: when it does not answer in time the dialog still reports the path, under wording that does not claim more than was measured. This is classified as a compatibility break because the wire shape moved, in two places. The dialog asks the daemon a new question (dispatch-worktree-close-preview), and the daemon reports the outcome back as a new event (worktree_kept) — a new request variant and a new broadcast payload variant. PROTOCOL_VERSION goes 7 → 8, and dot-agent-deck connect will refuse a laptop and a remote host running different sides of this change until both are upgraded; the event variant is why that matters, since an older peer fails the whole frame rather than skipping one message. Locally the skew is self-healing and nothing else regresses: measured against a v0.38.0 daemon, a deck built from this change attaches to it, renders its orchestration, delegates, receives work-done, and takes hook events exactly as before — and the one request the older daemon cannot understand is refused cleanly, leaving the dialog to render as it always did rather than failing.
See Finishing up.
Added
Ctrl+Z zooms the focused agent pane
The Dashboard and an orchestration tab each spend about a third of their width on the card sidebar, and that is the right trade while you are supervising — the sidebar is how you see which of seven agents is working, and it is most of why the orchestration view is worth looking at. It is the wrong trade the moment you stop supervising and start working in one agent: reading a long diff, following a plan, going back and forth with the orchestrator on a laptop screen. Until now there was no way to say so.
Press Ctrl+Z in command mode on the Dashboard or an orchestration tab and the focused agent's pane takes the whole frame. The card sidebar and the other panes are not drawn; the focused pane keeps its own border, which is what carries the title, the focus weight and the agent's status colour. Press Ctrl+Z again and the previous view returns exactly as it was — including a Ctrl+L split you had toggled, which is restored rather than reset to the default. The full gesture is Ctrl+D then Ctrl+Z.
It works on both tab kinds that have a card sidebar, because they are the same shape: a card sidebar beside a stack of agent panes, at 33/67 on the Dashboard and 34/66 (or a Ctrl+L-narrowed 25/75) on an orchestration tab. Each tab holds its own zoom, so zooming the Dashboard never touches an orchestration tab you were supervising. A Mode tab is two pane regions rather than sidebar-plus-panes, so it has no sidebar to reclaim and the chord reaches the pane as ordinary input.
That holds whatever the deck's Ctrl+T pane layout says. Zoom shows the focused agent and only the focused agent — a tiled deck does not zoom into three equally taller panes, which would be "make everything wider" rather than "get everything else out of the way". Your Ctrl+T choice is not touched while you are zoomed, only overridden for as long as the zoom lasts, so unzooming a tiled deck puts every pane back exactly where it was.
While zoomed, the border title reads orchestrator [Z] — or whichever role you are on. The marker is not decoration. The one real hazard of a zoom is forgetting you are in one and concluding your agents have disappeared, or watching one agent while another sits blocked behind the hidden sidebar; tmux marks a zoomed window with a Z in its status line for exactly this reason, and here the border is the only chrome zoom keeps, so the marker rides on it. Every agent keeps running while you are zoomed. Nothing is stopped, delegation still routes, work-done and status hooks still arrive, and an idle worker is still detected — zoom changes what is drawn and nothing else. What you genuinely give up is the sidebar's live view of everyone else, which is the trade the feature exists to let you make on purpose.
Zoom follows focus: a 1–9 role jump while zoomed stays zoomed, now on the new agent, because that jump is a deliberate "go work with that one". It is per-tab — each orchestration tab remembers its own, and a tab you open later starts unzoomed — which is the deliberate opposite of the Ctrl+L split, one setting for the whole deck. A sidebar width is a standing reading preference; zoom says "I am working in this agent right now", and a tab you never zoomed should not lose its sidebar behind your back. It is also ephemeral: nothing about it is written to the saved session, so a detach and reattach always returns the full supervisory view.
The binding is a plain z rather than Ctrl+Z, and deliberately so: Ctrl+Z is job control for whatever runs inside a pane, and the deck keeps forwarding it as 0x1a rather than taking a chord an agent's shell has a real claim on. The cost is one extra keystroke to reach command mode first; the cost of the alternative would have been suspending processes everywhere. Because the key is an ordinary letter it is scoped tightly — claimed only on an orchestration tab in command mode, so while you are typing in a pane, into the filter, into a rename or into the new-pane form, z is just the letter z and reaches whatever you are typing at.
Zooming and unzooming resizes the agent's PTY, so the agent reflows to the new width and back. Rebind it as toggle_zoom in [global] like any other action. There is no flag to enable this — it ships visible by default.
See Zooming the focused pane. Ctrl+Z costs you no job control, which is why it could be the binding at all. The chord is claimed only in command mode, so while you are typing at an agent it is still 0x1a — the terminal's suspend character — and the deck goes on forwarding it to the pane exactly as before. Suspending and resuming a program inside an agent's shell works the way it always has. This is the same narrowing that lets Ctrl+L stay readline's clear-screen and Ctrl+W stay word-delete while you type, and it means the deck's zoom key does not have to be an odd one out among Ctrl+D / Ctrl+N / Ctrl+W / Ctrl+T / Ctrl+L.
remote doctor — diagnose broken reverse tunnels
The reverse-tunnel recipe for reaching networks only your laptop can see (#97) works, but every way it can fail was opaque — several were found only by building the setup and hitting them. dot-agent-deck remote doctor <name> is a new read-only command that runs a fixed set of checks against a configured remote and reports each as PASS, WARN, FAIL or UNKNOWN with a specific fix, so the question "is this remote actually set up the way I think it is?" has an answer other than reading man ssh_config and guessing.
It distinguishes the failure modes that client-side error text cannot: AllowTcpForwarding no on the remote versus a port collision on the same port — both previously produced the identical remote port forwarding failed error — are now told apart by reading the remote's resolved sshd -T config. A DynamicForward pointed the wrong way (listener on the laptop instead of the remote, an easy mistake the forward direction itself won't warn about) and a forward configured without ExitOnForwardFailure (so a failed forward comes up silently) are both caught from ssh -G's resolved configuration. A forward that looks correctly configured but never actually bound on the remote is caught by probing the remote's own loopback. For the one case where accepting-but-unproven still matters — a listener on a concrete reverse forward — the doctor reports UNKNOWN rather than guessing PASS or FAIL: it can't prove that listener is the user's tunnel rather than something else on the same port, and says so instead of being confidently wrong in either direction. Exit codes follow the same distinction: 0 for a clear PASS/WARN, 1 for FAIL, 2 when a check couldn't be completed (most commonly sshd -T needing root) — "broken" and "could not check" are kept apart rather than collapsed into one non-zero exit.
Also fixed as part of the same investigation: connect previously misreported a broken reverse forward as HostUnreachable, which pointed users at the wrong half of their setup. A forward failure is now reported as ForwardFailed, matching what the doctor itself checks for.
Diagnosing a remote necessarily means connecting to it, so the probes are hardened for the case where the remote is the thing you're already suspicious of: observation sessions (used for every check the doctor runs) no longer delegate the local ssh-agent, X11, or GSSAPI credentials to the endpoint being diagnosed, closing a window where a compromised remote could use — though not extract — the laptop's forwarded credentials for the life of the probe.
No configuration is required and there is no flag to enable this — remote doctor is read-only from the start, so it ships visible by default rather than behind the experimental flag.
One orchestration, several providers: extends shares a team's workflow instead of copying it
A multi-role orchestration is mostly prose. Six roles, each with a description and standing instructions, and an orchestrator whose prompt_template carries the whole workflow — a hundred and forty lines, seventy of them one string. If you want the same team on a different provider, because a contributor only has credentials for one or because one account's credits ran out mid-task, you used to have to copy all of it. Two such copies drift; three drift faster. extends removes the copy. Point one [[orchestrations]] block at another by name and it inherits every role; its own [[orchestrations.roles]] entries then override them, matched by role name, and anything they leave out keeps the parent's value. A provider variant is then six command lines and nothing else:
[[orchestrations]]
name = "GPT"extends = "mixed"
[[orchestrations.roles]]
name = "coder"command = "devbox run agent-coder-oc"
The parent's role ORDER is kept whatever order you write the overrides in, because a role's position is what the tab layout and delegation key panes on — so a variant always opens with the same columns as the team it inherits from. A role name the parent does not have is added rather than rejected, which is how a variant grows a seventh role; it has to bring its own command, since there is nothing to inherit one from. Chains work, and a cycle or a parent that does not exist stops the config loading with a message naming both sides rather than leaving you a half-built team to diagnose.
This repo now uses it on itself. Its .dot-agent-deck.toml defines three orchestrations — mixed, anthropic and GPT — running the identical six roles with the identical prompts, differing only in which agent each role launches, with mixed declared the default. Switching a whole team to another provider is one flag: --orchestration 'anthropic'.
See Sharing a workflow with extends.
Fixed
A Codex worker gets its delegated task in under a second instead of after thirty, and an OpenCode one stops waiting for a signal it never sends
Delegating to a clear = true Codex worker took about 31 seconds to reach the worker's pane, every single time. The deck holds a task until the replacement agent says its session has started — but Codex announces its session when its first turn begins, which is to say only once a task has already arrived. The signal the deck waited for was caused by the very task it was withholding, so the wait could never be satisfied and every delegation paid the full 30-second fallback before writing blind. Five production samples measured 31.2, 31.2, 31.7, 31.7 and 32.3 seconds; the constancy is the tell.
The delay was not the whole cost. Half a minute of a worker sitting apparently idle is long enough to make you re-run the delegation, and the re-run supersedes a worker that was about to come good — after which the deck's own "this delegation may not have been delivered" watch fires against the superseded generation and reports a perfectly healthy delegation as a failure. The orchestrator was being told its work had failed when it had not.
A Codex worker runs under the deck's own wrapper, which hosts its terminal, so the deck no longer waits to be told: it watches the interface come up and delivers as soon as it has. The same delegation now lands in about a second instead of half a minute.
Two things count as having seen the interface, and they are not worth the same. The first is the child taking the terminal out of cooked mode — a program reading raw keystrokes is, by construction, one that consumes input rather than echoing it. That one is an observation, and it is what the deck releases on. It is an observation of the agent owning the terminal, though, not of the agent being ready for a task: a full-screen interface switches the terminal over at the very start of its own startup — measured at a tenth of a second — and keeps dropping keystrokes until it has finished drawing itself. So the deck releases on it and then still waits, for five seconds, a figure measured against how long a real Codex goes on ignoring input rather than picked. The second is output settling: the child painted something and then stopped for three quarters of a second. That one is a guess — a launcher stalled part-way through its own startup goes quiet exactly like an interface waiting at its prompt. Neither elapsed time nor a first byte counts at all, because a launcher's Starting… banner is not an interface.
The two do not arrive in order of strength, and that turns out to decide the shape of the fix. If you launch Codex through a wrapper like devbox run, the launcher prints a line almost immediately and then spends two to four seconds computing its environment in silence before Codex is started at all — so the guess arrives first, every time, while the pane still holds the launcher. Delivering on it, even with the readiness buffer applied, put the task into the launcher's own line editor: measured across production runs, the pointer landed 1.85 seconds in and was still sitting unsubmitted in Codex's composer afterwards, with no turn ever started and nothing anywhere to say so. So the deck does not deliver on silence straight away. It goes on waiting for the real interface to come up behind the quiet launcher, delivers the instant it does, and falls back to delivering on the silence — with the buffer — only if it never does. A healthy Codex worker reaches the strong signal in well under a second and never pays for the wait at all. How long the deck is willing to wait is the half-minute it already had for this, reused rather than a new number of its own: a first run in a fresh project, where devbox installs packages before it does anything else, took sixteen seconds to reach the interface, and a shorter window gave up before then and wrote the task into the launcher — where it sat in the terminal's input queue until Codex started, was drained as one run of ordinary typing, and ended up in the composer unsent with nothing anywhere to say so. So the worst case is a wrapped agent that never presents a full-screen interface: it waits the window out, delivers on the silence with the one-second buffer, and reaches its prompt at about thirty-one seconds — exactly what a Codex delegate cost before this change. Nothing gets slower than it already was, and the cold launcher stops losing the task.
OpenCode had the same symptom from the opposite cause: it emits nothing at all before its first prompt, so there was never anything to wait for. It now says so — an agent can declare that it has no pre-prompt readiness signal, and the deck skips straight to a bounded readiness buffer instead of spending 30 seconds discovering the same thing again. That applies to a scheduled task's first prompt as well as to a delegation. An agent the deck does not recognise keeps waiting exactly as before: "we do not know what this is" is not evidence that skipping the wait is safe.
That skip needed a number of its own, and finding out why is worth stating plainly, because the first attempt at it was wrong. Half a minute of waiting for an event that cannot arrive is dead time by every measure except one: it happened to give the replacement half a minute to boot. Removing it left the one-second buffer as the only thing between a restarted OpenCode and the task written into it, and one second is not enough — the task is swallowed outright, with no text in the input box and nothing on screen to say a task was ever sent. So the declared-no-signal path now holds the task for eight seconds, measured the same way as Codex's five: replay the deck's own write against a real OpenCode on a terminal and read the screen back. Across 176 runs the outcome turns on a single moment — the instant OpenCode paints its Ask anything... prompt. Before it the task is gone; after it, every run delivered. That moment is two and a half seconds in on an idle machine and four and a half on a busy one, so eight seconds is a little under twice the busy requirement, and the same delegation that used to take about thirty seconds now takes under ten.
Treat that one as a mitigation rather than a guarantee, more so than Codex's: on a machine loaded far past ordinary — sixteen cores with sixty-four busy processes on them — OpenCode needed twelve seconds and eight would have lost the task. DOT_AGENT_DECK_DELEGATE_READINESS_BUFFER_MS raises it. Unlike Codex, there is nothing here for the deck to watch instead: OpenCode emits no signal at all before its first task, so an interval is the ceiling for this agent until that changes.
The one-second post-delivery readiness buffer is unchanged for everyone else — Claude Code, a wrapped worker that never presents a full-screen interface, and the timeout fallback — and deliberately so. It covers the gap between "a session exists" and "the input box treats Enter as submit" — real for Claude Code, which announces itself early in its boot. What is new is that a worker the deck launched itself and then watched take the terminal out of line-editing mode waits five seconds rather than one, because that is a different gap being measured: not a session announcement running ahead of a boot, but a full-screen interface's own startup, which goes on discarding keystrokes for as long as it takes to finish drawing. Five seconds clears it with headroom on a busy machine as well as an idle one. Treat it as a mitigation rather than a guarantee — it is another program's startup time, and it stretches with how loaded your machine is — and DOT_AGENT_DECK_DELEGATE_READINESS_BUFFER_MS overrides it if yours needs longer, or shorter.
See Orchestration for what clear = true costs per agent and when to reach for that variable.
Demo reel for issue #243: https://youtu.be/JfhLX7lqyJE. Watch before merging. It shows clear = true delegation reaching three real interactive workers — Codex, Claude Code, and OpenCode — each receiving its task pointer and visibly doing the delegated work.
Hooks no longer point at a build directory that is about to disappear
Installing hooks wrote the path of whatever binary happened to be doing the installing into ~/.claude/settings.json, ~/.codex/hooks.json, Devin's config, and the OpenCode plugin. If that binary was a local build — and it is whenever you run the deck from a checkout — the path written was a target/debug or target/release path: the least durable location on the machine. It is removed by cargo clean and it vanishes with its worktree. Every hook then failed with /bin/sh: 1: /…/target/release/dot-agent-deck: not found, naming a path you never typed. Nothing announced it, because the install also happens silently on every dashboard launch, so the breakage arrived days later with no obvious cause. Found in the field with all four configurations pointing at the same deleted directory at once.
The deck now resolves a durable path before writing one. An installed binary keeps writing its own path, as before. A build in a checkout writes your installed deck instead — ~/.local/bin/dot-agent-deck if it is there, otherwise wherever dot-agent-deck sits on your PATH — and if neither exists, hooks install fails with a message saying what to install and writes nothing at all. A silent startup install refuses the same way, to the log rather than your terminal. The value written is always an absolute path to a file that exists; it is never a bare dot-agent-deck, which would leave resolution to whatever PATH the agent's shell turned out to have (#536).
Configurations already broken repair themselves. On the next launch, a deck-owned hook whose binary is confirmed missing is rewritten to the durable path, and the repair is logged rather than done silently. For Claude Code and OpenCode, a path that still works is left alone even when it differs from what the deck would write — your own wrapper, or a second checkout you are deliberately using, is not something a startup should quietly repoint. Codex and Devin do not yet: they re-pin their own rules to the resolved durable path on each launch, which #730 tracks. If you launch the deck from more than one install, that is not a one-time correction — two checkouts, or an installed deck and a checkout, resolve different durable paths, so the Codex and Devin pins flap between them and follow whichever you launched last. A hook of your own that merely mentions dot-agent-deck in its command is never touched, on any of the four. The same repair now covers the OpenCode plugin's BINARY_PATH, which had the opposite problem: it was rewritten on every launch, so a working pin was replaced by whichever build started the dashboard.
New troubleshooting section: A hook fails with not found.
The generated delegate and work-done commands are runnable from any directory, and on any shell the text tells the agent to use
When the deck cannot verify that its own bare name will resolve for the agent that has to run it — it is not on $PATH, a different build shadows it, or its file name carries characters a shell would reinterpret — it writes its own path into the generated delegate and work-done command examples instead. The 0.36.1 entry that introduced this said the result was "always runnable, never a guess". Two gaps meant that was a promise rather than a property, and both are now closed. The path was not guaranteed to be absolute (#560). Nothing made it one — it was whatever the operating system reported, and only Linux is guaranteed to report an absolute path there. On macOS the deck is told the path it was invoked as, so launching it as ./target/release/dot-agent-deck put exactly that relative path into the worker's task file. The worker then resolved it against its own working directory — an orchestration directory or a git worktree, never the deck's launch directory — and the command failed. The failure was silent: completion signals are fire-and-forget, so nothing surfaced an error and the handoff simply never arrived. The path is now made absolute before it is written, so it means the same thing in every directory. Symlinks are deliberately left alone, since which file a symlinked launch should name is a question this change does not reopen. The path was quoted correctly and spelled unrunnably (#561). Windows writes its paths with backslash separators, and a POSIX shell decides whether a command is a path or a name to look up purely on whether it contains a forward slash. A Windows path was quoted flawlessly and then looked up on $PATH anyway, so it failed with command not found — not only in cmd.exe and PowerShell, which the original report anticipated, but in git-bash and WSL too, which it did not. The deck now writes Windows paths with forward-slash separators, which is the form those shells accept, needs no quoting at all for an ordinary C:/Users/... install path, and still quotes a path containing a space. The generated blocks are fenced as bash and tell the worker to run the command via Bash, so that is the shell the emitted form targets on every platform; cmd.exe and PowerShell remain out of scope, and a path that has no Bash-compatible spelling at all is declined in favour of the plain name rather than written out in a form that could be misread.
Neither gap could occur on Linux, which is why both survived: one is impossible there by construction and the other requires a Windows path. Both branches are now covered by tests that inject the failing input directly rather than depending on the machine the tests run on.
A remote host can no longer drive the terminal that reports why remote add failed dot-agent-deck remote add is the first contact with a host you have not yet decided to trust — it runs before any registry entry exists, and its whole job is to find out whether that host is reachable at all. When it failed, the message it printed quoted the host's own output verbatim, and so did remote upgrade. Anything the far end wrote reached your terminal as live bytes: a CSI sequence could clear the screen or move the cursor back over lines the deck had already printed, an OSC sequence could retitle the window, and a bidi override could visually reorder the very line you were reading to work out what had gone wrong. Answering the connection was enough — no credentials, no successful login, no install.
Text the remote wrote is now scrubbed where it enters the error rather than where it is printed, so all four remote subcommands that print one of these errors are covered by construction and a fifth cannot be added past it. That covers ssh's own stderr on an unreachable, unauthenticated or otherwise failed connection, and — reached first, by a host that connects perfectly well — the arch probe's stderr, the arch probe's output when it does not name a supported platform, the version the remote binary reports back, the download step's stderr, and the hook install's stderr. Everything a terminal acts on rather than shows is dropped: C0 and C1 controls, ESC, NUL, DEL, and the Unicode bidirectional formatting and override codepoints. The printable payload of a sequence survives as ordinary text, the same policy the deck already applies to agent-supplied display names, and newlines are deliberately kept — these messages put Details: on its own line, and multi-line ssh stderr is the most useful part of the diagnostic. A remote whose output contains nothing unusual, which is every real one, prints exactly as it did before.
Only the error path changed. remote add and remote upgrade still relay the remote hooks install command's own successful stdout unchanged, the way ssh host cmd does. Classification is unchanged too: the ssh error matcher and the platform detector still read the raw bytes, so nothing about which error you get depends on what was stripped.
This closes an inconsistency inside one command family rather than a missing capability — remote doctor has escaped its remote-derived text since it shipped, and remote add and remote upgrade stood unprotected next to it.
Installing hooks into a shared agent config directory can no longer be redirected onto another file
When the deck installs its hooks it rewrites a config file that a third-party tool owns — Codex's hooks.json and config.toml, Devin's config.json, OpenCode's plugin file. It has always published those safely in the sense that mattered most: it writes a scratch file next to the destination and renames it into place, so a crash or a concurrent reader never sees a half-written config. What that scratch file was called, and how it was opened, turned out to matter just as much.
The name was .<config file name>.tmp.<pid>, which is to say it was fully derivable by anyone who could see the destination and read a process id — both of which are ordinary, unprivileged things to know. It was then opened in a way that follows a symbolic link. So anyone who could add an entry to that config directory, before the deck got there, could leave a link at that name pointing at some other file they were able to write, and the deck would truncate that file, reset its permissions and fill it with the config it had meant to publish. The rename afterwards moved the link rather than the content, so the config file the deck was asked to update did not even end up holding the result — nothing was left at the expected place to show what had happened.
This matters only where the config directory is not yours alone. A private ~/.codex on a single-user machine was never exposed. A shared, group-writable, or otherwise multi-tenant home — a build agent, a shared workstation, a container image whose home directory is world-writable — was.
The scratch file is now created exclusively: the deck asks the operating system to create a genuinely new file and to fail if anything is already at that path, which a symbolic link always counts as, whether or not it points at something real. That alone makes the redirection impossible even against a name that was guessed outright. On top of it the name now carries an unpredictable component, so a name cannot be reserved ahead of time in the first place, and a clash is answered by drawing another name rather than by deleting whatever holds it or by abandoning the write — a squatter costs the install a retry, not the hook installation. On Unix the scratch file is also created owner-only rather than at whatever the umask allows, so it is never briefly readable by others in the window before its contents are written; the permissions the published file ends up with are unchanged, still copied from the config file being replaced.
One related fix comes with it: a failure while writing or flushing the scratch file used to leave that file behind next to the config. It is now cleaned up on every failing path, as it already was when only the rename failed.
Miscellaneous
An opt-in desktop GUI preview, built from the same checkout
The TUI has always been the only way to watch a deck. desktop/ adds a second local client — a Tauri 2 window with a React frontend — that talks to the same daemon over the same per-user IPC socket, using the same Hello handshake. It is a developer preview for PRD #176: it is not built by the default release, it ships in no artifact, and it replaces nothing. You build it yourself or you never see it.
Two ways to run it. pnpm dev serves the frontend in an ordinary browser against a fixture: no daemon, no agent process, no LLM call, and ?state=disconnected|error|empty to inspect the failure states without having to cause them. pnpm tauri dev opens the real window, which lists daemon-owned agents, attaches xterm.js to each PTY, and forwards input and resize back. The live path deliberately refuses to attach across a build or protocol mismatch, and it never recycles a daemon that might own agents you care about — starting one is always an explicit, confirmed action, never a side effect of opening the window.
The daemon stays the single source of truth. The desktop opens no HTTP API and no TCP listener; it verifies the socket's ownership and permissions, then bridges typed snapshots and bounded terminal chunks through Tauri IPC under a restrictive CSP and a minimal capability set. Terminal input and launch commands are capped at 64 KiB and dimensions constrained to 1..=4096. None of that makes it a sandbox — every live terminal is the underlying agent CLI with the daemon user's full permissions, and typing into one can authorize real work.
Where the daemon does not expose structured data yet, the UI says so instead of guessing: model, cost, token, branch and lease all render as unavailable in live mode, because the current snapshot carries agent type but no reliable model identifier. Live workflow launch is macOS and Linux only — generated profile commands use POSIX shell quoting, so both the webview and the Rust bridge refuse it on Windows rather than hand quoted strings to cmd.exe. Pi cannot be the workflow coordinator here; its native seed path has no delivery acknowledgement, so the bridge rejects that launch before spawning any role. devbox shell now provides Node.js 24.12.0 and pnpm 10.34.5 alongside the Rust toolchain, so the desktop preview needs no hand-installed JavaScript toolchain. Still pending from the PRD: the standalone protocol-crate extraction, the orchestration graph and its delegate/work-done/dispatch daemon events, OS-native notifications, packaging and signing, and the M1.3 multi-pane throughput qualification.
See docs/develop/desktop-gui.md.
cross is installed at a pinned version in both workflows that use it
The aarch64 release leg and the pre-tag check that mirrors it both installed the cross-compiler driver with cargo install cross --locked. --locked is easy to read as a version pin and is not one: it fixes the dependency graph of whichever version resolves, while the version itself is still whatever crates.io calls newest at the moment the job runs. Both workflows now install cross at an explicit --version 0.2.5.
The version is not an arbitrary "newest today" choice. release.yml carries a comment recording a measurement — that CROSS_BUILD_ENV_PASSTHROUGH forwards the released version into the aarch64 build container, so the published ARM64 binary reports the version actually being released — and that measurement was taken on cross 0.2.5. An unpinned install is precisely what let a comment like that quietly stop describing the binary in use. Pinning it makes the two agree by construction, and moves a change in cross's passthrough semantics from something that arrives silently to something that arrives with a visible bump.
Both workflows moved together on purpose. aarch64-crossbuild-check.yml exists to predict the release leg before a tag is cut, and says so at the top by listing what it holds identical; the cross version is now named as part of that list, since pinning one side alone would have left the mirror asserting a sameness that no longer held.
The Nix job's third-party actions are pinned to commits instead of movable tags
The nix job in CI installed Nix and its store cache through two DeterminateSystems actions referenced by major tag — nix-installer-action@v22 and magic-nix-cache-action@v14. A major tag is a mutable ref that its owner can repoint at any commit, so what a reviewer read and what CI later executed were only guaranteed to match until someone upstream moved the tag. Both are now referenced by full commit SHA with the tag kept in a trailing comment, matching how the generated workflows in this repo already pin theirs — readable at a glance, bumpable by Renovate, and immutable in between.
Each SHA was resolved from the upstream repository twice by independent routes, and both tags turned out to be lightweight tags pointing straight at a commit rather than annotated tag objects — worth confirming rather than assuming, because pinning an annotated tag's own SHA produces a ref that looks pinned and is not the commit Actions will check out. What this deliberately does not fix: both actions download and execute a native binary at runtime — the installer fetches nix-installer, and the cache action fetches magic-nix-cache from a stable channel URL. A commit pin fixes the fetcher, not the fetched, so that channel still moves. Both actions expose source-tag / source-revision / source-url inputs that could pin the payload as well; choosing values for those needs its own upstream verification and a measured run, so it stays a separate decision rather than riding along here. The job's existing containment is unchanged: contents: read and pull-requests: read only, with determinate: false, use-flakehub: false and both diagnostic endpoints disabled, so no hosted service and no secret are involved either way.
The e2e temp-dir reaper's deletion floor is derived from the longest lifetime cap in the tree, and a build gate keeps the two in step cargo xtask clean-e2e-tmp never reaps a root whose owning test process is still alive, but a dead owner is not proof the tree is unreferenced: the deck's daemon setsids out of the test's process group and can keep writing there for as long as its own DOT_AGENT_DECK_TEST_MAX_LIFETIME_SECS cap. So a dead-owner root is held for a floor first, and that floor was 600 seconds, documented in two places as "2x the 300-second orphan cap".
It had not been 2x for a while. orchestration_dispatch_002 pins 900 so its daemon outlives the test's own 300-second work budget — without which the failure dump reports NO PANE — never spawned at all for roles the same dump renders alive — and a TuiDeck builder's with_env is applied after the harness env_clear, so that value reaches the child verbatim. The ambient clamp added alongside it never sees such a pin and was never meant to. Between 600 and 900 seconds the derivation therefore no longer held, and --apply could hand remove_dir_all a directory a live, still-entitled daemon was writing under. Reaching it needed the test process killed and its daemon alive past t+600s and--apply, which is not the default — nothing was on fire — but it is a deletion tool whose safety argument had gone 300 seconds short, so it is closed deliberately rather than left implicit.
Two changes, and the second is the one that lasts. The floor is now MAX_PINNED_ORPHAN_CAP_SECS * 2 rather than a literal, so the derivation is the code instead of a claim about it. And linkage-check rule 11 fails the build when any DOT_AGENT_DECK_TEST_MAX_LIFETIME_SECS pin under tests/ exceeds that constant — the next cap raise is now a red build naming the number to move, not a silent re-opening of the same window. The rule reads two shapes off comment-stripped source: the variable adjacent to a literal value (including the multi-line .env( spelling), and a const …MAX_LIFETIME…_SECS binding, which is how WRAP_TEST_MAX_LIFETIME_SECS = "120" is covered even though the nine wrap_io.rs sites pass it by name. A cap computed at run time is out of reach of any text scan; like rule 10 this is the belt rather than the braces, and it covers every pin that exists today so that the next one written is the case it is for. It deliberately has no per-line opt-out, unlike rules 8 and 10: those guard a local choice a reviewer can wave through at the site, while this one guards a pair of numbers that must move together, so the only correct response to a longer cap is to raise the constant and let the floor follow.
The cost is real and lands on the safe side: a killed run's roots are now held 30 minutes instead of 10 before --apply can take them. That is small against the case the ownership rule was filed for in the first place — 280 roots totalling 6.2 GB whose youngest was 4h09m — and against the usual reason to reach for the tool, which is a tmpfs filling up over hours rather than seconds after a Ctrl-C. Nothing else about the reaper moved: a live owner is still never reaped at any age, --older-than still decides only the roots with no usable PID, and --ignore-liveness is still the one operator-driven escape.
The pin-lockstep guard reads flow-style YAML pins, the way Renovate does scripts/check-pin-lockstep.sh compares the Rust toolchain and cargo-nextest versions pinned in devbox.json against the ones pinned in .github/workflows/, so cargo test-fast in a devbox shell and cargo nextest run in CI stay the same claim. Its toolchain scanner found candidate sites with grep -nE '^[[:space:]]*toolchain:' — a line-start anchor, which only ever matched block-style YAML. renovate.json's customManager is unanchored, so with: { toolchain: 1.98.0 } was a pin Renovate tracked and bumped and this guard could not see at all. Given one block-style site that agreed and one flow-style site a whole minor release away, the check printed ok and exited 0; that is now a reported drift. The sibling cargo-nextest scanner already matched its token anywhere on a non-comment line, but its value extraction ran to the closing brace, so {tool: cargo-nextest@0.9.143} came back as 0.9.143} and was reported as an unreadable pin rather than compared. Both scanners now share one line finder and one value extractor.
A flow-style pin is deliberately accepted and compared rather than reported as unreadable. The script's stated principle is that a pin it reads but Renovate does not — or the reverse — is a lockstep between the wrong two things; flow style is tracked, so it can drift, and comparing pins that can drift is the entire job. Reporting it instead would have been a false alarm claiming Renovate cannot read something it reads perfectly well.
Un-anchoring naively would have made things worse, which is why this was not a one-line regex swap. ci.yml's own header comment says the customManagers "match on toolchain: and tool: cargo-nextest@ anywhere under .github/workflows"; an unanchored match reads that line as a site and reports the repository's documentation of this very check as an unreadable pin. Full-line comments are therefore excluded — Renovate ignores that line too, for its own reason, since no bare X.Y.Z follows the token there. The two behaviours added alongside the guard are unchanged: a quoted-but-valid value still fails, in flow style as well as block style, because renovate.json matches a bare X.Y.Z and quoting one silently ends the tracking; and a $-expansion is still skipped, which matters more now than it did, since windows-cross-check's echo "toolchain: $WINDOWS_CROSS_CHECK_TOOLCHAIN" is a mid-line token the anchored scanner could never reach.
Four tests in xtask/linkage-check/src/pin_lockstep.rs cover the new cases, all four verified to fail against the pre-fix script. Output on the repository as it stands is byte-identical to before: seven toolchain sites, three cargo-nextest sites.