Kitty-native durable terminal workspaces, agent attention, and scoped remote credentials.
zka turns a complete Kitty workspace—OS windows, tabs, splits, titles, working
directories, and focus—into one durable unit. Each pane runs in its own hidden
zmx session, so the shell, editor, server,
or coding agent inside it keeps running when the view goes away.
When Codex or Claude Code needs input or finishes work, zka collects the exact panes that need you and takes you straight back to them. When a remote workspace needs your local SSH agent or OpenPGP key, an explicit credential-bundle claim projects stable endpoints into the origin workspace while keeping private keys and provider socket paths on the provider.
Switch, attach, and detach complete workspaces. Then work from one live inbox instead of hunting through tabs.
Note
zka 0.10.2 is pre-1.0 software for NixOS on Linux/Wayland. It deliberately builds on Kitty, zmx, OpenSSH, systemd user services, and coding-agent hooks instead of replacing them.
Quick start · Why zka · Compare · How it works · Remote workspaces · Credentials · Security · Attention · Configuration
One long-running terminal is easy. Six coding agents across local and remote machines are not: live processes are coupled to disposable windows, the session you need is buried in a tab, generic notifications do not tell you where to go, and a remote agent may need a local hardware-backed identity without receiving the private key.
zka keeps the terminal workflow and adds the missing workspace layer:
| Capability | What it gives you |
|---|---|
| Live process persistence | Detach a workspace or lose Kitty/SSH without killing the programs inside its panes. |
| Kitty-native UI | Keep ordinary Kitty OS windows, tabs, splits, layouts, titles, scrollback, and key bindings. |
| Whole-workspace restore | Recreate the logical topology, working directories, and focus around the same live zmx sessions. |
| Remote attach and move | Open an origin workspace from another machine over normal OpenSSH, as a mirror or a primary handoff. |
| Agent attention | See Codex and Claude Code panes that are blocked, failed, or done in one live queue; jump directly to the exact pane. |
| Credential bundles | Explicitly claim and release stable SSH-agent and filtered OpenPGP capabilities for a remote workspace, across reconnects. |
| Headless origins | Keep workspaces, hooks, notifications, and credential targets alive on a machine with no Kitty or graphical session. |
| Composable infrastructure | Keep network reachability, authentication, terminal rendering, and PTY ownership in tools that already do them well. |
zka is not a new terminal emulator or an outer multiplexer. It does not replay a foreground command and hope it resumes correctly. The process is already alive:
local view: Kitty → zka → zmx → shell / editor / agent
remote view: Kitty → zka → OpenSSH → zmx on the origin → live process
credential: origin process → workspace socket → zka/yamux/OpenSSH
→ provider SSH agent or filtered gpg-agent → YubiKey
The closest conceptual alternative is herdr. Both products start from the same observation: persistent panes are not enough when several coding agents can need attention at once. They choose different abstraction boundaries:
- herdr is the multiplexer. Its background server owns the terminal panes, and its client renders an agent-aware workspace inside your existing terminal. It is portable across Linux and macOS, detects many agents, manages Git worktrees, and exposes a broad orchestration API.
- zka composes the workspace. Kitty continues to own the visible windows, tabs, splits, rendering, and input; zmx owns one persistent PTY per pane; zka adds durable workspace identity, topology restore, remote attachments, and a Codex and Claude Code attention layer. It is deliberately NixOS-, Wayland-, and Kitty-native.
Choose herdr when you want one portable, self-contained, agent-aware terminal multiplexer. Choose zka when Kitty itself is the workspace you want to preserve, including its native topology and desktop integration, while arbitrary pane processes remain alive independently behind it.
This table compares product boundaries, not just whether a README can claim a feature. “Persistence” distinguishes a live PTY from reconstructing a layout or resuming an agent through its own session mechanism. The credential column says what the product documents; an underlying shell or SSH setup can always add other credential plumbing independently.
| Project | Surface and persistence | Remote model | Agent and workspace model | Documented credential model |
|---|---|---|---|---|
| tmux | Multiplexer in any terminal; the tmux server owns live PTYs | Run tmux on the remote host over SSH | General sessions, windows, and panes; scriptable but not agent-semantic | Inherits and refreshes environment such as SSH_AUTH_SOCK; forwarding policy belongs to SSH |
| Zellij | Batteries-included multiplexer; the Zellij server owns live PTYs | Run remotely, or attach from a browser or terminal over authenticated HTTPS | General panes, layouts, collaboration, plugins, and programmatic control | Uses the session host's environment; no product-level credential-claim model is documented |
| herdr | Agent-aware multiplexer in any terminal; its server owns live PTYs | Run remotely or use its SSH thin client | Broad agent detection, direct attach, worktrees, plugins, and an orchestration API | Uses the session host's environment; no product-level credential-claim model is documented |
| cmux | Native Ghostty-based macOS terminal; restores app topology, scrollback, and supported sessions | Durable SSH workspaces reconnect to a remote relay/tmux session | Agent notifications, native subagent panes, browser surfaces, files, and automation | cmux ssh forwards the local SSH agent into the remote workspace |
| Superset | macOS editor/CLI/MCP with persistent terminals per Git worktree | Primarily local desktop workspaces | Parallel agents, monitoring, diff review, editor, PR flow, and worktree isolation | Uses credentials available to the local workspace; no remote-forwarding model is documented |
| Claude Squad | Terminal manager using tmux for live process persistence | Runs wherever its tmux host runs | Multiple agent profiles, one worktree per task, preview, diff, and checkout workflow | Uses credentials available in its tmux host environment |
| Vibe Kanban | Kanban web/desktop task runner rather than a general PTY multiplexer | Self-hosted deployment model | Planning, worktree execution, browser preview, diff review, and PR flow; the project is sunsetting | Uses credentials available to the task host; no workspace forwarding model is documented |
| zka | Native Kitty windows, tabs, and splits; one zmx-owned live PTY per pane | OpenSSH mirrors, transactional primary moves, and reconnecting headless origins | Arbitrary terminal programs plus Codex/Claude lifecycle attention; no task planner, diff review, or worktree isolation | Explicit workspace claims for a selected whole SSH agent and fingerprint/keygrip-filtered OpenPGP signing or decryption |
Comparison checked against each project's primary public documentation in August 2026. These tools optimize for different jobs: general multiplexing, agent orchestration, worktree-based delivery, native terminal restoration, or controlled access from remote workspaces to provider-held credentials.
Conventional OpenSSH agent forwarding
gives the remote host use of every key offered by the forwarded agent for the
life of that connection. GnuPG's
agent-extra-socket similarly keeps
private keys local and permits remote signing/decryption, but leaves public-key
distribution, socket placement, reconnects, and ownership to the operator. zka
does not make its SSH byte proxy key-selective. It adds an explicit durable
workspace claim, stable reconnecting endpoints, a separate keygrip-enforcing
OpenPGP filter, provider-side use notices, and credential-aware status/doctor
checks.
Add zka to your flake inputs and import its module into the machines that will create or display workspaces:
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
zka = {
url = "github:xlfe/zka";
inputs.nixpkgs.follows = "nixpkgs";
};
zmx.url = "github:neurosnap/zmx";
};
outputs = { nixpkgs, zka, zmx, ... }: {
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
zka.nixosModules.default
({ pkgs, ... }: {
services.zka = {
enable = true;
shell.command = [ "fish" ];
zmx.package = zmx.packages.${pkgs.stdenv.hostPlatform.system}.default;
};
})
];
};
};
}Apply the configuration:
sudo nixos-rebuild switch --flake .#my-hostThe module installs zka, Kitty, OpenSSH, the Kitty watcher, managed Codex and
Claude Code hooks, and the zkad systemd user service. zmx.package supplies
the persistent PTY backend. Install the Codex and Claude Code CLIs separately on
machines where you use them.
zka launchChoose New workspace, give it an optional name, and work in Kitty normally. Every new tab or split automatically becomes a persistent zmx-backed pane.
Prefer the CLI? Start a workspace in the current directory:
zka kitty --name example-project --cwd "$PWD"Detach closes the Kitty view and deliberately leaves every pane running:
zka workspace detach example-project
zka workspace attach example-projectThat is the core zka loop. attach restores the workspace around the original
live processes; it does not rerun codex, claude, nvim, a dev server, or
the shell.
- Run
zka launchand create or switch to a workspace. - Add Kitty tabs and splits as usual; each becomes a managed pane.
- Start any terminal program. Codex and Claude Code get agent-aware status through managed lifecycle hooks.
- Detach when you want the view gone but the work to continue.
- Open
zka attention showwhen you want only the panes that need a decision. - Attach the same workspace locally, or from another configured host over SSH.
- Claim a credential bundle only when that trusted remote workspace needs it, then release the claim when the operation is complete.
The launcher groups known workspaces into Attached and Detached. Selecting an attached workspace switches to its existing Sway window; selecting a detached one reconstructs its Kitty view. Each row shows workspace-level agent state, hook-reported agent types, and pane/tab/window counts.
workspace "example-project" on devbox.example
├── saved Kitty topology: OS windows → tabs → splits
├── pane A → zmx session → live Codex or Claude Code process
├── pane B → zmx session → live editor
└── pane C → zmx session → live shell
attachments
├── devbox.example → dedicated Kitty instance (primary)
└── laptop.example → dedicated Kitty instance over SSH (mirror)
optional credential claim owned by laptop.example
├── origin SSH_AUTH_SOCK → yamux stream → provider's selected SSH agent
├── origin GNUPGHOME → yamux stream → provider's filtered gpg-agent
└── workspace PIVB socket → semantic mint stream → provider's networkless pivbd
| Term | Meaning |
|---|---|
| Workspace | The durable unit: logical Kitty topology, panes, agent state, and attachment metadata. |
| Pane | One stable zka pane ID backed by one zmx-owned PTY. |
| Origin | The machine that owns the workspace state and zmx sessions. |
| Provider | The attachment machine that owns the local agent or hardware token backing a claimed credential bundle. |
| Attachment | A dedicated Kitty view of a workspace on one machine. |
| Primary | The attachment that owns the interactive primary lease after a local start or successful move. |
| Mirror | An additional fully interactive attachment created without revoking the primary. |
Kitty remains the visible interface. zmx remains the only persistent PTY owner. OpenSSH provides authentication and transport. zka owns workspace identity, topology capture, restoration, remote coordination, lifecycle, attention, and credential claims. There is no listening zka TCP service, PTY migration, or local zmx wrapped around an SSH connection.
The origin owns one canonical, generation-numbered topology. Tabs, splits, ordering, layouts, and tab titles changed in any ready attachment are committed at the origin and reconciled into every other connected attachment. Focus, scroll position, viewport size, and compositor geometry remain local so using a mirror does not steal focus on another machine. Disconnected attachments catch up from their last verified generation when they reconnect.
Reconciliation persists an applying fence before issuing any structural
Kitty command. Captures made while that fence is active cannot replace the
canonical topology, so a failed or interrupted multi-command repair remains a
local intermediate state. After repeated failure the attachment becomes
layout-stalled for that generation while panes, credentials, and attention
continue to operate; a later topology generation or an explicit reconcile
re-arms it.
The difference between closing and detaching is intentional:
| Action or failure | Result |
|---|---|
| Close a split or tab | Remove that pane and kill its zmx session. |
| Detach a workspace | Close only the local Kitty attachment; preserve all zmx sessions. |
| Attach a workspace | Focus the existing view or recreate it around the same sessions. |
| Confirm Kitty quit / close the final pane (workspace owned by this machine) | Kill the workspace. |
| Confirm Kitty quit / tear down the whole view (remote workspace attached here) | Detach only this attachment; the origin's zmx sessions and other attachments survive. Kill explicitly with zka workspace kill. |
| Kitty crash or lost control socket | Preserve the sessions and mark the attachment unhealthy. |
| Kill a workspace | Persist cleanup intent, terminate its zmx sessions, and retry partial cleanup durably. |
| Forget a detached remote workspace | Remove only this machine's cached metadata and generated Kitty sessions. The origin workspace and its processes survive and may reappear after a later reconnect. |
| One backend dies | Restore a removable zmx backend is dead placeholder while other panes survive. |
| Every backend dies | Close remaining managed views and reclaim the workspace. |
Restoration recreates the logical OS-window/tab/split hierarchy, layout state, titles, working directories, and active focus. A watcher triggers topology capture and origin-pushed reconciliation. Persistent failure never automatically adopts the partially applied Kitty tree.
zka workspace list
zka workspace inspect example-project
zka workspace reconcile example-project
zka workspace adopt-layout example-project
zka workspace adopt-layout example-project --confirm TOKEN
zka workspace create api --template ./quad.kitty-session
zka workspace create devbox.example:api --attach
zka workspace attach example-project
zka workspace move example-project
zka workspace focus example-project --pane PANE_ID
zka workspace seen example-project
zka workspace detach example-project
zka workspace forget devbox.example:example-project
zka workspace rename example-project shell-work
zka workspace kill shell-workattach and move are idempotent: repeating either command reuses the
deterministic machine/workspace attachment instead of creating duplicates.
kill is immediate and non-interactive.
forget is the local-only cleanup for a cached remote workspace. Every local
attachment must already be detached. An SSH_ALIAS: qualifier selects the
matching entry in the local cache; the command does not open SSH, change SSH
agent ownership, or stop anything on the origin. Reconnecting to that origin
can discover and cache the workspace again. Use kill when you intend to
destroy the authoritative workspace and its live processes.
create births a workspace without launching Kitty, locally or on a remote
origin (SSH_ALIAS:NAME; SSH_ALIAS: lets the origin pick a name). The
workspace comes out dormant — fully attachable, with no view anywhere and
no processes started — and stays that way until something attaches; list
shows the state as dormant. Pane directories default to the origin's home;
an explicit --cwd must be absolute for a remote origin. With --attach the
standard attach follows immediately and this machine becomes the primary.
Creation is replay-safe over SSH: retrying after a dropped connection returns
the workspace that was already born instead of creating a duplicate.
Run zka help, zka workspace help, or a command with --help for the complete
CLI surface.
Start a workspace from a topology-only Kitty session template:
new_tab work
layout splits
launch --location default
launch --location vsplit
launch --location hsplit
launch --location hsplit
focus
zka kitty --name quad --template ./quad.kitty-sessionTemplates may contain topology directives and bare launch directives only.
Program-bearing launches, unknown directives, and the reserved zka_workspace,
zka_pane, or ZKA_* variables are rejected. zka adds stable pane IDs and
canonical attachment commands itself.
Configure a normal OpenSSH host alias that resolves from the destination. The
origin and destination must run the same zka version, and zkad must be running
on the origin.
The graphical path is the same: choose Remote workspace in zka launch,
enter the SSH alias, and select an origin workspace — or pick New workspace
on <host> to create one on the origin and attach it here in one step. From
the destination, the CLI equivalents are:
zka workspace list --origin devbox.example
zka workspace inspect devbox.example:example-project
zka workspace create devbox.example:api --attach
zka workspace attach devbox.example:example-project
zka workspace move devbox.example:example-project
zka workspace forget devbox.example:example-project
zka workspace rename devbox.example:example-project shell-work
zka workspace kill devbox.example:shell-workUse attach for a mirror. Use move to make the destination primary. A move is
a two-phase handoff:
- Fetch origin revision R and register a preparing destination attachment.
- Create every destination Kitty pane.
- Require a fresh origin-side heartbeat from every SSH-to-zmx client.
- Confirm the logical topology and focus.
- Commit the primary lease at revision R.
- Only then revoke and close the old primary views.
If Kitty creation, SSH, revision validation, or pane readiness fails, zka removes the new view and leaves the source untouched.
The daemon keeps one supervised ssh -T control process per origin. Its stdio
is a yamux session: a reserved stream carries the versioned,
one-MiB-limited JSON-lines control protocol, and independent streams carry
credential connections. Terminal traffic remains on separate SSH channels that
attach directly to zmx on the origin.
OpenSSH server-alive checks detect dead connections. Transient startup,
transport, and handshake failures retry with jittered exponential backoff capped
at 30 seconds. Authentication or host-key rejection, a missing local ssh or
remote zka, protocol incompatibility, and credential node-pin failures stop
that supervisor with an explicit diagnostic. Pane channels reconnect
independently and reattach to the same zmx sessions. Mutating handoff requests
are replay-safe if SSH drops after the origin acts but before the response
arrives.
zka never restarts a missing foreground process. It reports the missing backend explicitly while preserving the rest of the workspace.
Credential bundles let an attachment machine act as a provider for a persistent workspace on another origin. A bundle names semantic capabilities; the current protocol implements three:
- SSH agent: a byte proxy to the provider's selected agent. The claim is workspace-scoped, but the protocol is intentionally not key-filtered: every identity offered by that agent is available through the endpoint.
- OpenPGP: signing and decryption through the provider's restricted
gpg-agentextra socket. Configured fingerprints are resolved to keygrips on the provider and enforced by an Assuan-aware default-deny filter. - PIVB: a constrained subject-token mint request to a provider-side,
networkless
pivbd. ZKA never forwards PC/SC, APDUs, PINs, control sockets, or arbitrary RSA digests. The bundle allowlists aliases and each claim pins the live card's serial, JWK key ID, and public key.
This is a breaking replacement for the old workspace-agent relay. Remove
services.zka.ssh.forwardAgent and any ForwardAgent=yes or -A option across
the fleet; zka rejects them rather than running two credential paths.
Define the bundle on both machines. Only the provider carries the authorized OpenPGP fingerprints:
# Shared by laptop (provider) and devbox (origin).
services.zka.credentials = {
defaultBundle = "work"; # selected only by an explicit create/attach claim
# Protocol 1 is cooperative. Enforced provenance is not yet available.
pivb.routingMode = "environment";
# Default PIVB grant window a claim from this node requests. Empty (the
# default) requests none, so every mint costs its own YubiKey touch.
pivb.grantWindow = "";
bundles.work = {
sshAgent.enable = true;
openpgp.enable = true;
pivb = {
enable = true;
aliases = [ "ro" "deploy" ];
};
};
};
# laptop only: authorize the keys this provider may expose.
services.zka.credentials.bundles.work.openpgp.signingKeys = [
"1111222233334444555566667777888899990000"
];Important
The v0.8.0 instructions required this shared configuration but v0.8.0 also
projected managed credential paths into every newly created local pane on the
provider. Anyone who followed those instructions can have affected panes:
local Git push and SSH fail because SSH_AUTH_SOCK names an absent relay,
while GPG sees an empty managed keyring with agent autostart disabled. Upgrade
to this release on the whole fleet and follow the migration steps below.
Pin the two zka nodes before claiming credentials. zka doctor prints the
local node= value. On laptop, bind the outbound SSH alias to devbox's
node; on devbox, allow laptop's node ID to provide credentials:
# laptop
services.zka.ssh.expectedNodeIDs.devbox =
"0123456789abcdef0123456789abcdef"; # devbox's node ID
# devbox
services.zka.credentials.providers.laptop = {
nodeID = "fedcba9876543210fedcba9876543210"; # laptop's node ID
# Optional for fixed hosts; omit this for roaming providers.
# sshSourceAddresses = [ "192.0.2.10" ]; # exact IPs or CIDRs
};sshSourceAddresses is empty by default so a VPN lease change, tethering, or
travel does not require rebuilding the origin. When present it is an additional
constraint derived from sshd's SSH_CONNECTION, not a replacement for the
mandatory node-ID allowlist. Do not use a wildcard such as 0.0.0.0/0: that is
equivalent to omitting the address check, but misleadingly looks restrictive.
On the provider, zka can opt in to configuring the globally shared user
gpg-agent, its restricted extra socket, and graphical pinentry:
services.zka.credentials.gnupg = {
configureAgent = true;
pinentryPackage = pkgs.pinentry-gnome3;
operationTimeout = "45s";
};Leave configureAgent = false on the origin. If another module already owns a
suitable provider agent, extra socket, and pinentry, leave it false there too.
The setting is deliberately opt-in because the user agent is shared with every
other smart-card consumer in that session.
The pinentry itself should be graphical (GTK, Qt, GNOME, or another
display/session-bus implementation). zka does not bind pinentry to the pane's
terminal: before workspace creation or an explicit credential claim, the CLI
asks the local zkad to refresh the provider agent from the CLI peer's live
graphical session. It queries GnuPG's current std_env_names, carries
LC_CTYPE and LC_MESSAGES separately, and deliberately drops GPG_TTY,
TERM, and INSIDE_EMACS. The refresh runs without a controlling terminal and
has one eight-second budget across discovery, environment query, and
UPDATESTARTUPTTY. Failure is printed as a warning and does not turn a
credential claim into an availability failure.
The peer environment comes from the same-UID Unix client process, guarded by
SO_PEERPIDFD; this requires Linux 6.5 or newer. A relative
WAYLAND_DISPLAY is checked against that peer's XDG_RUNTIME_DIR. zka assumes
the provider gpg-agent belongs to the same user session and therefore resolves
the same per-user runtime directory when pinentry starts. A reachable peer
session wins over zkad's captured startup session, which is only a fallback for
stale or incomplete callers. X11 candidates must use an empty, unix, or
localhost display host and must resolve to a live local X socket; remote
display hostnames are never installed into the provider agent.
UPDATESTARTUPTTY replaces the user gpg-agent's startup environment globally,
so this affects every restricted consumer of that agent, not only zka. The
filtered OpenPGP route relies on this: restricted agent connections copy the
startup environment when they open and reject attempts to override it with
session OPTIONs. To undo a bad refresh, first run the action again from the
correct graphical session. A code rollback alone does not restore the replaced
agent state; restart it if necessary:
gpgconf --kill gpg-agentOn a headless node, zka skips the refresh. zka doctor fails
pinentry-session when that node is configured or currently active as an
OpenPGP provider, or when its selected SSH source is gpg-agent, because a
graphical prompt can never be delivered there.
New workspaces default to the credential bundle of the machine creating them. For a local create, the origin node binds its local bundle. For a remote create, the machine driving zka binds its bundle on the remote origin before attaching:
zka kitty --name local-project
zka workspace create devbox:new-project --attach
zka workspace create devbox:public-project --attach --no-credentialsdefaultBundle supplies the creation default and the name used when --bundle
is omitted. --credential-bundle overrides it; --no-credentials creates an
unclaimed workspace whose managed endpoints fail closed. It does not restore
ambient SSH_AUTH_SOCK or GNUPGHOME inside the pane. zka kitty and
workspace create also take --credential-window DUR, which binds the new
workspace's bundle with a PIVB grant window.
For remote creation the graphical-session refresh still targets the local daemon, because that machine owns both the provider and the CLI peer; only the prepared credential bundle is claimed by the remote workspace. Attaching an existing workspace without an explicit claim performs no refresh and does not change its provider.
Attaching an existing workspace never changes its provider automatically. Use an explicit claim when the attaching machine should take over the whole bundle:
zka workspace attach devbox:example-project
zka workspace attach devbox:example-project --claim-credentials --credential-bundle work
zka workspace attach devbox:example-project --claim-credentials --credential-bundle work --credential-window 30m
zka workspace credentials status --json devbox:example-project--credential-window (and --window on credentials claim and
activate-local) asks for a PIVB grant window; without
it, and without credentials.pivb.grantWindow, every mint costs its own touch.
A binding is workspace-wide and has exactly one controlling attachment. The attachment must be ready, authenticated by the control path, and belong to the provider node. Detaching that owning view releases the whole bundle and advances the durable generation; detaching any other view leaves it unchanged. Explicit release and workspace deletion have the same fail-closed effect:
This deliberately reverses the short-lived node-owned schema-9 model. Node ownership made a confirmed detach indistinguishable from a temporary transport loss, so a reconnect loop could retain or resurrect authority after the user closed the controlling view. Attachment ownership preserves reconnect across a dropped control connection while giving confirmed detach a durable revocation event.
zka workspace credentials release devbox:example-projectA transient control disconnect retains both the durable binding and the stable origin endpoints, reports remote capabilities as degraded, and fails new operations closed. Reconnection replaces only the private provider transport; panes and zmx backends keep using the same paths.
PIVB, SSH-agent, and OpenPGP move as one bundle. A remote claim atomically replaces an active local provider. Release leaves every stable workspace endpoint unavailable; it never falls back to a local card. A trusted local launcher may explicitly and idempotently activate the origin bundle when the workspace is unclaimed:
zka workspace credentials activate-local --if-unclaimed --bundle work example-project
set pivb_route (zka workspace credentials endpoint example-project)
pivb agent-session \
--route-socket "$pivb_route" \
--alias ro \
--source-label codex:agentic/ro \
-- sbx codexThe bare endpoint command exits nonzero for an unclaimed or degraded route;
use endpoint --json when inspecting route state rather than launching an
agent session.
The endpoint path and daemon-owned listener stay fixed for the workspace. Provider changes replace the route behind it, so an already-running fixed-alias sandbox can use a newly claimed provider without receiving a new socket or broader authority. The claim pins the live slot-9c serial, JWK key ID, and SPKI; a different card requires a new explicit claim or local activation. Before takeover, the origin also checks the remote provider, issuer, alias targets, and serial/key ID against its card-free PIVB policy. On every successful mint the origin relay binds the response to that active route pin, which origin-side pivbd verifies alongside the signed token and local enrollment. See PIVB credential bundles for the launcher, PIVB service, timeout, and smart-card lease contract.
A claim normally says nothing about how often the card may be asked: every mint through it costs a YubiKey touch. A grant window is the operator saying, on the claim itself, that one touch may cover the next span of identical work:
zka workspace credentials claim --bundle work --window 30m devbox:example-project
zka workspace credentials activate-local --bundle work --window 30m example-projectThe window belongs to the claim, not to the mint. It opens at the claim's own
UpdatedAt and runs for min(requested, provider max_grant_window_s), so the
provider's operator always has the last word and re-asking near the end cannot
push the deadline out. Because the two numbers can differ, both are reported:
window= is what was asked for, window_granted= appears only when the
provider shortened it, and window_remaining= counts down the real deadline.
zka doctor appends window=… remaining=… to each claimed workspace, and
credentials status --json carries window_s, window_granted_s, and the
absolute window_deadline. The CLI also warns on the spot when a grant came
back short.
credentials.pivb.grantWindow supplies the default for claims made from this
node. An explicit flag always wins, including --window 0, so a node whose
configuration opens a window by default can still make a windowless claim.
Values are Go durations: 0, or between 1m and 12h, in whole seconds.
Windowed claims fail closed rather than degrading quietly. A bundle that does
not enable PIVB cannot carry a window, and neither can a provider publishing
max_grant_window_s = 0; either is refused at claim time, naming the bundle or
the provider that blocked it, instead of succeeding into a window that would
never open. Touch-free minting inside an open window is still bounded by the
provider alias's own assertion_reuse_s: a window can only shorten reuse, never
create it.
Renewal is re-granting. A windowed claim always advances the claim generation, even when it is byte-identical to the one it replaces, because providers re-read a claim only when its generation changes and would otherwise keep authorising against the old anchor.
Expiry is a state, not a failure: when the window closes the next mint simply costs a touch again. Nothing is released, nothing is refused, and the claim stays exactly as it was. The origin says so out loud — a desktop and ntfy notice two minutes before the close (skipped for grants shorter than four minutes, which would announce their own opening) and another at expiry, both honouring the ordinary notification policy and both journalled either way.
Releasing the claim closes the window at both ends. Every path that retires a claim — explicit release, owner detach, a provider observing the change on its own side, and a refreshed workspace mirror whose claim vanished or changed generation — tells the provider's pivbd to drop the assertions it still holds for that claim. That call is best-effort and asynchronous: the claim change is already durable when it runs, so a pivbd that is absent, older, or failing costs a journal line and nothing else, and the worst case is a grant that expires on its own inside the provider.
The credential environment is fixed when the zmx backend is created, not when a
view attaches or a provider changes. Every new local or remote pane receives the
same stable workspace SSH_AUTH_SOCK, GNUPGHOME, and—when any configured
bundle enables PIVB—the exact cooperative attachment tuple:
PIVB_ATTACHMENT_MODE=route-required
PIVB_ROUTE_SOCKET=/absolute/stable/workspace/socket
PIVB_ATTACHMENT_PROTOCOL=1
pivb capabilities --format=json is checked against both zkad's configured
binary and the pivb resolved from the launching user's PATH before a backend
is started. A partial tuple, stale binary, unknown protocol, conflicting route,
or failed route is rejected without trying the origin-local card. An unclaimed or
disconnected route fails closed; a local binding routes those paths to the
origin provider, and an explicit remote claim transfers them without recreating
the pane, zmx backend, sandbox, or attachment.
Enforced attachment provenance remains a separate Stage 2. A cgroup is not an
authenticator in a delegated user subtree: a same-UID process can write its PID
to another pane scope's cgroup.procs and impersonate that workspace. An
inherited descriptor would provide a second factor, but the target-host probes
ran zmx 0.6.0,
which closes inherited descriptors 3 through 63 before spawning the PTY child.
Stage 2 therefore requires either an explicit zmx pass-descriptor contract or
process isolation that removes the same-UID control surfaces. ZKA does not
expose a cgroup-bound mode that would overstate this boundary.
If a remote provider daemon restarts without an explicit
ssh.identityAgent, SSH becomes degraded until the bundle is re-claimed rather
than silently switching agents.
Endpoint projection intentionally uses the union of capabilities in every
configured bundle, not only the currently claimed bundle. This pre-creates
stable paths so a later claim can activate them without replacing the backend.
For example, an SSH-only claim can still have an empty, fail-closed GNUPGHOME
when another configured bundle enables OpenPGP.
Inside a pane with the managed GNUPGHOME, gpg --list-keys intentionally
shows only the bundle's imported public keys and gpg --list-secret-keys shows
no local secret keys. The normal keyring is unchanged; unset GNUPGHOME (or use
an explicit --homedir) when you want to inspect it. This is isolation, not
keyring data loss.
After upgrading and restarting zka across the fleet, inventory pane environments:
zka doctor
zka workspace credentials status --jsonEnvironment version 5 is protocol 1. Any other live version is route-unsafe.
Ordinary workspace reconcile
remains a non-destructive topology recapture/backend census; it never terminates
a pane. Recreate only after reviewing the named panes:
zka workspace reconcile --recreate-backends example-projectThe explicit operation requires an existing attachment-owned ready claim,
checks the configured and pane-visible PIVB capabilities, verifies stable
listeners and detached zmx run, then replaces the complete affected backend.
It never claims defaultBundle implicitly. This terminates programs in the
named panes. Durable pending state makes interruption retryable, while a failed
preflight leaves the old backend running. Until every live backend reports the
exact selected version, endpoint status is degraded and PIVB minting is
unavailable.
Schema 9 to 10 deliberately clears every credential claim—including cached
remote copies—because schema 9 did not retain an attachment owner. Every
workspace therefore requires a manual claim from a ready attachment after the
upgrade. Each upgrade attempt writes a new private
.v9.<timestamp>.<nonce>.backup; backups are never reused across an
upgrade/rollback/upgrade cycle. Zkad logs each exact backup path. Backups are
retained rather than pruned automatically; remove obsolete copies only after
the rollback window has closed.
For each OpenPGP private-key operation, the provider must have an interactive
session, must not report a positive screen lock, and must successfully deliver a
credential-use notice to the desktop service or mandatory ntfy-send fallback.
That fallback is a security path and ignores notifications.ntfyEnabled; if
neither channel accepts the notice, the operation fails closed. The operation
then remains bounded by operationTimeout. See
Security model for what these checks do—and do not—authorize.
Git reports the outer failure as gpg failed to sign the data; the useful clue
is the Assuan error immediately above it:
| gpg/Assuan symptom | What zka rejected | What to check |
|---|---|---|
No Pinentry |
No interactive provider session, a positively locked screen, or failure to deliver the mandatory credential-use notice | Unlock/log in on the provider, then check desktop/ntfy delivery and journalctl --user-unit zkad; a broken ntfy fallback deliberately looks like this rather than bypassing the notice |
Timeout |
Pinentry, card touch, decryption, or signing did not finish before operationTimeout |
Provider pinentry, YubiKey touch policy, smart-card contention, and credentials status --json active operations |
Forbidden |
The origin selected a keygrip outside the bundle or sent a command the OpenPGP filter does not allow | user.signingKey, configured fingerprints, openpgp-keys doctor output, and release/re-claim after bundle changes |
For a fleet upgrade, install and restart the same zka version everywhere, run
zka doctor on provider and origin to collect node IDs and validate bundle/key
configuration, add the peer pins, then run zka doctor --origin devbox from the
provider. Recreate only the panes named by credential status and verify the
original hardware path:
git commit -S -m "update for release"
git verify-commit HEADzka coordinates terminals and credentials for one Unix user across machines you administer. It is not a sandbox, privilege boundary between same-UID processes, or substitute for OpenSSH host/user authentication.
| Boundary | Enforced behavior | Residual authority or risk |
|---|---|---|
| Network | zka opens no TCP listener. Remote control, topology, and credential streams travel inside authenticated, encrypted OpenSSH sessions; incompatible protocols fail closed. | SSH configuration, host keys, authorized client keys, and the remote Unix account remain part of the trusted computing base. |
| Local user | Runtime/state directories are mode 0700, files and Unix sockets are 0600, and the systemd unit uses UMask=0077 and NoNewPrivileges=true. |
Any process already running as that Unix user can read user-owned state and can connect to a credential socket whose path it can reach. Per-workspace sockets are routing boundaries, not same-UID isolation. |
| Workspace | Dormant creation remains unclaimed; create-and-attach may explicitly select the driver's default bundle. Later attachment claims only with --claim-credentials. Each generation is owned by one active provider attachment; confirmed owner detach releases it, while transport loss only degrades it. Daemon-owned endpoints remain stable and fail closed. |
Protocol 1 is cooperative. A same-UID process can strip the inherited marker or reach another user-owned control socket; enforced pane provenance requires a future zmx descriptor contract or process isolation. |
| Provider identity | The outbound alias is pinned to an expected target node ID, and the origin accepts credential listeners only from configured provider node IDs. Optional source CIDRs can narrow fixed-host deployments. | The provider node ID is asserted inside an authenticated SSH session but is not yet bound to the particular client public key. Another key authorized for the same account could assert a configured ID. Public-key binding through sshd authentication metadata remains future work. |
Socket paths carry different kinds of meaning, so zka does not apply one fallback rule to all of them:
| Class | Examples | Failure behavior |
|---|---|---|
| Hints | SWAYSOCK, I3SOCK |
Try the environment hint first, then discover a live compositor socket under XDG_RUNTIME_DIR. Doctor warns when a set hint failed. |
| Policy | SSH_AUTH_SOCK, GnuPG agent-extra-socket |
Never scan for a substitute. The selected socket determines which keys are authorized, so an unreachable path fails closed. |
| Identity | zka control/watcher sockets, Kitty attachment endpoints, workspace SSH/PIVB relays and GnuPG homes | Keep the stable path owned by zka and repair or republish that endpoint; never attach to a lookalike socket. |
XDG_RUNTIME_DIR is the session root supplied by pam_systemd, not another
socket hint. zka derives its owned runtime paths from it and does not attempt to
rediscover a different runtime directory.
Private keys and provider socket paths are never copied to or persisted on the
origin. OpenPGP public keys are imported into a private per-workspace
GNUPGHOME; each corresponding private keygrip is resolved and authorized on
the provider.
The SSH capability is deliberately a whole-agent byte proxy. An origin process that can reach the claimed endpoint can list and request use of every identity offered by the selected agent. zka adds stable claim/reconnect semantics but no SSH protocol filter, per-key allowlist, or provider-side zka notification. Any confirmation comes from the agent or hardware token itself.
The OpenPGP capability has a narrower protocol boundary. Its filter:
- allows only the Assuan commands needed for discovery, signing, and decryption;
- filters
HAVEKEYandKEYINFOto configured keygrips and rejects unknown key selection; - blocks raw
scdaemonpassthrough and replaces remote key descriptions with zka-generated context; - swallows remote display, terminal, locale, and pinentry-mode options so the origin cannot choose or relabel the provider's prompt; and
- bounds lines, responses, inquiries, and each private-key operation.
The swallowed session options must not be forwarded: the upstream socket is
restricted and GnuPG answers those options with FORBIDDEN, terminating the
connection. zka instead opens a fresh upstream connection for each downstream
client, so it receives the provider agent's most recently refreshed startup
environment.
Before signing or decryption, zka requires a session bus, fails immediately on a positively reported freedesktop/GNOME screensaver or logind lock, and requires the desktop notification service or mandatory ntfy fallback to accept a provider-side notice. This is a fail-closed security signal, not interactive approval: successful delivery does not prove that a human saw it, and an unknown lock API does not prove presence. Pinentry and a hardware-token touch, when the key is configured to require them, remain the final human authorization boundary. Software-backed configured keys are allowed with a loud status/doctor warning rather than rejected.
The PIVB capability is narrower again: its route accepts one versioned
subject-token mint operation, not a generic byte stream. ZKA overwrites the
caller-supplied card and routing context with the card pinned by the claim and
the authenticated origin, workspace, bundle, generation, provider, attachment,
and operation ID. Provider-side pivbd validates the complete alias, target,
audience, enrollment, and pinned public key before signing. The origin-side
pivbd then verifies the JWT signature, claims, lifetime, SPKI, and local
enrollment before returning it. Only the five-minute subject token crosses the
route; STS and IAM exchanges still run in the sandbox's Google auth library.
The route carries no PIN, unlock operation, digest-signing primitive, APDU,
PC/SC session, or Google access token.
ZKA also owns a cooperative provider-wide smart-card lease. Filtered OpenPGP
private operations and PIVB hardware operations serialize through it when
pivbd is configured with zka's lease socket. This removes the routine
scdaemon/PIVB race; unrelated same-UID processes that bypass zka remain a
residual source of card contention. Workspace-forwarded PIVB work requires the
lease. Direct-local unlock and mint retry a temporarily missing zkad socket and
then retain their pre-ZKA behavior; a lease denial or protocol failure never
downgrades to uncoordinated access.
Credential notices contain zka-owned provider, workspace, bundle, capability,
operation, and fingerprint fields. Sending the mandatory fallback discloses
that bounded metadata to the configured ntfy helper. Separately,
notifications.ntfyIncludeEvidence = true can send raw agent evidence such as
assistant output or tool descriptions; it is false by default because that text
may contain source code, prompts, paths, or secrets.
- Use zka only between origins and provider accounts you trust, with normal strict OpenSSH host-key verification and narrowly authorized client keys.
- Configure both node-ID pins, and add
sshSourceAddressesonly when its roaming cost is acceptable; do not treat a wildcard CIDR as protection. - Prefer a persistent explicit
ssh.identityAgent, hardware-backed OpenPGP keys, and touch/PIN policy appropriate to unattended remote workloads. Wire pivbd to zka's cooperative card lease when both use the same smart card. - Keep desktop notification delivery or the mandatory ntfy fallback working; check it before relying on remote signing.
- Claim the smallest bundle, with the shortest grant window that covers the
task, only while needed; release it afterward, and never combine zka bundles
with OpenSSH
ForwardAgent. - Use
zka workspace credentials status --json,zka doctor --origin HOST, andjournalctl --user-unit zkadto audit claims, key backing, transport, active credential operations, and degraded paths.
Each pane records explainable Codex or Claude Code lifecycle evidence and one of
unknown, idle, working, blocked, done, or error. The workspace
exposes the highest-priority aggregate. Managed hooks associate agent events
with the hidden pane through ZKA_WORKSPACE_ID and ZKA_PANE_ID.
Filesystem-isolated agents can instead receive a per-process hook socket. Run
the trusted sandbox launcher under zka relay hooks; the relay binds the
capability to the current workspace and pane, while the sandbox receives no zka
identity or control socket. This requires launcher-side mount and environment
wiring and must be rolled out with that launcher change. See
sandboxed agent hook relays for the exact
contract and limits.
Claude support is hook-only: zka does not read screen contents, scrollback, or
transcripts. Claude Code does not emit Stop after a user interrupt, and a
dismissed permission dialog may not emit a closing event. An idle notification
can repair those states; otherwise the next prompt or session transition clears
them. Background subagent activity cannot overwrite the parent pane, while
subagent permission requests still surface as blocked.
Claude permission prompts, AskUserQuestion, plan approval, and MCP elicitation
dialogs map to blocked; successful or failed tool completion resumes
working; Stop maps to done; and StopFailure maps to error without
marking the zmx backend dead. SessionEnd clears the Claude label and returns
the pane to unknown.
zka attention is a live projection of what needs you now, not a notification
history. Resolved items disappear automatically; finished work disappears while
that exact pane is focused. The queue shows blocked work first, then errors,
then completed work, oldest first within each state.
zka attention show # graphical queue of actionable panes
zka attention status # one human-readable snapshot
zka attention status --json # versioned machine-readable snapshot (v2)
zka attention focus-next # restore/focus the highest-priority exact pane
zka attention pause # silence interruptions; agents keep running
zka attention resume
zka attention toggleKitty titles and local notifications reflect pane state. By default, zka sends
desktop and ntfy-send notifications for blocked and error, and for done
when the pane has no attached view.
Desktop notifications go straight to org.freedesktop.Notifications on the
session bus, so they need no helper binary on PATH. Clicking the notification
body or its Focus button focuses that exact pane and raises the Kitty window
that owns it; resolving a pane withdraws its notification, and a pane that
changes state updates its notification in place rather than stacking a new one.
A host with no session bus — a headless or remote origin — simply has no desktop
channel, which is not an error.
Delivery is deduplicated per event. A failure is retried three times immediately,
then on a backoff from 30 seconds to 15 minutes, for up to 8 attempts or two
hours, after which it is abandoned and reported rather than retried forever.
Retries stop early once the pane is no longer actionable, so a resolved pane is
never notified about late. Every failure is logged to journalctl --user -u zkad, counted in zka attention status, listed by zka workspace inspect, and
checked by zka doctor.
zka attention status --json reports "version": 2. The change from 1 is
additive — counts.undelivered, a top-level delivery aggregate, and a
per-item delivery array — but the version is worth checking, because
counts.total alone no longer means nothing is wrong: delivery.failed can be
non-zero while items is empty, since a channel that cannot deliver stays
reported after the pane it failed for has resolved.
Pause persists across daemon restarts. It suppresses locally generated desktop and ntfy notifications while agents and remote synchronization continue. The pending count stays visible, and resume delivers only items that still need attention and were not delivered before. Pause and ntfy policy belong to each origin; they are not propagated over SSH.
Waybar can hold one streaming subscription to zkad, with no polling interval
or process per update:
The module always emits a count, including 0, plus one of the CSS classes
clear, blocked, error, done, paused, notify-failed, or
unavailable:
#custom-zka { color: #99a8b8; }
#custom-zka.blocked, #custom-zka.error { color: #ff8f91; }
#custom-zka.done { color: #6ed5c0; }
#custom-zka.paused { color: #7b8794; }
#custom-zka.notify-failed { color: #ffb454; }
#custom-zka.unavailable { color: #d2a8ff; }When a notification channel cannot reach you, the count also gains a ! suffix
(0!, 3!) and the tooltip names the channel and the error, so the failure is
visible even without the notify-failed rule above. That class outranks
paused, because a pause is deliberate and a broken channel is not; the tooltip
still reports the queue as paused.
Bind the launcher and inbox directly in Sway:
bindsym $mod+Return exec zka launch
bindsym $mod+a exec zka attention show
bindsym $mod+Shift+a exec zka attention focus-next
bindsym $mod+Ctrl+a exec zka attention toggle
for_window [app_id="^zka-launch$"] floating enable, resize set width 680 px height 560 px, move position center
The launcher and attention popup share the stable Wayland app ID zka-launch.
The attention window title is zka attention if a compositor rule needs to
distinguish it. Up/Down selects, Enter or a click focuses the exact pane, P
pauses or resumes notifications, and Escape closes the popup.
The NixOS module owns the runtime configuration shared by the CLI, launcher, and daemon. Managed hooks for both supported agents default to enabled:
services.zka = {
enable = true;
shell.command = [ "fish" ];
zmx.package = zmx.packages.${pkgs.stdenv.hostPlatform.system}.default;
codex.enableManagedHooks = true;
claude.enableManagedHooks = true;
attention.states = [ "blocked" "error" "done" ];
kitty.extraArgs = [ "--class" "managed-kitty" ];
ssh.identityAgent = "/run/user/%i/ssh-agent.socket";
ssh.options = [
"-o" "ServerAliveInterval=5"
"-o" "ServerAliveCountMax=3"
"-o" "BatchMode=yes"
];
# Provides swaymsg, used to raise the Kitty window owning a pane when a
# notification is actioned. Defaults to pkgs.sway when programs.sway.enable
# is set, and to null otherwise. zkad runs from a systemd unit, so the
# absolute store path is what makes this resolvable at all.
# sway.package = pkgs.sway;
notifications = {
desktopEnabled = true;
ntfyEnabled = true;
ntfyIncludeEvidence = false;
ntfyCommand = "ntfy-send";
};
# Add the package that provides your already-configured helper.
# extraPackages = [ inputs.ntfy-send.packages.${pkgs.system}.default ];
};Credential configuration is intentionally split between provider and origin; use the complete Credential bundles example rather than copying one machine's half to the other.
When managed Codex hooks are enabled, the module owns
/etc/codex/requirements.toml. Add other system requirements under
services.zka.codex.extraRequirements; zka does not disable user or project
hooks. Disable the managed entries with
services.zka.codex.enableManagedHooks = false.
Managed Claude hooks are written as the independent
/etc/claude-code/managed-settings.d/50-zka.json drop-in, so other managed
settings files remain untouched. The hooks run system-wide but immediately
return without side effects outside a zka pane. Disable them with
services.zka.claude.enableManagedHooks = false.
ntfy-send authentication remains in the helper's own configuration. zka never
reads or transports its token.
ntfy titles begin with the workspace name and the message leads with a readable
state summary, followed by the workspace, pane title, and origin names. Internal
workspace and pane IDs are omitted. Set
notifications.ntfyIncludeEvidence = true to put assistant output or tool
descriptions first instead of the safe state summary; those details may contain
sensitive data.
A machine can be an origin without ever hosting a view: a cloud server runs
only zkad, zmx, sshd, and the agents inside its panes, while every Kitty
that displays its workspaces lives on your GUI machines.
services.zka = {
enable = true;
headless = true;
linger.users = [ "felix" ];
zmx.package = zmx.packages.${pkgs.stdenv.hostPlatform.system}.default;
notifications.ntfyPackage = inputs.ntfy-send.packages.${pkgs.system}.default;
};headless = true swaps in the launcher-free zka-headless package (no
Gio/Wayland/Vulkan closure), drops Kitty from the machine entirely, defaults
desktop notifications off, and makes zka doctor and zka workspace reconcile headless-aware. Everything that matters on an origin stays: zmx,
OpenSSH, the agent hooks, and ntfy.
Two settings are load-bearing on a server:
linger.userskeeps the systemd user instance — and with itzkadand/run/user/UID— alive after the last SSH session closes. Without it, agent hooks silently no-op and notifications stop between logins.notifications.ntfyPackageputs the ntfy helper onzkad's PATH as an absolute store path. With no session bus and no local Kitty view, ntfy is the only channel that can reach you while nothing is attached; desktop notifications for this origin's panes fire on whichever machine holds an open mirror.
The daily flow: from a GUI machine, zka launch → Remote workspace →
New workspace on <host> (or zka workspace create host:name --attach).
The workspace is born dormant on the origin, your local Kitty attaches as
primary, and the panes' zmx sessions start on the origin. Close the laptop —
agents keep running; confirming Kitty's quit dialog on a remote view only
detaches it. Come back from anywhere with zka workspace attach host:name or
zka attention focus-next. The origin and every machine that attaches to it
must run the same zka version.
Select the SSH agent used by zkad
Control SSH runs inside zkad, so without an explicit IdentityAgent it
inherits the systemd user manager's environment, not necessarily the environment
of the shell that opened the launcher. Prefer a persistent socket:
services.zka.ssh.identityAgent = "/run/user/%i/ssh-agent.socket";OpenSSH expands %i to the numeric user ID. This socket is also the
provider-side source for bundles that enable the SSH-agent capability. The path
stays on the provider and is never persisted or sent to the origin.
[!CAUTION]
IdentityAgentselects the agent socket, butIdentitiesOnly=yesseparately limits authentication to keys named byIdentityFile. A GPG agent, smartcard, or other agent-only key may therefore work with plainsshwhile zka fails withPermission denied (publickey)because the key was never offered. Unless you deliberately need to restrict a multi-key agent, omitIdentitiesOnly=yes; the explicitssh.identityAgentalready selects the intended agent.
If IdentitiesOnly=yes is required, name the corresponding public key file.
OpenSSH can use that public key to select the matching private key held by the
agent; the private key does not need to be exported:
services.zka.ssh.extraOptions = [
"-o" "IdentitiesOnly=yes"
"-o" "IdentityFile=/home/user/.ssh/gpg-card.pub"
];Inspect the user manager's current environment with:
systemctl --user show-environment | rg '^SSH_AUTH_SOCK='For a temporary, session-dependent workaround, import the interactive shell's agent and restart the daemon:
systemctl --user import-environment SSH_AUTH_SOCK DISPLAY WAYLAND_DISPLAY
systemctl --user restart zkadThe default BatchMode=yes prevents password, host-key, and private-key
passphrase prompts. Any required confirmation must be available independently;
zka has no controlling terminal and does not relay prompts through the launcher.
zka doctor --origin HOST verifies bundle configuration, provider sockets,
OpenPGP fingerprints/keygrips and card backing, workspace claims, and the
multiplexed credential transport. journalctl --user-unit zkad contains the
same bounded SSH connection diagnostics.
Reuse SSH authentication across panes
The control connection and remote panes are separate SSH sessions. OpenSSH multiplexing carries them as independent channels over one authenticated network connection:
services.zka.ssh.extraOptions = [
"-o" "ControlMaster=auto"
"-o" "ControlPersist=10m"
"-o" "ControlPath=/run/user/%i/zka-ssh-%C"
];Append these entries to any existing ssh.extraOptions list. If that list also
contains IdentitiesOnly=yes, configure a matching IdentityFile as described
above. The first connection becomes the master; later control and pane sessions
reuse it without another key or hardware-token operation. %C keeps each
destination's socket name short and unique.
A connection failure interrupts all multiplexed channels together. zka's control and pane reconnect paths then reattach them to the same zmx sessions.
Use the local clipboard from remote Neovim
clipboard=unnamedplus does not transport clipboard data between machines. In a
remote pane, a wl-copy provider runs on the origin. Kitty selection copying
works because the displaying Kitty process handles it locally.
For Neovim 0.10 or newer, select its built-in OSC 52 provider before anything initializes the clipboard:
if vim.env.ZKA_WORKSPACE_ID then
vim.g.clipboard = "osc52"
end
vim.opt.clipboard = "unnamedplus"Do not test SSH_CONNECTION: Neovim can start in a local attachment and later
be viewed remotely because zmx preserves the process. Restart Neovim after
changing the provider.
Test the terminal path independently; the displaying machine's clipboard should
become zka-osc52-test:
printf '\033]52;c;emthLW9zYzUyLXRlc3Q=\a'Kitty permits clipboard writes by default. Reading over OSC 52 may raise Kitty's configured permission prompt; Kitty's normal paste binding avoids granting remote programs unconditional clipboard-read access.
zka doctor
zka doctor --origin devbox.example
journalctl --user-unit zkadThe credential lines are intended to be read directly during rollout. A hardware-backed provider and a healthy claimed workspace look like this (IDs and keygrips are shortened here):
ok credentials-config work=ssh-agent+openpgp+pivb; default=work
ok daemon /run/user/1000/zka/zkad.sock; node=0123456789abcdef0123456789abcdef
ok current-pane-credentials not running inside a zka pane
ok provider-environment reachable graphical session from local CLI session
ok credentials-provider configured provider sockets are reachable
ok openpgp-keys work=11112222…99990000/A1B2C3D4:card
ok pinentry-session provider agent has a reachable graphical startup environment; last refreshed by create for 89abcdef at 2026-08-11T12:00:00Z
ok credentials-claim example-project=work@fedcba98[openpgp:ready,pivb:ready,ssh-agent:ready]
ok credentials-transport ready
ok credential-environment no panes require credential-environment recreation
FAIL makes zka doctor exit nonzero. WARN is deliberately conspicuous but
does not fail the command; for example, a configured key ending in
:software is usable but has no hardware-touch boundary:
WARN openpgp-keys work=11112222…99990000/A1B2C3D4:software
FAIL credentials-provider openpgp: dial unix /run/user/1000/gnupg/S.gpg-agent.extra: connect: no such file or directory
FAIL credentials-transport degraded: provider control session is unavailable
An agent launched from an interactive login may retain GPG_TTY or TERM
until zka performs its first graphical refresh; pinentry-session reports that
pre-refresh state as WARN. If terminal routing reappears after zkad records a
successful refresh, the same check reports FAIL. The zkad-ssh-agent,
caller-ssh-agent, and ssh-agent-match rows remain present even when the
newer daemon-side provider diagnostic is unavailable.
A stale compositor hint also warns without breaking Focus actions:
WARN sway-ipc recovered via XDG_RUNTIME_DIR at /run/user/1000/sway-ipc.1000.99.sock; SWAYSOCK=/run/user/1000/sway-ipc.1000.42.sock is stale (exit status 1: Unable to connect); fix your Sway session environment import; zka recovery does not repair other programs in the session
zka retries compositor discovery on each Focus action rather than caching the
recovered path. This recovery fixes notification and CLI focusing only. Other
programs still see the stale environment value until the Sway session imports
its current SWAYSOCK; import it into the systemd user manager at every Sway
start rather than treating zka's warning as the durable repair.
Inside a legacy v0.8.0 pane, the targeted diagnosis prints only origin-owned
paths. Provider checks now run inside zkad's sanitized environment, so they do
not inherit the pane's managed GNUPGHOME or SSH_AUTH_SOCK:
FAIL current-pane-credentials pane 01234567 uses credential environment v2 but routing mode environment requires v5; run `zka workspace reconcile --recreate-backends abcdef…`
ok provider-environment reachable graphical session from local CLI session
ok credentials-provider configured provider sockets are reachable
FAIL credential-environment example-project=01234567 (run `zka workspace reconcile --recreate-backends abcdef…` after claiming from a ready attachment)
zka workspace inspect WORKSPACE includes attachment health, pane/backend state,
canonical topology generation/digest, convergence state, agent evidence, and
retained notification failures. zka workspace reconcile WORKSPACE forces a
complete local recapture and repair without restarting pane backends. Backend
replacement requires the separate --recreate-backends flag.
zka workspace adopt-layout WORKSPACE is the deliberate escape hatch when the
saved target should be replaced by what one local Kitty attachment currently
shows. The first invocation is read-only and prints the desired and observed
shapes plus a confirmation token. A second invocation with --confirm TOKEN
recaptures the attachment and commits only if both the desired base and the
observed candidate are unchanged and the pane set is exact.
The current migration writes a unique private .v9.<timestamp>.<nonce>.backup
before upgrading to schema 10. Both the local daemon and remote protocols are
version 16. Upgrade the CLI and daemon fleet together, then restart zkad on all
SSH peers. To roll back, first inventory and stop every live backend reporting
credential environment v5; an old daemon would otherwise accept the
restored v9 pane record while the newer route-required process keeps running.
Then stop zkad, restore the newest matching v9 backup for the upgrade being
rolled back, install the prior binary on every
peer, and restart zkad. Do not restore state alone.
zka doctor checks the enabled Codex and Claude Code executables and their
managed hook files. An integration disabled in the NixOS module is reported as
disabled instead of failed. On a headless origin the view-layer checks
(kitty, kitten, swaymsg, kitty-watcher) report skipped on a headless origin; zmx, ssh, ntfy-send, and the credential checks stay real. The sway-ipc
check runs inside zkad, resolves the same socket used by notification actions,
and performs a read-only get_version request so a daemon started before Sway
can be diagnosed without focusing a window.
"Exact restore" means logical Kitty topology plus the live terminal state still owned by zmx. zka does not promise pixel-perfect compositor placement, migrate terminal graphics, or adopt an ordinary already-running PTY. Managed workspaces use dedicated Kitty instances so every view can be identified and restored safely; shared unmanaged Kitty processes remain outside the model.
zka manages durable terminal workspaces, not prompts, branches, worktrees, diffs, or agent task planning. Any terminal program can run in its panes, while the built-in attention integration currently targets Codex and Claude Code lifecycle hooks.
Credential bundles are semantic adapters, not arbitrary socket forwarding. The current protocol implements SSH-agent, filtered OpenPGP, and constrained PIVB adapters. OSTUI and other capability types remain future designs.
Keep short-lived utility terminals on plain Kitty unless their processes should
persist. Existing new_window_with_cwd and new_tab_with_cwd mappings continue
to work: the managed instance uses Kitty's last OSC-7-reported directory before
routing the new pane through zka.
Version 0.10.2 combines three systems: durable Kitty-native workspaces, Codex and Claude Code attention routing, and reconnect-safe remote credential bundles. The current tree extends those bundles with workspace-owned PIVB routes while preserving the fixed-alias sandbox ABI. It also includes remote mirrors and two-phase moves, headless origins, Waybar streaming, desktop/ntfy notifications, and durable cleanup after partial failures. Version 0.10.1 made topology publication transactional across local and remote attachments: incomplete reconciliation is fenced from the canonical layout, runtime Kitty identities are re-resolved after moves, and deliberate recovery uses a two-capture operator confirmation. Version 0.10.2 additionally makes the provider-side admission of new remote panes durable, including across authoritative cache refreshes. It speaks PIVB forwarding protocol 3 and daemon/remote protocol 16, so pivb 0.5.0 and zka 0.10.2 must be deployed together. Upgrades from an older protocol still require every PIVB bundle to be released and re-claimed and zka daemons to be upgraded as one fleet; 0.10.1 to 0.10.2 changes neither protocol nor persisted state. Repeated local activation of an existing claim remains a true no-op unless it carries a window, which is always a fresh grant, and endpoint health is still reported for the whole bundle before a launcher starts its sandbox. It retains inherited SSH and GnuPG credentials in locally created panes, inventories ambiguous v0.8.0 pane environments before recreation, and recovers Focus actions from stale compositor environment hints while keeping the underlying session problem visible as a doctor warning.
State schemas v1 and v2 are reset on the first v0.3-or-newer start because v3 changed process ownership. The reset removes old zka state and generated Kitty sessions but does not kill old zmx sessions or signal existing Kitty processes. Those zmx sessions remain as unmanaged orphans that can be inspected or removed with zmx.
The flake supports x86_64-linux and aarch64-linux. Build the binaries and run
the NixOS module checks with:
nix flake checkThe package check runs the Go test suite with the Wayland-only nox11 build tag.

