Skip to content

Update after release

Use this runbook after replacing an installed Pohunek binary from a component release archive or rebuilding it from source.

A daemon of the current release accepts public protocol 3..=4: clients of the previous release (protocol 3) keep working against it, and a range outside that returns daemon/version_mismatch before any method runs. The CLI and Rust client of the current release speak 3..=4, so they also work against a daemon that only speaks v3, and so do TypeScript SDK clients (see “Mixed releases across hosts” below). A client and a daemon more than one release apart are refused. Before replacing any component, inventory every CLI, SDK, custom client, managed hook and local or NetBird-reachable daemon that must talk to another peer. Drain cross-host automation, upgrade the daemons first, and then verify every host with pohunek health --json and pohunek host inspect <host> --json. The reported protocol_version is the version negotiated for that connection (4 for a current CLI).

Protocol v4 spells the worker instance identifier worker_instance_id where v3 spelled it runtime_id. The daemon translates that spelling for protocol 3 clients only; a v4 client or hook never sends it. Do not downgrade one peer independently: a v3 client is served by a v4 daemon, a v4 CLI or SDK client works against a v3 daemon. Managed Codex and Claude hook assets carry POHUNEK_INTEGRATION_VERSION=14, and the upgrade never rewrites them: follow “Managed hook assets after an upgrade” below. An older hook still sends the old key, which the daemon accepts only inside a session whose launch baked protocol 3; in any other session its native-identity reports are rejected. Hooks of a session launched before the upgrade keep reporting with the protocol version baked into its launch environment while the daemon serves that version. State hooks older than version 11 sent an integer seq that the daemon rejects, so their public-socket reports were dropped, and always spelled the native-id worker key in the protocol 4 form. Notification hooks older than version 10 send a bare integer v instead of the {minimum, maximum} range, so the daemon drops their notifications until they are reinstalled. Claude assets older than version 12 register no state hook on Stop, so they send no main-turn subagent snapshot and stale Claude subagent rows are not reconciled until they are reinstalled; a session worker started by a release without the snapshot claim refuses it, so those sessions keep their rows until their own stops arrive. Codex and Claude state hooks older than version 13 write no hook failure trace (pohunek-hooks.jsonl), state hooks older than version 14 send their Stop snapshot without the label, program and agent_type fields, and a session worker started by a release without the trace sets neither POHUNEK_HOOK_TRACE_PATH nor POHUNEK_HOOK_TRACE_MAX_BYTES, so its hooks write no trace either until the session gets a new worker. Earlier protocol transitions (integer-v1 to range negotiation, the v3 overlay-routing change) do not widen the supported range.

The managed hook scripts and registrations in each agent’s config home belong to the owner: neither the installer wrapper, pohunek service upgrade nor daemon start rewrites them. A release can change them without changing POHUNEK_INTEGRATION_VERSION, so after every upgrade compare the installed assets with the ones the new daemon embeds:

  1. Run pohunek integration doctor on the daemon host (add --profile <name> or --all-profiles for a host profile’s own config home). It exits non-zero when any asset is outdated. An asset is outdated when its version marker differs from the expected one and also when its bytes differ from the embedded script at the same version, reported as asset_modified with the install command as remediation. Without --agent it covers only the runtimes the daemon serves, so run it with --agent claude or --agent codex on a host where that package is not installed.
  2. Reinstall each agent it names: pohunek integration install --agent claude and --agent codex (with the same --profile or --all-profiles selector). The install replaces the managed files atomically and keeps unrelated user hooks.
  3. For Hermes, run pohunek integration doctor --agent hermes --hermes-profile work (or --hermes-home <absolute path>; use the target you installed with). The asset_current check fails when the installed plugin was written by an older release even though no file was edited; integration status shows outdated=true. Run pohunek integration update --agent hermes --hermes-profile work with the same target; it needs --confirm-modified only when modified=true. A v0.33.0 Hermes plugin with a 3..=3 policy can continue using its tools through the current CLI’s 3..=4 protocol window, including its old --runtime-id flag and timed-out start recovery. The update refreshes the plugin assets and policy to the current shape; it is not required solely to restore tool access during that window. A policy outside the window is rejected as pohunek_cli_incompatible and needs an explicit update. An upgrade never rewrites a user-modified Hermes plugin or policy.

The installer wrapper prints these commands after a successful install or upgrade. A new, resumed or forked session whose agent has outdated Claude or Codex hook assets carries a session warning of kind hook (shown by session new, session list and session inspect) that names the exact install command; the session still starts. Files in a user-owned config home change only by an explicit owner command, never as a side effect of an upgrade or a launch.

During a staggered multi-host upgrade a CLI of the current release may talk to a daemon of the previous release on another host over NetBird. Nothing has to be upgraded in one pass for that pair. The CLI and Rust client advertise the protocol window 3..=4; the older daemon answers in protocol 3 and the client translates requests, results and subscription events through the same shape-only adapter the daemon uses. The CLI’s --json output uses the current shape plus the old runtime-identity spelling at typed session sites while v3 remains supported. The command pohunek host inspect <host> --json reports protocol_version 3 for such a host, pohunek host list shows it as reachable, and multi-host commands include it.

A method added in the current release does not exist on the older daemon. The client refuses it before sending anything, with daemon/daemon_protocol_too_old, whose message reads “host ‘’ runs protocol 3, but package.list needs protocol 4”. The recover hint names the fix: upgrade the pohunek install on that host, then retry. Today the package.* methods are the only ones in this class; the rest of the public methods work across the pair. A payload the adapter cannot translate fails with daemon/version_translation_failed instead of being passed through; upgrade the host the same way.

The TypeScript SDK (and so the web and desktop UIs built on it) behaves the same way: it advertises protocol 3..=4, translates a protocol 3 daemon’s payloads, and refuses a method new in the current release with the same typed daemon_protocol_too_old error (ClientError.kind === "daemonProtocolTooOld", with tooOld.host, tooOld.daemonVersion and tooOld.requiredVersion). Only a peer two releases apart still has to be upgraded: the window is exactly one release wide and the client is refused with daemon/version_mismatch.

The contract is that release N stays compatible with release N-1 for live workers (private worker protocol versions PREVIOUS_VERSION and CURRENT_VERSION), for the worker journal, and for public-protocol clients; nothing else is shimmed. The current state differs for the last two. The worker journal reader accepts an explicit list of schemas, which today holds only the current one. The public protocol window is 3..=4: protocol 3 clients and tooling keep working against a daemon of this release, and the CLI and Rust client of this release work against a protocol 3 daemon, as do TypeScript SDK clients (#527); a peer two releases apart is refused. The daemon’s persisted state, <data_dir>/metadata.jsonl, does migrate from any older kept schema, so skipping releases is safe for it. Each release’s notes name every schema or protocol constant that changed (STORE_SCHEMA_VERSION, PROTOCOL_VERSION, MIN_PROTOCOL_VERSION, the worker protocol CURRENT_VERSION and PREVIOUS_VERSION, WORKER_JOURNAL_SCHEMA_VERSION, EXPECTED_INTEGRATION_VERSION); read those lines before updating.

A session launched from a host profile by a previous release that did not freeze the profile revision has none recorded, so a plain pohunek session resume <id> of it fails with agent_profile_changed (“no recorded revision”). Check the profile first, then resume it with pohunek session resume <id> --accept-profile-change: the relaunch uses the current profile and freezes its revision, and later plain resumes work. The local daemon honors the flag; a remote host refuses it. The upgrade’s own profile pins are not such a change: a built-in session whose profile differs only by the package and digest keys the upgrade writes resumes with a plain resume, and a refusal of that resume is a defect to report.

CI proves the window on every change to the daemon, worker, protocol, or CLI crates and on every release tag: the upgrade workflow stages the previous release’s published binaries, whose host profiles run the built-in Claude and Codex runtimes with the release’s managed hooks. It starts a stopped and a live Claude session, then runs pohunek service check and one pohunek service upgrade of the new build without --accept-runtime-loss. The step installs the release’s official packages, pins every host profile to them, and reinstalls the hooks. The test asserts that the live session is adopted by the same worker and keeps screen, input, hook reports and notifications working, that the stopped session resumes with a plain resume, which must succeed, onto the package digest its profile names, that stop and a plain resume of the adopted session relaunch through the package, that new Claude and Codex sessions launch through the packages, and that the previous release’s own CLI still talks to the new daemon. A failure there means a live session would not survive the upgrade or a profile would not launch; fix it before releasing. The run keeps the previous release’s store and protocol outputs as the upgrade-goldens-<tag> artifact.

On its first start after an update the daemon migrates an older store before it serves anything and first copies the original to <data_dir>/metadata.jsonl.pre-schema-<old>, where <old> is the oldest schema it found (owner-only; an existing backup is never overwritten). Keep that file until the updated daemon has run correctly for a while, then delete it (pohunek service uninstall --purge removes it with the store); to roll back, stop the daemon, put the old binaries back, and copy the backup over metadata.jsonl.

A daemon that finds a store written by a newer release refuses to start with a StoreSchemaError (“has schema version N, newer than the schema version M this daemon supports”) and leaves the store untouched. This happens after a downgrade or after running an older binary against a newer data directory. Recovery: install the newer release again, or restore the metadata.jsonl.pre-schema-<old> backup taken by the migration (losing the changes made since it). A store older than every migration the daemon keeps is refused the same way; migrate it with an intermediate release.

  1. Download the component archive for the binary being updated: CLI (pohunek), daemon (pohunekd, pohunek-sessiond, and the pohunek CLI that installs them as a native service).

  2. Run pohunek doctor --json to confirm the current binary can find required paths and state directories.

  3. Run pohunek health --json to confirm the daemon responds with the expected version and protocol compatibility.

  4. Run pohunek host inspect local --json to inspect local runtimes and capabilities.

  5. Review config changes before applying pohunek setup config --force; default setup config should not overwrite existing files.

  6. Attach reads its reconnect settings (attach_reconnect_seconds, attach_reconnect_interval_seconds, attach_reconnect_max_attempts) only from <config_dir>/attach.conf; a value left in launcher.conf is silently ignored. If you tuned those keys, copy them into attach.conf (pohunek setup config creates a template with every key commented at its default).

  7. Upgrading from a release that installed launchers: core owns no launcher assets, and it deletes none, because you may have customised them. Remove the files the former launcher scripts and sway drop-in setup steps wrote, or keep them by installing the launchers from zajca/pohunek-work, whose setup command owns those scripts and keybindings from then on. The paths are the same on Linux and macOS (XDG_DATA_HOME and XDG_CONFIG_HOME when set, otherwise ~/.local/share and ~/.config):

    Terminal window
    for name in lib.sh pohunek-rofi pohunek-rofi-issue pohunek-launch-issue pohunek-launch-pr; do
    rm -f "${XDG_DATA_HOME:-$HOME/.local/share}/pohunek/bin/$name"
    done
    rm -f "${XDG_CONFIG_HOME:-$HOME/.config}/sway/config.d/pohunek.conf"
    swaymsg reload

    Run swaymsg reload only on a host that runs sway. A bare pohunek setup on macOS never wrote the sway drop-in and skipped the scripts, so only an explicit launcher scripts install leaves files there. The old drop-in bound $mod+p and $mod+i to the removed scripts; drop any include line for the config.d directory that you added only for it. Move the three attach_reconnect_* keys from launcher.conf into attach.conf as in the previous step; core never reads launcher.conf, so leave or delete it.

  8. For important projects, verify pohunek project show <id-or-label> --json and resolved actions with pohunek project actions <id-or-label> --json.

For the Hermes M2 runtime, inspect the hermes entry after upgrade. Only the official Hermes package serves it, so install that package from the new release’s daemon archive first (pohunek plugin install runtime/packages/hermes.tar.zst --catalog runtime/runtime-catalog.json --yes). It is launchable only with version: "0.20.0" and supported: true; a missing binary has no version-policy result, while a wrong or unparseable version is reported as unsupported. The model-free compatibility check, cargo xtask hermes compatibility --bin-dir ABS --package ARCHIVE, needs the pinned Hermes executable on PATH, an absolute directory ABS of canonical built pohunek, pohunekd, and pohunek-sessiond executables, and the Hermes runtime package archive built by scripts/build-hermes-package; it installs the plugin from that package and validates committed evidence. Refresh goldens with cargo xtask hermes refresh-goldens --hermes-bin ABS, where ABS is the absolute path to the pinned Hermes executable. The harness runs that real Hermes process in a real PTY but replaces the model API with a repository-owned, deterministic IPv4-loopback mock. It requires no provider credentials and incurs no provider cost. Credential-source suppression normally produces no Copilot startup probe. If the pinned background exchange still starts, the mock admits at most its three-attempt budget of CONNECT api.github.com:443 requests plus a three-attempt CONNECT api.githubcopilot.com:443 fallback budget. Fast process shutdown may shorten those probes or interleave them with scenario traffic. The mock validates the two exact request lines and matching Host headers, returns HTTP 403 before TLS begins, and therefore receives no authorization header or token. An over-budget attempt, any other CONNECT, extra header, or absolute-form external request fails closed. Each of the six model-bearing classic scenarios must then make this exact localhost sequence: five ordered detection GETs to /api/v1/models, /api/tags, /v1/props, /props, and /version, each receiving a deterministic HTTP 404; then exactly one POST /v1/chat/completions. Discovery is not cached across those processes. The isolated config statically pins pohunek-compat-v1, context_length: 64000, and discover_models: false, so Hermes does not request /v1/models and the mock does not permit that path. The isolated home is preseeded with fresh models_dev_cache.json and cache/model_catalog.json files, and remote model-catalog refreshes are disabled. Its isolated auth.json suppresses every Copilot credential source, including the gh auth token fallback. A repository-owned noncredential value is selected before that subprocess; its pinned three-attempt token exchange is the locally denied probe described above. An unreachable isolated D-Bus address also prevents child processes from opening the operator’s desktop keyring. Harness-owned HTTP(S) proxy variables point at the loopback mock and exempt only localhost. The exact denied Copilot probe is the only admitted non-local proxy authority and never opens a tunnel. This is a fail-closed application-level defense, not OS-level network containment. Exact response evidence is the pinned streaming response frame’s ordered rounded header, exact content, and rounded footer render events across prompt-toolkit redraws. The prompt-ready and exit classic scenarios issue no model API requests. The mock validates this application-level sequence, the POST model and last user prompt, and the terminal tool where required. The refresh uses an isolated home without reading the real Hermes home or state.db. Review every refreshed fixture and leave no pending golden records before release. The refresh also sets HERMES_SKIP_NODE_BOOTSTRAP=1 and gives only the TUI process an empty isolated PATH. A missing Node/npm runtime is recorded as the recognized local unsupported state; the harness must never install TUI dependencies or contact a package registry. Classic terminal-tool captures keep the normal executable path for their exact repository-owned commands.

Do not downgrade a host from M2 to M1 after it has persisted a Hermes session. M1 can preserve unknown provider values neutrally on the wire, but it cannot operate the M2 Hermes runtime or safely rewrite its persisted launch identity. Recover by upgrading forward to the matching M2-or-newer component set.

For a daemon archive upgrade, run its installer (packaging/install-daemon.sh, which runs pohunek service upgrade --from <archive-dir>) rather than replacing only pohunekd. Before it changes anything, the upgrade runs the upgrade preflight with the new binaries and refuses when a live session would not survive the switch. It then copies the new binaries and the release’s catalog trust anchor into their own <prefix>/libexec/pohunek/<version>/ directory (the daemon reads the anchor only at startup, so the restart below is what makes a new anchor effective), switches active_version in service.toml, rewrites the daemon job to the new version, and restarts only the daemon. Worker jobs are separate native jobs (systemd transient units or launchd jobs, one per worker generation) that keep running the versioned pohunek-sessiond they started from, so their PID, PTY, and child PID remain unchanged. Version directories that a live worker journal, a registered worker job (even one that has not started its process yet), or a running process still references are kept; the others are removed and listed in the upgrade report. When worker jobs cannot be discovered (including when a worker unit or launchd definition of this installation is malformed), a worker journal cannot be read, or a journal written by an older pohunek under an earlier journal schema names a worker that may still run, every version is kept. Rerunning pohunek service upgrade for the already active version restarts nothing but repeats this cleanup, so a version kept earlier is removed once nothing references it any more. This cleanup sees only the journals and worker jobs of its own installation namespace, so a prefix belongs to exactly one namespace (<prefix>/libexec/pohunek/installation_owner): an upgrade, uninstall, or cleanup of another namespace fails with service_prefix_owned before it changes anything. The owning installation’s uninstall releases the prefix. If the named namespace no longer has an installation (for example its state directory was deleted by hand), delete that record and rerun. A version directory that already exists is reused only when it and its binaries are owned by you, mode 0755, free of symbolic links, and single-linked; otherwise the command fails with service_version_untrusted naming the offending entry. Make sure nothing runs from it, remove it, and rerun. The installer counts the daemon as ready only when daemon.health reports the new version on a connection whose kernel peer credentials name the daemon job’s running main process, as the service manager reports it. A manually started daemon of the same build that holds the socket while the supervised job crash-loops therefore never makes the step ready: the command keeps polling and then fails with service_daemon_not_ready, whose detail names both processes (daemon socket is served by pid X; the supervised job runs pid Y, or has no process). Stop the stray daemon and rerun the command. After health returns:

  1. Compare pohunek service status --json before and after the upgrade for an important live session: its workers entry keeps the same generation and pid, and versions still lists the old version as referenced.
  2. Inspect that session and confirm the same worker_id and worker_instance_id.
  3. Treat runtime.state=incompatible, conflict, or lost as a diagnostic state. Do not restart or kill the worker merely to make the status disappear.
  4. If concurrent reconciliation or lifecycle work returns runtime/session_runtime_commit_stale, refresh the session with pohunek session inspect <target> --json. The losing operation was not published; retry only from the runtime identity, decimal generation, and state now reported as authoritative. This code is not a post-rename durability warning: the daemon internally logs and applies a commit whose rename succeeded but parent-directory sync remained uncertain.

An interrupted install or upgrade is journaled in ~/.local/state/pohunek/service-install.json; running the same command again resumes it, and a different command rolls it back first — but only while the transaction has not reached its registering step, because only then can nothing have been registered. pohunek service status --json reports it as pending_transaction. A transaction that passed its config step may already run its daemon with live workers, so it is never rolled back directly: an interrupted install met by pohunek service upgrade, or by service install with another version or prefix, fails with service_install_pending and changes nothing, and pohunek service uninstall removes an interrupted install — or an interrupted upgrade — through the full session-checked uninstall instead (--stop-sessions stops the live sessions), without restoring the previous daemon. Whether the upgrade’s daemon was replaced or the replacement was interrupted before its service-manager call makes no difference: the uninstall reaches the daemon that actually serves the supervised job, as long as its version is one the configuration or the pending transaction proves belongs to the installation — the active one, or the previous version of a pending upgrade at or after registering. A job actually serving anything else fails at once with service_daemon_version_unexpected, before the uninstall touches a session — even when no session or worker is live at all. Rerun pohunek service install (or packaging/install-daemon.sh) to finish it: the same version and prefix resume. The same rule covers an install whose step fails at or after registering without an interruption: a service-manager call can time out after it really registered the daemon job, and that daemon may already own live sessions, so the install keeps its record, daemon job, and service.toml and fails with service_install_incomplete instead of rolling back. Rerun pohunek service install with the same version and prefix to finish it, or run pohunek service uninstall to remove it after its session check. An upgrade that fails before ready still rolls back to the previous version. A rollback journals that it has begun before it changes anything; if it fails or is interrupted partway (for example the restored daemon never becomes ready), the record stays and the next install, upgrade, or uninstall finishes the rollback first — rerunning the same upgrade then starts it over instead of resuming it. packaging/install-daemon.sh asks pohunek service status --json first and runs service install whenever the pending transaction is an install, service upgrade otherwise when service.toml exists, and service install on a fresh host; a failing status query aborts it before anything changes. Only one service transaction runs at a time: each holds ~/.local/state/pohunek/service-install.lock, a second one fails with service_transaction_in_progress (status then reports transaction_in_progress: true), and a crashed holder’s lock is released automatically, so rerunning the command is always safe.

pohunek service lock -- <command> [args...] holds that lock while it runs <command> and exits with the command’s status (128 plus the signal number when a signal ended it). The lock stays with that process: it records itself and a fresh random token in the owner-private ~/.local/state/pohunek/service-install.lock.holder and passes the command only the token, in POHUNEK_SERVICE_LOCK_TOKEN. Every pohunek service install, upgrade, uninstall, check, or nested lock the command runs adopts the lock with that token instead of waiting for it, while any other service command is refused with service_transaction_in_progress until the command exits. Adoption requires the lock to be held, the holder record to carry the same token, and the recorded holder process to still run; anything else fails with service_inherited_lock_invalid, never with a lock of its own. Each adopting command also holds ~/.local/state/pohunek/service-install.lock.adopted shared for its whole run. When the command exits, the holder waits until every adopter it left running has finished, then removes its record and releases the lock; a command that tries to adopt only after that is refused. If the holder dies first, adopters still running keep every other transaction out with service_transaction_in_progress until they end. Adopting commands that run a transaction also hold service-install.lock.inherited exclusively, so two of them under one holder never run at once: the second fails with service_transaction_in_progress. The command runs in a process group of its own and, when the lock process owns the terminal, in the terminal’s foreground; SIGTERM, SIGINT, and SIGHUP sent to the lock process or its group are forwarded to the command’s group, so each reaches the command exactly once. On a terminal it behaves like a job: Ctrl-Z stops the command, the lock process takes the terminal back and stops too, so the shell reports the job stopped, and fg (or SIGCONT to the lock process) gives the terminal back to the command and continues it. One signal received while the lock process waits for adopters ends that wait. If the lock process cannot tell whether an adopter still runs, it removes its record, releases the lock, and fails. pohunek service check [--prefix <dir>] [--json] runs, without changing anything, every check the install (while an install is pending or nothing is installed) or upgrade of this version makes before its first effect — HOME and the XDG roots, the prefix, every directory it writes, a pending transaction it would refuse, the recorded installation, the prefix owner, and, for an install that starts over, a daemon job registered without service.toml (service_daemon_job_present) — and fails with the error and code that command would fail with. For an upgrade it also runs the upgrade preflight and accepts --accept-runtime-loss. packaging/install-daemon.sh runs its whole legacy retirement and the final service install|upgrade under pohunek service lock -- <installer>, and runs service check before it touches the legacy install.

pohunek service uninstall refuses while sessions are live and lists them. --stop-sessions stops every session through its worker first; the session store, journals, and host identity are kept unless --purge is given. The prefix ownership record is removed after the prefix’s version directories and CLI copy (it stays while a version is kept for a worker journal or job of this installation). The transaction record (service-install.json) is cleared just before service.toml, which is removed last, so if an uninstall fails partway or is interrupted, rerunning pohunek service uninstall finishes the cleanup. A pending install or upgrade that reached its registering step is uninstalled through the same live-session check even when its service.toml is already gone: the record’s version and prefix identify the installation. For a pending upgrade this restores no previous daemon and runs no rollback: the previous reader may already be incompatible with the migrated store and uninstall exposes no --accept-runtime-loss, so uninstall --stop-sessions --purge removes the pending upgrade and the installation even when pohunek service upgrade to the previous version would be refused with service_rollback_store_unusable; without --stop-sessions it still refuses with service_live_sessions while any session is live. A pending install that stopped before registering never started its daemon, so uninstall rolls it back; with --purge it then purges the durable metadata too (refusing with service_daemon_job_present while any daemon job is registered, and with service_orphan_workers while a worker still runs), and it keeps the transaction record until that purge finished, so a rerun resumes it. Without an installation or a pending install, uninstall --purge fails with service_not_installed and purges nothing.

A worker journal written by an older pohunek under an earlier journal schema blocks the uninstall with service_outdated_journals while its worker may still run: its recorded phase is not final and its recorded worker process is running or cannot be identified. The error lists each journal with its schema version and worker pid. This daemon cannot stop such a worker, so --stop-sessions does not help: end those sessions with the pohunek version that started them, or terminate the listed worker pid, then rerun the uninstall. A journal with no readable worker pid keeps blocking until you delete it, after confirming that no older pohunek-sessiond is still running. Before service upgrade, service uninstall, or service status touches anything, it verifies service.toml against the running user and the canonical XDG_STATE_HOME and XDG_RUNTIME_DIR roots; a moved root or another user fails with service_config_invalid naming the differing key, so the command can never address another installation’s jobs or socket, and status --json never reports the recorded namespace and prefix beside another environment’s transaction store.

Before any effect, including rolling back a pending transaction, service install, upgrade, and uninstall also refuse a --prefix that service.toml would reject (cli_usage: relative, . or .. components, repeated or trailing /, non-UTF-8, or too long; pass the one normalized spelling) and an XDG_* root or HOME that is not UTF-8 (service_environment_not_utf8), which the daemon’s job definition cannot carry. install and upgrade additionally require HOME to be set (missing_env) and every bootstrap root to be an absolute normalized path, with HOME an existing directory (service_environment_invalid): the daemon starts each session worker in HOME and refuses to become ready without it, even when --prefix and every XDG_* root are given explicitly.

The first worker-aware release is a destructive compatibility boundary because a legacy daemon cannot transfer an already-open PTY. Let all legacy sessions finish before installing. The installer moves the legacy daemon’s control socket aside, runs migration preflight --socket <moved-socket> against it, and only then stops the daemon and re-checks template workers in every state except inactive; any surviving worker aborts without removing legacy files. --accept-runtime-loss is informed consent to lose those existing PTYs and to proceed after the post-stop check, not a recovery command. See debug session runtime. The same flag is the one consent for the service-managed upgrade below; there is no second flag.

When the assistant feature is available, its update intent should use bundle version metadata and changed_in frontmatter to explain version-specific changes before recommending edits.

pohunek service upgrade and pohunek service check of an existing service run a read-only preflight with the new binaries before any effect: the new pohunek runs the new pohunekd upgrade-preflight, an internal command of the one release archive that prints a single JSON report. It reads the metadata store (migrated in memory only), the worker journals and the process table. It takes no lock and writes, signals and stops nothing, so it is safe while the previous daemon runs. It does not connect to worker sockets, because the running daemon holds each worker’s single controller slot; it judges the identity, subagent state and protocol a worker would report from what its journal recorded, with the same functions adoption applies (including the check that the launch and active identity processes are still in the PTY tree, launch_identity_process_invalid, active_identity_process_invalid, identity_process_root_missing; a process table that cannot be read fails closed with identity_process_inspection_failed). A record with a pending stop, removal or undelivered create is reported too (stop_or_removal_intent_pending, create_rolled_back), because startup finishes those instead of adopting. The real blind spots are what only a service manager or the live socket can show: the supervisor job’s identity and socket failures at connect time. A worker that journals no hook schema (a previous-release worker) is validated with the schema its runtime declares, read without side effects from the built-in runtime definitions and the installed package store; a live agent session that reported its active identity is therefore adoptable. Only a runtime whose definition cannot be read leaves the schema unavailable (worker_identity_unverified). The previous daemon has no facility that pauses session creation, so the upgrade runs the preflight twice: once before any effect, and again after staging, immediately before the daemon is replaced. A session created in between is caught by the second run; the window that remains is the replacement itself, where the new daemon’s own adoption decides and fails closed (conflict or lost, with a WARN and a recovery hint). An interrupted upgrade whose daemon replacement has not completed (including one interrupted just before the service-manager call) is judged again when it is resumed, so state that changed meanwhile needs consent. A refusal never touches the running daemon: one that happens before the replacement is rolled back without restarting it, and one at a resumed upgrade that may still replace it keeps the transaction, so rerunning with --accept-runtime-loss (or after the sessions end) resumes it. A rerun for the already active version skip it, as does a resume after the replacement.

Cancelling an interrupted upgrade before the daemon registration step usually leaves the previous daemon in place. A configuration migration stops it earlier, before the rewrite; rollback from that stop_daemon step or later must restart it. Before such a restart, rollback judges the previous daemon against the current store and live workers before changing the service. A previous daemon with upgrade-preflight judges its own reader and adoption; v0.33.0 and v0.33.1 have no such command, so only their schema-1 store is admitted, and each record must deserialize the way those releases read it: a line their reader would silently skip on its next whole-store rewrite — a known record kind with a missing or ill-typed field, or an agent kind that is not a runtime id — refuses the rollback before anything changes. Live worker recovery needs --accept-runtime-loss. A newer, corrupt, or unreadable store refuses rollback even with that flag. The installer stops the new daemon and judges again before starting the old one, so a write during the first check cannot become an unsafe downgrade. If that second check refuses, the daemon remains stopped and the transaction remains for recovery. service check applies the same read-only judgment; successful --json reports include the rollback preflight and accepted_runtime_loss in rolled_back. Restore the schema backup after stopping the daemon, or keep the newer release installed, to recover from an unreadable store. Worker journals that cannot be read are never treated as “no live workers”: a journal root that cannot be scanned yields an evidence_unavailable entry, and while a store line that names no session cannot be read, an unreadable or unsupported journal of an unknown session is listed as at risk. service uninstall keeps its own --stop-sessions rule and removes a pending upgrade at or after registering without restoring the previous daemon, as described above.

The report has two parts. The store part is the dry-run startup migration: missing, up_to_date, would_migrate (with the schema it migrates from and to, and the record count) or refused. A refused store (newer than the binary, no migration path, an invalid schema_version, a migrated body over the 16 MiB store limit, or unreadable) means the new daemon would not start. The upgrade then fails with service_upgrade_store_unusable, and --accept-runtime-loss never overrides it: fix or restore the store, or install the matching newer release.

The sessions part lists every live session with one verdict and a stable reason code. A session whose stored runtime is already lost or terminal is not listed: the upgrade is not what ends it.

  • adoptable: the new daemon adopts the worker and keeps its native recovery. A session launched from the built-in Codex, Claude Code or Hermes of an earlier release is adoptable when the official package that serves its runtime id is installed and adopts it, live or stopped (its pin names that package id; the package version may differ). Resume and fork then run through the package. A stopped session launched from an official package is adoptable_by_newer_package when a strictly newer version of the same package adopts it. The detail names both versions. See “Stopped sessions on an older official package version” below.
  • would lose recovery: the worker is adopted but the session cannot resume natively (native_recovery_unmappable, resume_record_unreadable, native_recovery_unverified, runtime_source_not_served). A binding that needs the runtime registry to complete its launch spec is native_recovery_unverified, because the store migration cannot consult the registry. runtime_source_not_served means the session’s launch pin names a runtime source the new binary does not serve and no installed package adopts: a built-in runtime it does not ship, or a package digest that is not installed. Resume and fork resolve exactly the pinned source, so nothing else stands in and no command rebinds the session. Finish the work in the live session, then continue in a new one with pohunek session new --agent <runtime> (the detail names the runtime), or accept the loss. When the session’s runtime resolves nowhere on the new binary (the built-in Codex, Claude Code or Hermes without the official package, or a package that is not installed), the session is reported this way even if it cannot resume, because every interaction with it (input, attach, resize, rename, metadata) would be refused too: install the runtime first (the detail names the official package install), then finish the work. A session whose host profile resume would refuse is reported with resume’s code (agent_profile_missing, agent_profile_changed, agent_profile_revision_unavailable): restore the profile, or accept the loss. A stopped session record the new binary cannot load is record_unreadable in the stopped list; its recovery is lost at startup.
  • already not resumable: a stopped session whose conversation is absent from its declared store (agent_native_reference_missing), or whose stored reference is malformed, is refused by resume on the running release too. The upgrade does not change that, so it needs no --accept-runtime-loss; the summary counts it apart from the sessions at risk. A relocated store (agent_native_store_relocated) or a store-condition refusal is would lose recovery with resume’s code: the running release may detect those differently, so the preflight cannot prove it refuses too. A reported reference keeps its check only where the preflight can prove it identical: a session pinned to a package digest, or a built-in that a 0.34.0 to 0.34.2 store (schema 5) adopts into the official package whose check equals the v0.34.2 golden. Any other session the upgrade adopts from the built-in runtime is would lose recovery, with the same code and a detail that names the reason. Sources outside those are fail-closed, so the verdict is would lose recovery there.
  • would not be adopted: the new daemon would not manage the worker (record_unreadable, worker_without_session_record, store_unusable, worker_generation_missing, worker_journal_schema_unsupported, worker_journal_unreadable, worker_journal_missing, worker_journal_ambiguous, worker_already_ended, worker_not_running, worker_process_unverifiable, runtime_identity_mismatch, worker_generation_not_advanced, worker_child_missing, worker_not_initialized, worker_protocol_incompatible, worker_marker_legacy). Evidence the preflight cannot read counts against the session. worker_marker_legacy means the worker journal names a release before 0.33.1, or none: that worker marks its children without POHUNEK_WORKER_INSTANCE_ID, so the new daemon could never sweep or report them and refuses to adopt the worker. Stop the session with the running release before upgrading, or accept losing its runtime. worker_without_session_record applies to a live worker journal with no store record only while a store line that names no session cannot be read, because that line may be the worker’s own record.

A live worker journal with no store record and no such unreadable line is listed under unmanaged_workers: the running daemon does not manage that worker either, so the upgrade leaves it as it is and does not refuse.

When any session is not adoptable, the command exits non-zero with service_upgrade_sessions_at_risk and lists each session with its code and detail. Let those sessions finish (or end them) and rerun, or pass --accept-runtime-loss after reviewing the list. With the flag the upgrade proceeds, and the --json report carries the full preflight object and accepted_runtime_loss: true. A preflight that cannot run at all (missing or failing pohunekd, an unparsable report) fails with service_upgrade_preflight_failed, which the flag does not override.

On Linux the preflight and the runtime step run with the PATH of the systemd user manager, read from its Environment property over the session bus, unless the recorded daemon job carries its own PATH, because the daemon job inherits it. If that PATH cannot be read, the command stops with service_manager_environment_unavailable before any effect; the flag does not override it.

A job that records its own PATH still takes store variables such as CLAUDE_CONFIG_DIR from the service manager. When that manager cannot be read (no reachable user bus on Linux, no launchd gui/<uid> domain on macOS), service check warns that native references were judged at the default store locations. When that judgement covers a stopped session, service upgrade or a rollback stops with service_manager_variables_unread before any effect; make the manager readable and rerun, or pass --accept-runtime-loss to accept that a session judged that way may lose its recovery. Without stopped sessions the upgrade proceeds with the warning.

Sessions whose runtime has ended but that keep native resume or fork are judged by the same rules and listed under stopped sessions (stopped_sessions in the --json report, and a stopped section in the text output). A stopped session that would lose its resume or fork is at risk and needs --accept-runtime-loss like a live one.

When the preflight budget (20 seconds by default) runs out before a stopped session’s native reference is checked, the session is would_lose_recovery with reference_unverified_within_budget, and its detail gives how many sessions are unverified this way. Run pohunek service check --preflight-budget <SECONDS> (up to 300), then pohunek service upgrade --from <archive dir> --preflight-budget <SECONDS> with the same value; the CLI’s preflight deadline grows with the budget. The installer (packaging/install-daemon.sh) passes no budget, so an installer user runs the archive’s own CLI directly: <archive dir>/pohunek service check --preflight-budget <SECONDS>, then <archive dir>/pohunek service upgrade --from <archive dir> --preflight-budget <SECONDS>. Unlike the wrapper, a direct run does not take the service transaction lock around both commands and does not print the hook asset step afterwards (pohunek integration doctor, then pohunek integration install --agent <agent> for each outdated agent). --accept-runtime-loss accepts such sessions only after the check has been reviewed.

Official runtime packages during an upgrade

Section titled “Official runtime packages during an upgrade”

The preflight also carries the release’s official runtime plan, from pohunekd ensure-official-runtimes --dry-run run against the release’s runtime/ tree before any effect. The plan lists the packages the host uses with the reasons (profile, session, integration), the host profiles it pins, the integration hooks it reinstalls, and every refusal. Live and stopped sessions are judged against the runtime registry as it will be after the plan is applied, so a host on an older release whose sessions the release’s packages adopt is not refused for them. service check and service upgrade print the plan as plan lines and each refusal as a refused line.

A plan with refusals (for example, no package for this platform) fails with service_upgrade_runtimes_refused before anything is stopped, and --accept-runtime-loss does not override it. The error lists every refusal. On a fresh upgrade the early gate refuses it before the binaries are published; a resumed upgrade, or a plan that changes between the two gates, is refused at the later gate, which keeps the transaction for a rerun or rolls it back. A plan that changes something is applied after the old daemon stops: the package registry, the catalog state and the host profiles are backed up owner-private first, and the backup is restored when anything fails. Once the new daemon is ready, the integration hooks the release lists are reinstalled. A failure there is reported and does not roll the upgrade back. A marked Hermes home is updated with the hermes executable on the daemon job’s PATH, the one the runtime plan ran with, not the PATH of the shell that runs the upgrade. A Hermes home without the pohunek marker is reported as a gap with the command that installs it; nothing is written into it.

The manual migration below remains the fallback for a host that cannot run the upgrade’s runtime step, for example to move its sessions before an upgrade.

A host whose previous release installed the packages itself (its own service install pinned its profiles to them) gets the release’s packages too. The plan reports the release’s package as present when its digest is the one the host serves, and as install otherwise. A host profile pinned to an older version of the same official package is planned as repin and pinned to the release’s package by the apply (repinned). Its entry names the previous and the new version and digest. A pinned profile the step does not move is listed as kept, with a reason:

  • pinned_to_newer_version: the profile’s package is newer than the release’s;
  • pinned_to_non_official_package: a local or linked package;
  • pinned_to_other_package: another package id;
  • pinned_version_unknown: the pinned digest is not installed, or its version is not a semantic version;
  • pinned_to_same_version: another digest of the release’s version;
  • pinned_target_not_official: the release’s package is recorded as not official on this host.

In --json, a repin entry carries previous_digest, previous_version and version; a kept entry carries kept_reason, its pinned digest and, when the registry records it, previous_version. A package entry whose selected version is newer carries replaces_newer with that version’s digest and version. The report layout is report_version 2.

A kept profile changes nothing and does not refuse the plan. Move it with pohunek plugin profile migrate <name> --digest <digest> --yes when you want to.

When the selected official package of a runtime is newer than the release’s package (installed with plugin update from a later archive), the plan says that the upgrade re-selects the release’s lower version. The upgrade still does that. Select the newer version again afterwards with pohunek plugin select <package> --digest <digest>. Profiles pinned to the newer version keep their pin.

Stopped sessions on an older official package version

Section titled “Stopped sessions on an older official package version”

A stopped session launched from an official package is pinned to that package’s digest. On resume or fork, a strictly newer version of the same package adopts it, so a release that widens a package’s supported agent range also reaches sessions launched before it. All of these must hold:

  • both versions are official (recorded from the signed catalog) and serve the same runtime id under the same package id. A package installed by explicit digest or linked never adopts a session, even under the official package id;
  • the newer version is selected and enabled, and its root verifies;
  • the older version is still installed, and the newer one declares the same integration handler, hook schema and native reference check;
  • the newer version honors the launch the session froze: the same reference kind, the same assigned reference strategy, and fork support when the frozen launch forks;
  • for a session launched from a host profile, the profile now pins the newer version (the upgrade repins it, see above). A profile still pinned to the older version keeps the session on it.

This covers Pi too. service upgrade does not repin Pi profiles, because the upgrade’s runtime step manages only claude, codex and hermes. After pohunek plugin update pohunek.runtime.pi <archive> --catalog <catalog> --yes, move a Pi profile with pohunek plugin profile migrate <name> --yes. Its stopped sessions are then adopted on their next resume or fork. A session launched as the bare pi runtime follows the selected version.

A newer version that fails one of these checks does not break the session. When the upgrade repinned its profile to that version, the session still resumes from its frozen digest, as before the upgrade. The profile counts as unchanged when only its pin moved to a strictly newer official version of the same package. The preflight reports the session as adoptable.

The relaunch keeps the frozen program and arguments. It probes the agent with the newer version’s version probe and records the newer digest as the session’s pin. A lower version never adopts a session, and neither does another digest of the same version (the registry holds one digest per version). A live session is never adopted: it keeps being observed through the digest it runs.

“Official” means recorded from a catalog that the host’s single trust anchor verified. The registry does not record which anchor root or key authorized a package (#1200).

The apply is judged once more. After it, the new release’s preflight judges the live sessions again, with the same decision as before the apply, because the apply may select a package with another native contract. A session at risk, or a preflight that cannot run, rolls the apply back.

When the release carries no usable runtime catalog, an installed package that loads keeps serving its runtime: the step reports catalog: unavailable and the package as kept, with nothing installed and nothing refused. A runtime with no installed package that serves it still fails with release_runtime_catalog_unavailable. A plan whose only change is the release catalog’s trust state (catalog: record) stops and backs up the daemon like any other apply.

An interrupted upgrade leaves its transaction journal, service-install.json in the CLI’s state directory. On macOS it holds the launchd default PATH of the previous daemon job, in the optional launchd_default_path field, read from the loaded job when the upgrade started. Linux never writes the field, and a journal written without it has none.

A job without a recorded PATH gets, in launchd’s order: the domain’s PATH (set with launchctl setenv, readable whether or not the job is loaded); the loaded job’s default; for a job that is not loaded, the journaled default of the transaction; and only when none of these is available, the persisted launchd configuration. A rollback therefore judges with the PATH the restored job gets where launchd can still say so. The persisted-configuration case is reported as a warning: line that names the journal, because it is the best evidence available and not launchd’s answer. On Linux a job gets the systemd user manager’s current Environment PATH; the journal does not change that.

A journal written by an older CLI, or one whose job was not loaded when the upgrade started, has no journaled default; its rollback of a job that is not loaded reads the persisted configuration with that warning. A journal with the field is read by older CLIs as an unknown field, so an older CLI refuses it; roll it back with the CLI that wrote it or with this release.

Move Claude and Codex sessions to the official package before upgrading

Section titled “Move Claude and Codex sessions to the official package before upgrading”

The upgrade installs and pins the official packages itself (see above), so no manual step is needed before pohunek service upgrade. The steps below are the manual path to the same state, for a host that cannot run the upgrade’s runtime step or that must move its sessions before the upgrade.

A session records the runtime source it was launched from: a built-in runtime compiled into the daemon, or the digest of an installed runtime package. Resume and fork relaunch exactly that source. A release that does not serve it and has no installed package adopting it adopts the live worker but reports the session as would lose recovery with runtime_source_not_served. To keep native recovery across an upgrade to a release that does not serve the built-in Claude or Codex runtime, install the official package with the release that is running, before upgrading. The installed package adopts the built-in sessions, and the preflight reports them as adoptable:

  1. Install the official package from the running release’s daemon archive (v0.34.2 or later ships it under runtime/), authorized by the archive’s signed catalog:

    Terminal window
    pohunek plugin install runtime/packages/claude.tar.zst --catalog runtime/runtime-catalog.json --yes

    Use runtime/packages/codex.tar.zst for Codex. A catalog past its expires_at is refused; install from a newer archive instead.

  2. Pin every host profile with that base: pohunek plugin profile list shows the ones in needs_migration, and pohunek plugin profile migrate <name> --yes pins each to the selected package digest.

  3. Start new sessions for the work. They are pinned to the package digest. Sessions launched from the built-in are adopted by the installed package, live or stopped, and keep resume and fork through it when the package honors their recovery contract. Finish the work in a session the package cannot honor before the upgrade, or accept losing its native recovery.

    A release that compiles none of Codex, Claude Code and Hermes in (every release after 0.34.x) serves codex, claude and hermes only from their packages. A live Codex, Claude or Hermes session launched from the built-in without the installed package is refused every interaction with it (input, attach, resize, rename, metadata), whether or not it could resume (runtime_builtin_removed; the preflight reports it at risk and its detail says so). Installing the package in step 1 keeps such a session usable after the upgrade, and resume and fork run through the package when it honors the recovery contract. No release up to 0.34.x ships the Hermes package, so step 1 has no Hermes package to install: finish or stop built-in Hermes sessions before the upgrade, or accept the loss (--accept-runtime-loss) and install the Hermes package from the new release’s archive afterwards to finish their work in the live session. On a host that keeps no Codex or Claude Code, the hooks pohunek installed into ~/.codex or ~/.claude stay removable after the upgrade with pohunek integration uninstall --agent codex or pohunek integration uninstall --agent claude, which need no package. After the upgrade, install the hooks again only after the package: pohunek integration install --agent claude (or codex) is refused while no package serves the runtime.

  4. Run pohunek service check with the new release’s binaries: the sessions launched from the package are adoptable.

A later service upgrade installs the newer release’s package. It repins the profiles pinned to an older version of it, and stopped sessions move to the newer version on their next resume or fork (see “Official runtime packages during an upgrade”). Outside an upgrade, pohunek plugin update <package> <archive> --catalog <catalog> --yes with a newer release’s archive installs and selects its version for new launches. A profile moves to it only through plugin profile migrate. plugin update refuses a lower version than the selected one (plugin_update_downgrade) and a version that cannot be ordered (plugin_update_version_unordered); plugin select is the explicit rollback. The release upgrade test (scripts/upgrade-test, crates/cli/tests/release_upgrade.rs) runs the one-step path for Claude and Codex from the previous release to the build under test.

From 0.35 a failing pre-create or post-create hook’s stderr tail appears in the session.new warning, so the hook’s stderr is a pipe the daemon drains instead of /dev/null (hooks.stderr_tail_bytes, default 4096). A process a hook leaves running in the background with its stderr inherited (npm run dev &, also nohup cmd &, since nohup redirects only a terminal) keeps running while the daemon does. Two seconds after the hook ended, the daemon hands that stderr to a drain process (/bin/cat into /dev/null), so the process keeps running across a later daemon restart on macOS or without a service manager. Only a restart within those two seconds still gives it SIGPIPE on its next stderr write. If the drain cannot start, the daemon log names the hook:

WARN ... a background process still holds the hook's stderr and no drain process could take it; redirect its stderr or set hooks.stderr_tail_bytes = 0, or it gets SIGPIPE after a daemon restart event=post-create script=...

Then redirect that process’s stderr in the hook (>/dev/null 2>&1 or a log file), or set hooks.stderr_tail_bytes = 0 in service.toml to keep every hook’s stderr on /dev/null. Under systemd the daemon restart already ends such processes.

Claude profiles with a detection manifest override

Section titled “Claude profiles with a detection manifest override”

A Claude package waits for the prompt box before it writes a session’s initial input (input.ready = "screen_idle", evidence from an idle rule on a screen region). A host profile over Claude whose manifest override has no such rule, such as rules = [] or only working and blocked rules, keeps loading when upgrading from 0.34.2, and its sessions keep the bracketed-paste readiness of that release: the initial input is written once bracketed paste is on, and Claude may drop it. pohunek doctor lists each such profile as a warning (agent_profile_<name>). pohunek service check and the upgrade’s preflight list it too, under profile_warnings with the code profile_without_screen_readiness, judged as the new release loads it: the planned profile text when the release plan pins the profile, else the profile on disk, over the planned package when its pin names a digest the release installs. That warning never blocks the upgrade and needs no --accept-runtime-loss. To get prompt-box readiness, add an idle rule on the prompt_box_body region to the override, copying the package’s live_prompt_box rule.

Configuration migration from schemas 2, 3, 4 and 5

Section titled “Configuration migration from schemas 2, 3, 4 and 5”

When the installed service.toml was written by release 0.33.x (schema 2, no [input] or [waits] table), by a release with schema 3 (no [waits] table), by a release with schema 4 (no input.write_stall_timeout_ms), or by a release with schema 5 (no [hooks] table) and the target version differs, pohunek service upgrade turns into a configuration migration and the --json reports carry the source schema, config_schema_migration: 2, 3, 4 or 5. service check verifies a pending migration’s backup and reports the migration it would perform without writing anything, so an archive updater sees it before any effect. A same-version refresh runs no migration and reports null. The migration assigns the documented defaults of the missing keys and tables explicitly: [input] 5000 ms and 150 ms for schema 2, input.write_stall_timeout_ms 3000 ms for schemas 2 to 4, [waits] 300000 ms and 1800000 ms for schemas 2 and 3, and [hooks] stderr_tail_bytes 4096 for schemas 2 to 5. Every value the file records, including tuned [input] and [waits] timings of a schema-3, schema-4 or schema-5 file, is kept.

The ordering follows the schema boundary. The transaction first saves the exact pre-upgrade bytes owner-private next to its record and journals their SHA-256 digest; the daemon job is then stopped before service.toml is rewritten, because a previous daemon’s strict reader refuses a restart against the new file and its restart policy would crash-loop it. The new daemon is registered after the write, with the still-live session preflight judged while the old daemon runs. A live session keeps its worker PTY through the stopped daemon: the workers run in jobs of their own.

An interrupted migration resumes from its record after re-verifying the digest: while the transaction has not rewritten service.toml yet, the file itself must still be the exact pre-upgrade bytes; a stray file where the backup will be written is harmless, the transaction rewrites it from the verified bytes. A failing step rolls the migration back: the exact backup is verified before any daemon is stopped, and the previous release’s store reader and live-worker adoption are checked whenever rollback must restart it. A registered new daemon is then stopped, the store is checked again, the exact pre-upgrade bytes are restored, and only then does the previous daemon restart against its own file. An unreadable store refuses rollback even with --accept-runtime-loss. A missing or altered backup refuses both the resume and the rollback closed with service_config_backup_invalid: nothing is written, no daemon restarts, and the record stays for a repaired retry. The recovery is to restore both files to the bytes the record digests, or to run pohunek service uninstall, which checks live sessions before removing the installation.