Pohunek Public API
This document is the public API contract for the daemon control protocol and the Rust SDK surface that speaks it.
Status: versioned public API, pre-1.0 stability. Breaking changes are allowed before 1.0, but they must be reflected in the protocol version and this document.
Source of truth:
- Wire types and method constants:
crates/protocol - Rust SDK transport API:
crates/client - Assistant launch orchestration and host connection types
(
HostConfig,ConnectionOptions,connect_client,AssistantError):crates/assistant - Daemon dispatch behavior:
crates/daemon/src/api
Status: Shipped v4, Implemented Relay Foundation, and Deferred Relay Evolution
Section titled “Status: Shipped v4, Implemented Relay Foundation, and Deferred Relay Evolution”Shipped now: protocol v4 owner paths
Section titled “Shipped now: protocol v4 owner paths”Protocol v4 is the implemented contract. Local clients connect through the
owner-only Unix socket, direct remote clients connect through a configured
overlay such as NetBird, and browser clients can use a transparent WebSocket
relay speaking the transport contract described below (the web control center,
an external zajca/pohunek-work client, ships its own). All three routes retain
the existing owner trust domain. Such a relay maps one WebSocket to one
daemon connection; it is not a team service, authentication authority, state
aggregator, or public pohunek-relayd implementation.
Version 4 also ships host-local stable identity and safe governance inspection. It does not ship a relay connection, relay-local transport, enrollment or owner mutation RPC, team WebUI, share API, or public transfer API. Existing local, direct-overlay and owner-path browser relay paths remain owner-only and unchanged.
Implemented relay foundation outside protocol v4
Section titled “Implemented relay foundation outside protocol v4”pohunek-relayd is an independently configured HTTPS authority, backed by
PostgreSQL. It is not a pohunekd protocol-v4 endpoint and it does not add a
host link, routing, attach, or team WebUI. The implemented bounded surface
includes liveness/readiness, generic OIDC browser Authorization Code with PKCE
and device authorization, account and credential lifecycle, account linking, and
service-account credential lifecycle. It also has protected stopped-lifecycle
migrate, bootstrap, provision, and recovery commands.
The native pohunek relay commands use a separate HTTPS-only client and the OS
keyring for login, status, logout, credential rotate, and interrupted
rotation recovery. They bind a credential to one exact HTTPS origin. This is a
foundation surface, not a complete team-management or host-control API. The
account-linking routes below have no pohunek relay subcommand; they are
HTTPS-only.
Relay account-linking surface
Section titled “Relay account-linking surface”Account linking proves control of one additional stable OIDC identity and
attaches it to an existing relay account. A relay identity is exactly the issuer
plus the immutable subject. Email, display name, Google hd, GitHub login, and
every other provider profile attribute are attributes, never linking inputs, and
matching email strings never link accounts.
Wire types are exported by crates/relay-protocol, and JSON field names are the
Serde field names of those structs. Every route is served with no-store, sits
behind the existing relay authentication boundary, and accepts either a browser
session cookie or a bearer credential in one request, never both. A browser
mutation additionally requires the exact configured Origin and an
x-pohunek-csrf header matching the CSRF cookie.
| Route | Request | 200 response |
Notes |
|---|---|---|---|
POST /v1/account/links/browser/start |
AccountLinkRequest |
AccountLinkBrowserStart |
Browser session only; a bearer caller is rejected. Returns {link, authorization_url} and sets the one-use host-only __Host-pohunek-relay-login binding cookie, so the authorization URL alone cannot complete the transaction. |
POST /v1/account/links/device/start |
AccountLinkRequest |
AccountLinkDeviceStart |
Bearer credential only; a browser caller is rejected. Returns {link, authorization} where authorization carries the RFC 8628 verification_uri, optional verification_uri_complete, user_code, expires_at, interval_seconds, and the one-use poll_secret. The poll value is delivered once and is stored only as a keyed digest. |
POST /v1/account/links/device/poll |
AccountLinkPollRequest plus the x-pohunek-device-secret header |
AccountLinkPollResult |
Polled by link_id by the same bearer credential that started the transaction; the possession value travels in the header, never in the URL. The status tag is pending or slow_down with retry_after_seconds, complete with {link, identity}, denied, expired, or cancelled. |
GET /v1/account/links |
PageRequest query (after, limit) |
AccountLinkPage |
Read-only history for the calling account only, ordered by link_id ascending, with next_cursor for continuation. limit above 128 is rejected. No CSRF is required. |
POST /v1/account/links/{link_id}/cancel |
CancelAccountLinkRequest |
AccountLinkRecord |
Cancels one pending transaction owned by this account and closes its unconsumed provider row. An already cancelled transaction returns its current record, so a retry after a lost response is safe. |
POST /v1/account/identities/{identity_id}/unlink |
UnlinkIdentityRequest |
IdentityRemoved |
Removes one active linked identity and returns {identity_id, principal_id, removed_at, account_link_generation, revoked_credentials, revoked_sessions}. An exact retry of the same idempotency coordinate returns the committed result instead of removing a second identity. |
AccountLinkRecord is the safe revisioned view of one transaction: link_id,
principal_id, channel (browser or device), state (pending,
completed, cancelled, expired, or failed), source_identity_id,
linked_identity_id, revision, account_link_generation, created_at,
expires_at, and completed_at. Possession digests, provider tokens, and
verifier material never appear in it, and IdentityRecord carries only
identity_id, issuer, and subject.
A browser link completes through the existing GET /v1/auth/oidc/callback. That
callback must present both the one-use login binding cookie and the caller’s
current session cookie; it consumes the BrowserLogin row before code exchange,
redirects to account/ready, and issues no new session cookie for a link. A
device link completes on the poll that returns complete. Both channels use the
same PKCE and device validation rules as login, and a transaction is completable
only through the channel that created it.
Every transaction is bound at creation to the initiating principal, the source identity, the exact authentication row and its generation, the issuer, client and audience, a keyed digest of its one-use possession value, the digest key id, the account-link generation, the recovery generation, and a bounded expiry. A completion re-checks all of those against current durable state, so a rotated or revoked source credential, a replaced browser session, an advanced account-link generation, a changed recovery generation, or a cross-principal caller fails the transaction instead of linking.
PostgreSQL holds the durable guarantees rather than application code alone: one
pending transaction per account, one created identity per completed transaction,
uniqueness of (issuer, subject) across identities that are not removed,
monotonic account_link_generation, immutable transaction provenance, and a
terminal state that cannot be revived or rewritten without advancing revision.
A removed identity keeps its row so credential and browser-session provenance
stays resolvable.
A completed link advances the account’s account_link_generation. An unlink
revokes that identity’s credentials and browser sessions, cancels every pending
link transaction for the account, and advances both the account-link generation
and the principal generation in the same transaction as the audit record; an
audit or database failure fails closed and closes active access rather than
acknowledging an authority change. An account never removes its last identity.
An unlink removes that identity’s authority over the account; it is not a ban on the identity. The issuer and subject can authenticate again afterwards, and teamless authentication provisions a brand-new principal with no membership, so the returning identity authorizes no team resource and has no path back to the account it was removed from. Barring an identity from the relay is a separate administrative decision, not a consequence of unlinking. Restore quarantine cancels every pending link transaction and blocks link changes while the recovery generation is stale.
Failures use the relay’s HTTP error contract with a stable code:
| Status | code |
Cause |
|---|---|---|
400 |
invalid_request |
A transaction presented through the wrong channel, Authorization mixed with a cookie or Origin, a malformed body, or a page limit above the bound. |
401 |
authentication_required |
No current credential or session, a failed CSRF or Origin check, or a poll possession value that does not match the transaction. |
403 |
forbidden |
An actor with no stable OIDC identity, such as a service account, or a completion that does not match the initiating account. |
404 |
not_found |
No link transaction or active identity with that coordinate belongs to this account. |
409 |
link_pending |
The account already has an open link transaction. |
409 |
link_replayed |
The transaction, callback, or identity proof was already consumed. |
409 |
link_cancelled |
The transaction was cancelled before it could be proven. |
409 |
link_self |
The proven identity is already linked to this account. |
409 |
link_collision |
The proven identity belongs to another account. |
409 |
last_identity |
Removing the identity would leave the account unable to authenticate. |
409 |
state_conflict |
A link coordinate is no longer current, or a retry coordinate conflicts with a different request. |
410 |
link_expired |
The transaction passed its bounded expiry. |
503 |
unavailable |
Recovery quarantine, a durable failure, or a configured issuer with no usable device endpoint. |
Deferred optional team relay
Section titled “Deferred optional team relay”The team relay RFC defines the next
extension. Its host-link and team API are not part of the shipped protocol-v4
contract. The extension keeps local Unix and direct overlay owner
paths unchanged and adds a separate Rust pohunek-relayd authority. A host will
initiate an authenticated userspace WireGuard tunnel and every control and
attach TCP stream; the public relay will never dial the host.
The normative future contract is in the RFC’s identity/evidence, recovery,
host-link scheduling, resource, snapshot, catalog, audit, failure, and
ownership sections. It does
not add an API to this document before its owning issues ship. The coordinated
protocol-v4 cutover in #70 will
add the authenticated host link, HostShare coordinates, immutable session
origin, atomic host snapshots, and host-initiated attach streams. There will be
no v4 relay compatibility shim and no change to the direct-owner trust domain.
The dependency path after the completed reduced #85 is
#107 and
#108 →
#92 → complete
#72, followed by #70, #82, #83,
#84, and #71.
The external owner-mode transparent browser transport (the web control center
backend in zajca/pohunek-work) remains supported after #86 adds a
separate typed team-relay client. The Rust relay has no local mode. The two web
surfaces may reuse presentation components, but they use explicit origins,
transports, credentials, and state with no cross-mode fallback. Existing names,
including WsTransport.relay, describe shipped SDK API and do not imply that
the accepted team relay has shipped.
Compatibility Model
Section titled “Compatibility Model”The current public protocol version is 4 (PROTOCOL_VERSION). A daemon of this
build accepts the inclusive range 3..=4 (SUPPORTED_PROTOCOL_VERSIONS): its own
version and the previous one, which it serves through shape-only edge adapters.
The Rust client, the CLI and the TypeScript SDK of this build advertise the same
window (CLIENT_PROTOCOL_VERSIONS is SUPPORTED_PROTOCOL_VERSIONS), so they also
talk to a daemon of the previous release
(#527).
Requests carry v: {minimum, maximum}. The first valid response selects the
highest overlapping version as an integer v, and that selection is fixed for the
lifetime of the connection. Subscription events use the same selected version and
are shaped for it. A non-overlapping range returns daemon/version_mismatch.
daemon.health and host.inspect report the version negotiated for the asking
connection in protocol_version.
A protocol 3 connection is answered method_not_found for any method added after
protocol 3 (today the package.* methods), and never receives their results.
The session.list metadata predicates metadata_eq and metadata_has are also
current-protocol-only request shapes. Because an older daemon knows the method
but cannot apply these predicates, current Rust and TypeScript clients probe
the negotiated version and return daemon/daemon_protocol_too_old before
sending the filtered request.
The CLI exposes these predicates as repeatable session list --meta-eq key=value and --meta-has key options. They combine with --filter and each
other using AND semantics; equality splits at the first = and opaque keys or
values may be empty. A CLI targeting a protocol-3 daemon fails before sending
a metadata-filtered request, while an unfiltered session list remains
available.
A Rust client, CLI or TypeScript SDK client whose connection selected protocol 3 (a daemon of the previous
release, typically on another host) translates in the opposite direction through the
same adapter: it sends requests in the protocol 3 spelling (runtime_id, pinned to
v: {3,3}) and hands its caller the current shape of every result and event, so
--json output and Subscription::next_line never carry runtime_id. A request
whose shape depends on the version first sends daemon.health on the connection,
offering only the versions the request itself allows; a connection whose selected
version lies outside the request’s own range fails client-side before anything is
sent (daemon_protocol_too_old below the range, version_mismatch above it).
After a version is selected every request is pinned to it, except host.discover
(RANGE_FORWARDING_METHODS): the daemon classifies peers for the range the request
advertised, so the client keeps its whole window on it and a protocol 3 peer stays
reachable_daemon however many requests the connection has already served. A
method added after protocol 3 is refused before it is sent, with the typed
daemon/daemon_protocol_too_old error. Its message names the host, the protocol the
daemon runs and the protocol the method needs (“host ‘netbird:build-2’ runs protocol 3,
but package.list needs protocol 4”); its recover hint says to upgrade pohunek on
that host. A payload that cannot be translated is refused with
daemon/version_translation_failed. A runtime reference that is not one of the
protocol 3 agent kinds (shell, codex, claude, hermes) is not checked on the
client: the protocol 3 daemon answers it with its own typed
agent_kind_unsupported.
The window is bounded. Adapters translate shape only (renames and moved fields,
and withholding events and enum values the older version never defined). A
semantic change (a new required parameter, a removed method or event, a changed
error code or meaning) cannot be adapted: it raises the accepted minimum as well
and is announced as a break in the release notes. The next protocol bump deletes
the oldest adapter, so each release accepts exactly its predecessor and no more.
See docs/architecture.md “Protocol versioning”.
Protocol v3 is the coordinated overlay-routing boundary. It changes
HostRecord.address to an optional IP-only value and adds required port and
overlay fields plus optional peer_id. Every daemon and bundled client must
be upgraded together; no v2 compatibility shim is provided.
The former exact integer request envelope is deliberately rejected. The historical protocol-v2 transition was the one-time move from integer-v1 to range negotiation; it provided no v1 envelope or notification-policy shim. The protocol-v3 transition likewise required a coordinated upgrade from v2.
Protocol v4 renames the public worker instance identifier from runtime_id to
worker_instance_id on SessionRuntimeIdentity (and therefore the flat
runtime_id of the output, screen and read results and the runtime objects of
their params), SessionRuntime, RuntimeInventoryEntry,
SessionReportNativeIdParams, and SessionNativeRecoveredEvent (including
previous_runtime_id, now previous_worker_instance_id). RuntimeId keeps its
agent-runtime meaning. Protocol 4 has no runtime_id alias for the worker
instance. A v3 client that sends its range 3..=3 to a v4 daemon is served by
the protocol 3 adapter: the daemon accepts the runtime_id spelling in its
requests (a request that already carries the v4 spelling is bad_request) and
answers, and streams events, in the runtime_id spelling, withholding warning
kinds and events v3 never defined (a v3 connection never receives a
native_recovery session warning; v4 connections do). The background_state
event is withheld from v3 connections, and SessionInfo.background is removed
from the session objects a v3 connection receives, so a v3 client sees no
background snapshot. SessionInfo.capability_refusals is removed from them as
well. A client whose range does not overlap the window
receives daemon/version_mismatch before any method runs; a client of this build
negotiates v3 and translates as described above. The window covers
the public daemon protocol and the Hermes CLI tool path: the CLI’s --json
envelope reports its actual 3..=4 window and its session summaries carry
both worker_instance_id and the v3 runtime_id spelling. Other typed CLI
results retain their current shape. Its observation
commands accept the old --runtime-id spelling as a hidden alias while v3
remains in the window. A v0.33.0 Hermes plugin whose policy pins 3..=3 can
therefore keep using tools, including timed-out start recovery, without
rewriting its owner policy. A policy outside the window fails closed with
pohunek_cli_incompatible; integration update --agent hermes refreshes
the plugin assets and policy explicitly. A client and a
daemon more than one release apart are refused.
Notification hooks deliver through the worker socket when
POHUNEK_WORKER_SOCKET_PATH is set and fall back to the daemon socket otherwise;
the worker’s notification_create hook request is additive to the private
worker protocol, so a worker that predates it simply refuses it. Managed hook assets carry POHUNEK_INTEGRATION_VERSION=14; an asset of
an earlier version still sends the old key, which the daemon accepts only from a
session whose launch baked protocol 3 (and an earlier notification hook still
sends a bare integer v instead of the {minimum, maximum} range, so the daemon
rejects its notifications), so integration.status reports it
outdated and integration.doctor as an asset finding until it is reinstalled.
Clients should call daemon.health after opening a control connection to learn
the daemon build version and protocol version, but daemon.health is not a
special unauthenticated handshake. It is an ordinary request and is negotiated
like every other method.
Within a negotiated public version, additive changes do not require a bump only
where the containing contract is explicitly open. New methods and error codes
are additive; older daemons return daemon/method_not_found for unknown
methods. Optional fields retain their documented omission behavior. Envelope,
observation, native-report, capability, and notification-policy objects are
strict and reject unknown fields, so changing their accepted shape requires the
appropriate negotiated-version treatment. RuntimeRef values and provider-policy map
keys are deliberately open value namespaces, not open object shapes.
Non-additive wire changes require a protocol version bump. Examples: changing a required field name or type, removing a field, changing enum string values, changing an existing method’s result shape, or changing attach stream framing.
Transports
Section titled “Transports”The daemon exposes the same protocol on two transports:
| Transport | Endpoint | Security boundary |
|---|---|---|
| Local | Unix socket at the configured runtime path | Owner-only socket directory and mode |
| Remote | One TCP listener per configured overlay, bound to that provider’s validated local member address and port | Overlay reachability and provider policy; NetBird/WireGuard is the default production provider |
The local runtime path is a shared host/client contract, not a wire-protocol
field. A valid absolute XDG_RUNTIME_DIR resolves the application root to
$XDG_RUNTIME_DIR/pohunek on Linux and macOS. Linux fails fast when it is
absent. macOS instead uses /private/tmp/pohunek-<effective-uid> when the
variable is absent; TMPDIR does not select this default. An explicit empty,
relative, or otherwise malformed value is rejected rather than treated as
absent. The root must be an effective-UID-owned real directory with exact mode
0700; the daemon socket is <runtime-root>/daemon.sock with mode 0600.
Unsafe symlinks, foreign entries, wrong types or modes, and encoded socket paths
that exceed the native Unix-socket limit are errors and are never repaired,
deleted, or truncated implicitly.
The Rust daemon, CLI, workers, and hooks use this shared resolver, and the
TypeScript SDK (@pohunek/sdk) implements the same contract: both are driven by the cases in
crates/paths/fixtures/runtime-paths.json.
The JSON control stream is newline-delimited UTF-8 JSON. One JSON value is sent per line. The current daemon and Rust SDK cap control lines at 1 MiB.
Raw terminal bytes are never multiplexed onto a JSON control connection. Attach uses a separate connection described in “Attach Stream”.
The TypeScript SDK also supports a transparent WebSocket relay transport
(WsTransport) for browser and Bun/Node clients that cannot dial Unix sockets
or daemon TCP directly. Browser code imports the browser-safe
@pohunek/sdk/browser entry; the root @pohunek/sdk entry additionally
exposes Bun/Node socket transports. A relay is not a daemon protocol endpoint
and does not aggregate state. It is a pure one-WebSocket-to-one-daemon-connection
tunnel:
GET /daemon/<host>/controlupgrades to a WebSocket whose text frames are control lines. The relay writes each frame’s UTF-8 bytes plus the daemon’s newline delimiter to one daemon control connection, and sends each daemon newline-delimited response/event line back as one text frame.GET /daemon/<host>/attachupgrades to a WebSocket whose binary frames are opaque attach bytes. The relay forwards bytes unframed to/from one raw daemon connection.- The relay enforces the 1 MiB control-line cap in both directions and closes the WebSocket on oversize input. It does not parse JSON, multiplex sessions, discover hosts, or retain protocol state.
- The
<host>URL segment is resolved only through the relay’s own host catalog. Unknown, stale, or unreachable hosts are rejected during upgrade. - Bind policy is the relay host’s concern. A production relay should bind
fail-closed to the owner overlay (for NetBird, a CGNAT address in
100.64.0.0/10); wildcard addresses such as0.0.0.0and::are never valid.
This transparent WebSocket framing contract is pre-1.0 transport
infrastructure. It remains the owner browser path alongside the future
team surface; it is not the accepted public team-relay contract.
@pohunek/testkit/bun-relay ships startTestRelay, a loopback-only,
Bun-only implementation of it used by the SDK transport tests.
Envelopes
Section titled “Envelopes”Request
Section titled “Request”{"v":{"minimum":4,"maximum":4},"id":"req-7f3","method":"session.list","params":{}}Fields:
| Field | Type | Required | Notes |
|---|---|---|---|
v |
object | yes | Inclusive {minimum, maximum} range. Endpoints are non-zero integers, minimum <= maximum, and unknown range fields are rejected. |
id |
string | yes | Correlation id. The response echoes it. |
method |
string | yes | One of the public method names below. |
params |
JSON value | no | Method-specific params. Missing defaults to null. |
origin_session_id |
string | no | Session containing the caller process. Must be paired with origin_daemon_id. |
origin_daemon_id |
string | no | Daemon instance paired with origin_session_id. Must be paired with it. |
For parameterless methods, send params: null or omit params unless a method
documents another defaultable object.
Origin markers are either both absent or both present, non-empty, bounded, and
restricted to unescaped ASCII identifier characters. Managed children inherit
the pair; the Rust SDK propagates inherited origin (unless its options select
OriginSource::Omitted) and the TypeScript SDK
propagates an explicitly configured ConnectOptions.origin to normal,
subscription, and dedicated connections. When both markers identify the target
as the caller’s own origin session, the daemon returns
runtime/plugin_self_target_denied for exactly session.stop,
session.resume, session.remove, session.remove_accepting_unconfirmed,
session.fork, session.resize, session.set_metadata, session.rename, and
session.input. Read-only methods,
including observation, remain available. The lifecycle reports
session.report_agent, session.release_agent, and session.report_native_id
are explicitly allowed because hooks must report their own session; the public
native-id report is the necessary local fallback when the owner-private worker
claim cannot be delivered. The owner-private worker claim is additionally bound
to kernel peer identity — the worker accepts a report only from the process it
names or from a descendant of that process, and never from a request field —
while this public fallback admits only the session’s PTY root itself, attested
by the kernel peer of its local connection, refuses every report for an
identity-v1 session with native_report_unattested, and then applies its
runtime, ordering, expiry, and provider rules. This is a narrow server-side confused-deputy guard
inside the existing single-operator trust boundary, not per-session
authentication or a broader mutation policy.
Response
Section titled “Response”Successful response:
{"v":4,"id":"req-7f3","ok":{"status":"ok"}}Error response:
{ "v": 4, "id": "req-7f3", "err": { "class": "daemon", "code": "method_not_found", "msg": "unknown control method: example.missing" }}Exactly one of ok or err is present.
The integer v is the highest overlapping version selected by the first
response and cannot change on later responses on the same connection.
Events are pushed only after a successful subscribe request. They are also
newline-delimited JSON, one event per line:
{"v":4,"event":"agent_state","session_id":"s-42","activity":"blocked","source":"osc_title"}Event payload fields are flattened at the top level beside v, event, and the
optional id.
Public Methods
Section titled “Public Methods”All params and result type names below refer to structs exported by
crates/protocol. JSON field names are the Serde field names of those structs.
| Method | Params | ok result |
Notes |
|---|---|---|---|
daemon.health |
null |
{status, daemon_version, protocol_version} |
Liveness and version probe. |
daemon.doctor |
null |
DaemonDoctorResult |
Runs daemon-local checks. Non-null params are daemon/bad_request. Each check is {name, status, detail}: name is the stable code and detail carries the remediation. Both platforms report worker_executable for the active supervision’s worker, including a bounded capability probe of its readable journal schema and overlapping private protocol range; an older worker without capability reporting is warn (unverified), while a missing, unsafe, malformed or incompatible required worker is fail. Linux also reports bin:git, bin:codex, bin:claude, the socket, state and log directory writability checks, netbird_cli and schema_version; macOS adds runtime_dir_private, socket_path_length, filesystem_access, login_shell, launchd_domain, desktop_notifications and keychain. It also reports configured profile checks as agent_profile_<name>, optional provider checks as agent_runtime_codex, agent_runtime_claude and agent_runtime_hermes, embedded manifest checks as agent_manifest_embedded_<runtime> for the runtime compiled in (agent_manifest_embedded_shell; Codex, Claude Code and Hermes ship only as packages, so no embedded Codex, Claude or Hermes manifest is checked), and host override checks as agent_manifest_override_<name>. Configured profiles fail when they cannot resolve securely, their package runtime is unavailable, their executable is missing, or the provider’s existing pinned version rule rejects it. An absent unconfigured optional provider warns. Embedded manifests are parsed from the daemon’s canonical sources; host overrides use the same bounded, contained loader and precedence as launch. An invalid referenced override fails, an invalid unreferenced override warns, and an empty override is valid and disables detection. Details are fixed and redacted. The daemon also reports Codex and Claude hook readiness as agent_integration_<provider> for runtime homes and agent_integration_<provider>_profile_<name> for configured profile homes. Current hooks are ok; missing or outdated hooks and an active install lock are warn; malformed or unsafe provider state is fail. A local request checks each distinct launch home using the same integration inspector as integration.doctor; a remote request checks runtime homes only, preserving the local-only boundary for profile-derived homes. Configured Hermes profiles receive agent_integration_hermes_profile_<name> with warn and an explicit unverified detail because their hook lifecycle is CLI-managed and the daemon has no config-home inspection policy. No home path, profile environment value, provider config text or parser output is returned. catalog_trust_anchor is ok when the release’s catalog trust anchor is loaded, warn when the installation ships none, and fail when it cannot be trusted. Hook interpreter readiness (python3) is reported by integration.doctor, not here. overall is fail only for required failures. |
host.inspect |
null |
HostCapabilities |
Live capability snapshot for the daemon’s host. |
host.discover |
HostDiscoverParams or null |
Vec<HostRecord> |
Enumerates peers from configured overlay transports and classifies daemon reachability. |
host.governance.inspect |
null |
HostGovernanceStatus |
Owner-safe, read-only snapshot of the daemon’s stable host identity and local governance state. Explicit null and omitted params both succeed; every non-null JSON value returns daemon/bad_request. The method never changes enrollment, ownership, or sessions. On unavailable governance it returns the fixed redacted daemon/host_governance_unavailable error; reload or restart the daemon, then retry. |
session.new |
SessionNewParams |
SessionNewResult |
Starts an agent PTY session. With initial input, a write the agent does not read for the configured stall bound ([input] write_stall_timeout_ms, 3000 ms by default) fails with session_input_stalled; part of the input may already have been written, and session.new then rolls the session back: the create fails with that error and the session is removed, not left running. Before session, worker, or worktree creation, the program a bare or profile-based launch names must resolve to an executable on the daemon’s PATH, or the call fails with agent_binary_missing; this refusal’s message and recover hint name only the program’s file name. A bare host-shell session is the exception: it launches the configured shell command, which its launch resolves and reports with the same code. A runtime that declares a version probe (runtime packages that declare one) then requires the isolated, bounded probe to accept that executable; a rejected release, unparseable output, a failing or timed-out probe, or a parser this daemon does not compile in returns agent_runtime_unsupported, also before session, worker, or worktree creation; its message names the parsed release and the supported range, or that the output did not parse, and never carries probe output (see “Version probe”). While startup found legacy resume bindings with no logical record and no migration manifest (listed in session.runtime_inventory as orphaned, migration_manifest_missing), it fails with migration_manifest_missing before anything is written, because a first logical record would make a later manifest unimportable; run pohunek migration preflight against the legacy daemon, and the next daemon start that imports the manifest lifts the refusal. A session whose worker socket could never be bound (the published control.sock path or the longer staged bind path beside it exceeds the platform sun_path limit) fails with configuration-class worker_socket_path_invalid, naming the path and the limit, before anything is written or launched; session.fork applies the same check. A worker job that ends before accepting connections fails the call with worker_exited_before_ready as soon as the service manager shows the job ended, carrying the worker’s exit status where the supervisor retains it, instead of waiting for the worker connect deadline (worker_connect_failed). A launch whose working directory is no longer a directory (a worktree removed after it was bound) is refused by the worker before the agent spawns, and the call fails with runtime-class session_cwd_missing; the create is compensated as below, so no agent runs outside its working directory. A create that fails after binding a worktree is compensated once its runtime is proven ended (checkout and binding, then the durable record); a compensation that cannot finish (for example a checkout locked with git worktree lock) still fails the call with its original error, keeps the record, and lists the session as reconnecting with reason create_compensation_pending while the daemon retries the compensation with the supervision backoff, until the session leaves with session_removed. A session launched from a host profile freezes the keyed revision of that profile into its resume binding; when the host cannot read or create its revision key the create fails with agent_profile_revision_unavailable before anything is written (a session without a profile needs no key). Optional metadata is written atomically with the session (see the metadata field note under SessionInfo below); the CLI exposes it as repeatable --meta key=value. A session created with initial input into a runtime whose [input] table declares ready = "screen_idle" writes that input only once the agent’s rendered prompt box shows it reads input; the enable of bracketed paste does not count. The wait uses the host’s full input.initial_startup_grace_ms even when the request omits extended_input_ready_wait; a request that sets it waits up to the runtime’s longer [input] startup_grace_ms when one is declared (the Hermes package declares 30000 ms). The grace is an upper bound: the input is written as soon as readiness shows. If no readiness is observed by the deadline, or the daemon begins shutting down first, nothing is written to the PTY, the call fails with session_input_not_ready (runtime class; a deadline names the grace and the session’s last observed activity, such as blocked, and a shutdown names the shutdown instead), and the session is removed as for any failed initial-input create (a removal that cannot finish is completed by reconciliation). A runtime whose existence check declares how its agent moves its store refuses a launch that would move it out of reach with agent_native_store_relocated before anything is spawned, and with native_store_check_task_panicked if that check crashes (see “Native session references”). |
session.list |
SessionListParams or null |
Vec<SessionInfo> |
Lists sessions; filters use AND semantics. Metadata filters require the current protocol; a client refuses them against a protocol-3 daemon with daemon/daemon_protocol_too_old before sending the request. |
session.inspect |
SessionId |
SessionInfo |
SessionId is a JSON string, e.g. "s-1". |
session.stop |
SessionId |
SessionStopResult |
Stops a live session (the entry stays in list); it ends stopped with its root’s exit. When a process outside the session’s process group keeps the PTY open past the stop deadline, the worker force-closes the PTY output and the stop still succeeds with SessionInfo.output_force_closed set, because output written after the close is not retained. Before it records that exit, the worker sweeps every same-user process carrying the runtime’s POHUNEK_WORKER_INSTANCE_ID marker (SIGTERM, then SIGKILL after the stop grace) and, while only processes whose environment cannot be read remain, re-reads the process table for up to the stop grace; if it still cannot prove every such process gone, it faults the runtime instead, the stop fails with worker_operation_failed, nothing is signalled that it could not attribute, and the session’s runtime becomes lost (runtime_lost_cleanup_unconfirmed when the daemon’s own sweep cannot confirm either). A stop whose root exit cannot be observed fails. A conflict session is stopped by the identity its record names, only after the recorded generation’s journal names the recorded worker and the service manager’s job under that generation is that worker’s: session_runtime_conflict (no recorded generation or worker), runtime_identity_mismatch (no journal names the recorded worker, or the job is not that worker’s), runtime_supervision_ambiguous (journals unreadable, no job holds the still-running worker process, or the ownership-marker sweep cannot confirm every marked process exited), runtime_supervision_unavailable (job cannot be inspected). A refusal leaves the record unchanged. lost, reconnecting, and incompatible sessions are refused with session_runtime_lost, session_runtime_reconnecting, and worker_protocol_incompatible. |
session.resume |
SessionResumeParams (a bare SessionId, or {session_id, accept_profile_change?}) |
SessionResumeResult |
Explicitly recovers a terminal or lost logical session from captured native recovery metadata, reusing the logical session id but starting a new worker generation (a new native service job) with a new runtime id. The previous generation must be proven ended first; two generations of one session are never live at once. A lost runtime with runtime_lost_cleanup_unconfirmed requires a fresh, confirmed sweep of the exact runtime its journal names before a new generation is minted. When proving the previous runtime ended, missing or mismatched supervision evidence, or a sweep that still cannot prove the marked processes gone, returns runtime_supervision_ambiguous with a recovery hint and leaves the logical session recoverable for a later retry. Before any recovery write or worker launch, recovery resolves the frozen program again and fails with agent_binary_missing when it no longer resolves to an executable; a runtime that declares a version probe (Hermes included) then revalidates that executable and returns agent_runtime_unsupported, with the same message as session.new, when the probe rejects it. Live, reconnecting, conflicting, or incompatible runtimes are rejected; a hook runtime without a native claim returns native_identity_missing, and one with a claim that was not verified as its launch process returns native_identity_unverified; an unreadable durable record, or absent, unreadable, ambiguous, or worker-id/instance-mismatched native generation evidence returns native_identity_evidence_unavailable before launch; a hook runtime whose tested worker journal carries a newer conversation switch that this daemon cannot verify refuses recovery with native_identity_uncertain instead of silently resuming the older verified target; other sessions without native metadata return not_resumable or agent_not_resumable. A Claude conversation id whose transcript cannot be found or verified under the effective Claude config home returns agent_native_reference_missing before a worker launches. A session launched from a host profile relaunches only while the profile still has the revision frozen into the session (see “Frozen profile revision” below): an edited profile, or a legacy session that never froze one, returns agent_profile_changed; a profile that no longer resolves returns agent_profile_missing; a host that cannot read its revision key returns agent_profile_revision_unavailable. An adopted built-in session whose profile differs only by the migration pin (package and digest keys) resumes and forks without the decision. accept_profile_change: true is the owner’s decision to relaunch under the current profile and freeze its revision; it is accepted on the local control socket only and a remote caller gets agent_profile_change_local_only before anything is resolved. The bare id shape stays valid on every protocol version of the window and never accepts a change. Before anything launches, the declared existence check refuses with agent_native_store_relocated when the agent’s store moved out of reach since the launch, and with agent_native_reference_missing when the conversation is not found (native_store_check_task_panicked when the check itself crashes). Daemon restart never calls this method automatically. |
session.fork |
SessionForkParams |
SessionForkResult |
Forks a native agent conversation into a new pohunek session id and PTY, using the source session’s cwd/worktree for cwd_mode: "same". Live sources are allowed. Unknown ids return session_not_found; external sessions return session_external_read_only; sources without launch-agent native metadata return not_resumable or agent_not_resumable; a source whose native generation evidence cannot be read or matched refuses before launch with native_identity_evidence_unavailable, and a newer unverified conversation switch refuses with native_identity_uncertain; an assigned reference whose conversation cannot be found returns agent_native_reference_missing before anything launches; a runtime that declares assign_fork launches the fork under a reference generated for it, returned as the fork’s native_session_id and resumable at once; Codex- and Hermes-backed sessions return agent_fork_unsupported; unmigrated legacy resume bindings return migration_manifest_missing as for session.new. The fork follows the same frozen-profile rules as session.resume (agent_profile_changed, agent_profile_missing, agent_profile_revision_unavailable, local-only accept_profile_change in SessionForkParams) and freezes the revision it launched under. The program and version-probe checks of session.resume apply before anything launches (agent_binary_missing, agent_runtime_unsupported). The source’s existence check runs first, with the same agent_native_store_relocated, agent_native_reference_missing and native_store_check_task_panicked refusals as session.resume, and a refused fork creates no child. A successful fork emits session_created. |
session.remove |
SessionId |
SessionRemoveResult |
Evicts a session from the registry, stopping it first if still live. Unknown id is session_not_found. A reconnecting or incompatible runtime, or a conflict whose reason is runtime_supervision_ambiguous, is retired through the service manager by the exact worker generation its record names, and every worker the session’s journals record for that generation must then be gone, before the record is deleted. A record in one of those states that names no generation, or a conflict for any other reason, fails with session_runtime_conflict, session_runtime_reconnecting, or worker_protocol_incompatible; a retirement the service manager cannot complete fails with runtime_supervision_unavailable; a still-running worker journaled under another generation fails with runtime_identity_mismatch; unreadable session journals or a journaled worker still running after the retirement fail with runtime_supervision_ambiguous. The record is kept in every refused case. Every removal then sweeps the processes carrying the session’s runtime ownership markers (every runtime its record or a journal of its generation names); a runtime named only by the record is signalled only when its process also carries this session’s marker. A missing session marker leaves removal retryable even with session.remove_accepting_unconfirmed. A sweep that cannot confirm every marked process exited fails with runtime_supervision_ambiguous after the removal intent was recorded, keeping the session listed so a retried removal, or the next daemon start, finishes it. When same-user processes whose environment cannot be read are the only obstacle, the error msg lists them (pid, start identity, command name; at most eight, then and N more) and recover tells the operator to inspect and end the ones belonging to the session and retry; no environment values are included. It always refuses on an unconfirmed sweep; session.remove_accepting_unconfirmed is the consent variant. Worktree cleanup is best-effort: the result reports worktrees_removed for checkouts that are gone and worktrees_failed for an owned checkout whose git worktree remove failed, whose binding is dropped regardless, so the leftover directory needs manual cleanup. This is an explicit operator removal and is deliberately not subject to the retention sweep’s unsaved-work hold. |
session.remove_accepting_unconfirmed |
SessionId |
SessionRemoveResult |
Removes a session exactly like session.remove, except that when the marker sweep of its runtimes is unconfirmed solely because same-user processes with unreadable environments may belong to them, it proceeds instead of refusing with runtime_supervision_ambiguous. The daemon never signals those processes; it logs each accepted process at warn once the sweep lets the removal proceed, before any cleanup, and lists the set in accepted_unconfirmed_processes (pid, start_identity, optional command; the field is omitted when empty). The list is deduplicated and capped at 64 candidates, checked before anything is deleted, and each command is a kernel command name with every character outside ASCII letters, digits, space and ._-+:@/ escaped as \u{..} and long names shortened with ..., so it is safe to print. Consent covers this one call only: nothing stores it, a retried removal needs it again, and the reconciliation finalizer and the retention sweep never have it. Every other unconfirmed reason (a signalled process still running, a sweep error, a missing supervision configuration) and every other refusal of session.remove still applies with the same error codes. A process among the accepted ones that does carry the runtime marker keeps running unsupervised after the worktree, logs and record are deleted. A daemon without this method answers method_not_found. |
session.runtime_inventory |
null |
RuntimeInventoryResult |
Returns the durable-worker runtime inventory captured at startup reconciliation: one RuntimeInventoryEntry per discovered worker with its runtime slot, claimed session id, worker/runtime ids, and classification (managed, orphaned, conflict, incompatible, or identity_mismatch). Read-only operator diagnostic; it never mutates or kills a worker. |
session.attach |
SessionAttachParams |
SessionAttachResult |
Mints a one-shot attach stream id. |
session.detach |
SessionDetachParams |
SessionDetachResult |
Cancels an active attach stream. After a worker stream failure, the first call returns its optional typed error and consumes that short-lived result; unknown or already-consumed streams return detached: false without error. |
session.resize |
SessionResizeParams |
SessionResizeResult |
Resizes the PTY on the control connection. |
session.input |
SessionInputParams |
SessionInputResult |
Injects text using agent-specific input framing. Hermes accepts at most MAX_SESSION_INPUT_BYTES UTF-8 bytes, permits LF and tab but rejects other C0/C1 controls without rewriting them, and returns session_input_blocked for fire-and-forget input while approval-visible activity is blocked. Unsafe or oversized Hermes text returns session_input_rejected. Every per-session input holds one gate through its complete framing transaction, preventing waited/waited and waited/fire-and-forget interleaving. Every worker plan contains a body fragment with the provider delay_after_ms and a separate submit fragment; body and Enter are never merged into one paste burst. With wait, every Rust/TypeScript typed call path, including generic Client.call, routes through the dedicated helper, normalizes absent until to [] before wire validation, and rejects timeout_ms outside 1..=8000 before transport. The daemon deduplicates targets, acquires a waiter permit, starts the overall deadline before the input gate, and rejects blocked activity with session_agent_blocked both before the gate and at the causal boundary, independently of provider fire-and-forget policy. A worker-owned submit delay cannot be activity-revalidated and already-written text cannot be safely retracted from an arbitrary TUI, so waited input rejects nonzero-delay framing with session_input_wait_unsupported before writing bytes. Zero-delay framing acquires the exclusive worker write reservation before capturing runtime-scoped activity revision evidence immediately before send. Timeout or shutdown before send cancels the reservation and writes no bytes. After send begins, the exchange is cancellation-shielded and retains the session input gate until the late ACK is consumed. Matching evidence above that lower bound and observed through the fixed deadline succeeds, including evidence between PTY plan flush and worker ACK. After the plan is sent delivery outcome may be unknown, so callers inspect the session and do not retry blindly. Bounded evidence history retains the maximum wait window, so a later same-activity report cannot erase valid pre-deadline evidence. The result includes activity, activity_source, runtime, activity_epoch, and decimal-string activity_revision; SDK helpers require all five and return session_input_wait_contract_mismatch when a same-version daemon ignores wait or omits evidence. Clients deduplicate by (activity_epoch, runtime, activity_revision). Runtime exit returns session_not_running; replacement returns session_runtime_changed; external sessions return session_external_read_only; shutdown cancels delivery or waiting. A write the agent does not read for the configured stall bound ([input] write_stall_timeout_ms in service.toml, 3000 ms by default; the bound restarts after every byte the PTY accepts) fails with session_input_stalled, and the write may have delivered part of its plan, so the caller inspects the session before resending. The dedicated connection adds fixed response headroom beyond the overall deadline. |
session.screen |
SessionScreenParams |
SessionScreenResult |
Reads one bounded, rendered, runtime-bound terminal snapshot without acquiring attach ownership or resizing the terminal. |
session.detection |
SessionDetectionParams |
SessionDetectionResult |
Read-only detector diagnostic. Returns the complete supported region-kind set and current text previews for regions required by the session’s active manifest. |
session.read |
SessionReadParams |
SessionReadResult |
Reads bounded text from the current rendered terminal without attaching. lines accepts 1..=1,000, defaults to that ceiling, and keeps the newest rendered rows. The complete serialized result is capped by MAX_SESSION_READ_RESPONSE_BYTES; byte truncation keeps the newest UTF-8 text and sets truncated. ANSI is a reserved request value that v2 always rejects. |
session.output |
SessionOutputParams |
SessionOutputResult |
Reads a newest retained tail or continues from an exact runtime-scoped output cursor. A request with wait_ms uses a dedicated connection with bounded-wait headroom. |
session.wait |
SessionWaitParams |
SessionWaitResult |
Performs one bounded long poll for state, activity, metadata, terminal, output, or runtime change. It always uses a dedicated connection. |
session.wait_turn |
SessionWaitTurnParams |
SessionWaitTurnResult |
Waits for the next turn event (turn_completed, agent_blocked, approval_required, exited) of one or more sessions after an opaque cursor, bounded by an agent-chosen timeout under a daemon ceiling; a timeout returns per-session progress. It always uses a dedicated connection. New in protocol 4: a protocol 3 client or daemon does not define it. See Turn Waits. |
session.report_agent |
SessionReportAgentParams |
SessionReportAgentResult |
Hook callback for nested agents running inside an existing session. It records an active-agent claim, optional process binding, and optional active native metadata without changing launch identity or resume binding; ignored reports return recorded: false. Claims are reconciled with process facts and can be auto-released when no live backing process remains. |
session.release_agent |
SessionReleaseAgentParams |
SessionReleaseAgentResult |
Hook callback that clears a matching active nested-agent report and restores the session’s default detector identity. Claude SessionEnd hooks use this as the clean-exit fast path; non-current releases return released: false; process-backed auto-release uses the same clear path. |
session.report_native_id |
SessionReportNativeIdParams |
SessionReportNativeIdResult |
Hardened public fallback for launch-agent resume metadata. Reports are runtime- and process-bound, ordered, expiring, and provider-matched; ignored reports return recorded: false. The caller is attested with its kernel peer credentials before any session state is read: only the session’s PTY root itself is admitted, and a caller that fails the attestation (a remote connection, a descendant of the root, another user, or a root whose session uses the identity-v1 schema) gets the error native_report_unattested (see the native-id report attestation rules below the schema section). The owner-private worker claim path is preferred. This is not the nested-agent active identity callback. |
session.set_metadata |
SessionSetMetadataParams |
SessionSetMetadataResult |
Merges owner-controlled metadata. Values must not contain secrets. |
session.rename |
SessionRenameParams |
SessionRenameResult |
Sets or clears a session’s owner display name (name: null clears). Cosmetic; the daemon trims it and rejects a control character or over-long name. |
session.diff |
SessionDiffParams |
SessionDiffResult |
Computes a unified diff of a session’s worktree against a base ref. base: null defers to the worktree binding’s recorded base branch, then the repository default. A session without a bound worktree returns session_no_worktree; a hostile explicit base (empty, leading -, or a control character) returns invalid_branch; a base that cannot be resolved to a merge-base against HEAD returns session_diff_base_unresolved. See SessionDiffResult under Core Payloads for the size cap and truncation semantics. |
session.policy.get |
null |
SessionPolicyResult |
Reads the daemon’s session retention policy. Non-null params are daemon/bad_request. |
session.policy.set |
SessionPolicyParams |
SessionPolicyResult |
Validates, replaces, and persists the session retention policy at <data_dir>/session-policy.json. The running sweep task reloads it on its next cycle, so no daemon restart is needed. |
session.retention.sweep |
SessionRetentionParams or null |
SessionRetentionResult |
Runs one retention sweep under the current policy TTLs, or reports the selection when dry_run is true. It works whether or not automatic sweeps are enabled, never exceeds the policy’s own per-sweep removal cap, and never selects an external session or one in conflict/incompatible runtime state. A matched session whose pohunek-owned worktree has an uncommitted change, an untracked file, commits contained in no other branch/remote/tag, or a state git cannot report is held, never removed: it is counted in held, excluded from eligible, and carries the hold reason (worktree_uncommitted, worktree_untracked, worktree_unpushed, worktree_unknown) on its candidate. One sweep inspects at most 64 checkouts; a matched session beyond that bound is held as worktree_unknown and inspected by the next sweep. worktrees_cleaned counts only checkouts confirmed gone from disk; a checkout that survived its removal is reported in worktrees_failed. The pohunek session retention sweep CLI exits non-zero when failed or worktrees_failed is above zero. |
subscribe |
null |
{subscribed: true} then event stream |
Consumes the connection into a one-way event stream. |
integration.install |
IntegrationInstallParams or null |
IntegrationInstallResult |
Installs agent hooks for active-agent state, native session id capture, provider-managed subagent lifecycle, and notifications into the config home of the selected runtime(s). Each report carries cleanup_incomplete, the quarantine paths of replaced originals whose deletion did not finish after the install committed (empty when cleanup completed). profile selects the config home a host profile launches with and all_profiles every distinct home of the runtime (see “Config homes and host profiles”); both are local-only, exclusive, and unknown fields return bad_request. With all_profiles a home that fails is listed in failed ({agent, home, error}) while the others still install, and the result is not atomic across homes. Without all_profiles every selected home is resolved before the first write, so a home that does not resolve refuses the request with nothing written. Without agent on a host where no served runtime has daemon-managed hooks (a clean host: Hermes is CLI-run and the shell has no handler) the request fails with runtime/integration_no_managed_runtime, whose hint names the install of each official package with daemon-managed hooks (Codex, Claude Code) that nothing serves and then pohunek integration install --agent <runtime>; the Hermes operator plugin is installed by the CLI with its own target flags and is never named. |
integration.status |
IntegrationStatusParams or null |
IntegrationStatusResult |
Returns a read-only per-agent report for managed Codex and Claude hooks: availability, expected and present asset paths, inspected registration paths, installed and expected versions, aggregate health (not_installed, current, or outdated), typed recovery (none, reinstall, or repair_configuration), and non-secret warnings. null selects every runtime the daemon serves through a daemon-run hook handler; unknown parameter fields return bad_request. profile and all_profiles select profile config homes (local-only; see “Config homes and host profiles”) and label each report with home. current requires exact managed executable permissions and exactly one registration under each installer-owned event. Inspection is size-bounded and runs outside the Tokio request task. It never mutates provider configuration. |
integration.uninstall |
IntegrationUninstallParams (agent required) |
IntegrationUninstallResult |
Removes exactly the assets and registrations the installer owns for Codex and Claude, as one rollback-protected transaction under the installer lock: registration first, then Codex trust records, then marker-carrying scripts. User hooks, the Codex hooks feature flag, the hooks directory, and the lock file are left alone; a symlink, directory, FIFO, or unmarked file at a managed script path is preserved and reported in preserved_paths. Each report carries state (removed or not_installed), removed_paths, updated_paths, preserved_paths, and cleanup_incomplete (quarantined originals whose deletion did not finish after the removal committed). agent names the one agent to remove and is required: a missing agent, unknown fields, and null params return bad_request, so removal never widens to every agent and a failure cannot leave one agent removed without a report. profile selects one profile’s config home and all_profiles every distinct home of that agent, one transaction per home with failures listed in failed and no atomicity across homes (local-only; see “Config homes and host profiles”); an absent agent is still bad_request with either. Ownership is judged on the inode that is deleted, and a provider file whose content, mode, or inode changed since it was read is a collision. The same re-verification runs before any rollback, so a file another writer changed is kept there too rather than replaced by the original. Every rollback or cleanup outcome that leaves data in quarantine is reported with its true path, as integration_recovery_required during a rollback and as cleanup_incomplete after commit. A registration file the removal wrote that another writer changed before the scripts are removed, or before the removal completes, is also a collision: it is re-verified (inode, mode, complete content), the other version is kept, and the removed scripts are moved back. Collisions, interrupted rollbacks, and concurrent installers return integration_destination_collision, integration_recovery_required, and integration_install_in_progress. |
integration.doctor |
IntegrationDoctorParams or null |
IntegrationDoctorResult |
Read-only diagnosis of the managed Codex and Claude hooks: per agent ok, the status report (absent while an operation is running), and findings with a stable snake_case code, severity (info or error), summary, and remediation. Only the absence expected of a not-installed integration is informational: an unsafe, symlinked, or group-writable config path is an error even when nothing is installed. Codes cover absent agents and hooks, config-root, asset, registration, provider-config, and Codex feature/trust drift, displaced_original_left_behind (a quarantined original from an earlier install or removal remains in the agent config directory) and quarantine_scan_incomplete (the bounded scan could not cover every entry, so it is an error and the diagnosis is never clean), and operation_in_progress (info: another install, uninstall, or doctor holds the installer lock, so nothing is read or scanned for that agent, status is absent, and the doctor stays ok; the doctor takes the lock non-blocking without creating the file and holds it for its whole inspection, so an installer arriving meanwhile gets integration_install_in_progress), unsafe_installer_lock (error: the lock file is not a regular owner-private file, so every install and uninstall fails), install_drift, and three informational python3 notes that never fail the doctor or change its exit code, because the daemon cannot know the agent’s PATH: hook_runtime_python_found (the first executable python3 behind an absolute entry of the daemon’s own PATH), hook_runtime_missing (none found; relative and empty entries are skipped), and hook_runtime_macos_shim (the first one is the /usr/bin/python3 stub, recognized through any alias, with no Command Line Tools or Xcode behind it); the probe never runs an interpreter and the remediation is to verify python3 on the agent’s PATH, and hook_socket_path_invalid (daemon or worker socket path over the platform limit). The runtime notes apply only to an agent whose hooks are installed. null selects every runtime the daemon serves through a daemon-run hook handler; unknown fields return bad_request. profile and all_profiles diagnose profile config homes (local-only; see “Config homes and host profiles”); each diagnosis carries home, and the recovery commands in its findings name --profile for a profile’s home. |
assistant.materialize |
AssistantMaterializeParams |
AssistantMaterializeResult |
Materializes the assistant knowledge bundle on the daemon host. |
notification.create |
NotificationCreateParams |
NotificationCreateResult |
Creates a host-local notification. Daemon policy is enforced for every producer, including provider hooks and daemon projectors. Dedupe may return created: false with an existing or upgraded record. agent_blocked/approval_required with attention:<session_id> and turn_completed with turn:<session_id> are deferred: the result still reports created: true with a minted id, but the record is held pending until attention_debounce_secs elapses; see NotificationPolicy. |
notification.list |
NotificationListParams or null |
NotificationListResult |
Lists notification records with exact-match filters and cursor pagination. Deleted records are excluded unless status: deleted is requested. |
notification.update |
NotificationUpdateParams |
NotificationUpdateResult |
Updates one record’s lifecycle status. Allowed transitions are unread -> read, read -> acknowledged, unread -> acknowledged, unread/read/acknowledged -> archived, and any non-deleted status to deleted. |
notification.delete |
NotificationDeleteParams |
NotificationDeleteResult |
Logically deletes one record. Unknown or already-deleted ids return deleted: false. |
notification.policy.get |
null |
NotificationPolicyResult |
Reads the daemon’s notification policy. Non-null params are daemon/bad_request. |
notification.policy.set |
NotificationPolicyParams |
NotificationPolicyResult |
Validates, replaces, and persists the daemon notification and automatic-retention policy at <data_dir>/notifications/policy.json. |
notification.retention.prune |
NotificationRetentionParams or null |
NotificationRetentionResult |
Explicitly deletes records selected by retention filters, or reports matches when dry_run is true. Applied pruning may also compact the action log at the policy threshold. |
project.list |
ProjectListParams or null |
Vec<ProjectInfo> |
Lists known projects on the target host. |
project.add |
ProjectAddParams |
ProjectInfo |
Registers a host-local git project path. |
project.show |
ProjectShowParams |
ProjectShowResult |
Shows a project plus live worktree state. Each ProjectWorktree reports owned when pohunek created it; owner_session_id names the session it was created for and stays after that session stops; session_id names a live (non-terminal) session running in it. Each of the two ids is omitted, never null, when it does not apply: owner_session_id on a worktree pohunek does not own, session_id when no live session runs there. Clients older than owner_session_id ignore it. |
project.rename |
ProjectRenameParams |
ProjectInfo |
Sets a custom display label. |
project.remove |
ProjectRemoveParams |
ProjectRemoveResult |
Removes a project record and optionally owned worktrees. |
project.prompt |
ProjectPromptParams |
ProjectPromptResult |
Resolves a prompt template without rendering it. |
project.action |
ProjectActionParams |
ProjectActionResult |
Resolves one action recipe plus prompt content. |
project.actions |
ProjectActionsParams |
ProjectActionsResult |
Lists available project actions after layer shadowing. |
worktree.remove |
WorktreeRemoveParams |
WorktreeRemoveResult |
Removes one owned worktree binding, refusing live sessions unless forced by the method contract. |
package.list |
null |
PackageListResult |
Local-only. Lists installed runtime packages in ascending digest order with the registry generation and per-package health. See Runtime Package Methods. |
package.inspect |
PackageInspectParams |
PackageInspectResult |
Local-only. Returns one installed package record and what its runtime descriptor declares. |
package.doctor |
PackageDoctorParams or null |
PackageDoctorResult |
Local-only. Verifies every installed package root and reports faults, unregistered roots and pinned digests that are not installed. |
package.install |
PackageInstallParams |
PackageInstallResult |
Local-only. Installs a runtime package archive under explicit-digest or catalog trust, or previews it with dry_run. |
package.link |
PackageLinkParams |
PackageInstallResult |
Local-only. Copies a developer package directory into content-addressed storage and installs it disabled and unselected. |
package.set_enabled |
PackageSetEnabledParams |
PackageChangeResult |
Local-only. Enables or disables an installed package for fresh launches. |
package.select |
PackageSelectParams |
PackageChangeResult |
Local-only. Selects the installed version bare requests of its package id resolve to. |
package.uninstall |
PackageUninstallParams |
PackageUninstallResult |
Local-only. Removes an installed package that no session or host profile pins. |
package.bind_profile |
PackageBindProfileParams |
PackageBindProfileResult |
Local-only. Pins a host agent profile to an installed package under the package lifecycle authority, or previews the change with dry_run. |
status exists as a method constant in crates/protocol but is not a supported
daemon method in this API version. It returns daemon/method_not_found.
HostCapabilities advertises terminal_read_supported,
output_read_supported, session_wait_supported, and
session_wait_turn_supported independently. Clients must check the relevant
flag instead of assuming that a reachable daemon or an attach-capable session
supports every observation method. A daemon that predates session.wait_turn
omits session_wait_turn_supported, which decodes as false; it answers the
method itself with daemon/method_not_found when it speaks protocol 4. Its runtimes entries
are live host-local probes: agent is the selected profile or base name and
optional agent_base is the RuntimeRef of the runtime behind it. Optional
version and supported are a provider policy, not generic availability:
their absence means that no version policy applies. The daemon builds the
inventory from its runtime registry: a runtime whose definition names a
version-probe parser reports a policy, and a present runtime with a policy
always reports supported as true or false. Clients derive launchability
from the entry alone: available and supported != false; an entry with no
supported has no policy, and no runtime name or agent_base is special-cased.
An unavailable runtime with a policy omits both fields.
Optional config_home_id on a runtimes entry is an opaque, non-secret
identifier of the config home a launch of that entry gives its agent, so a
client can group the entries of one account without learning a path. It is
reported for the bare runtimes that declare a [config_home] and for each host
profile of such a runtime, resolved by the same rule as the integration
lifecycle and the transcript observer (the declared variable from the launch
base environment overridden by the profile’s [env], else the default below
HOME). Two entries carry the same value exactly when their homes are one
directory: the canonical path when the directory exists, else the path with its
. components removed (a path with .. through a missing directory keeps its
own identity), so a symlinked alias shares the id and a home that is created
later may change it. The value is 32 lowercase hex digits, a truncated HMAC of
the directory under the host’s profile-revision.key secret with its own
domain, so it is stable across calls and daemon restarts of one host, differs
between hosts, cannot be checked against a guessed path without the key and
reveals neither the path nor any environment value. It is safe in a relay or
remote host.inspect answer for that reason, and it is not comparable across
hosts. The field is absent for a runtime without [config_home] (shell,
hermes), an unavailable pinned profile, a home that cannot be resolved (a
relative or non-UTF-8 value) and a host whose key cannot be read or created;
host.inspect never fails because of it. The first call on a host with a state
directory creates the key record if no relay approval has yet. A daemon that
predates the field omits it. For Hermes (served by its official package),
available: false omits both fields, while an installed unparseable or
non-0.20.0 executable reports supported: false. The daemon independently
enforces the same policy immediately before every Hermes launch or recovery
rather than trusting a potentially stale inventory response. The probe clears
ambient environment state except the launch PATH and uses private temporary
HOME, XDG, temporary and working directories. Its executable is resolved and canonicalized once;
the exact absolute path is then passed to the worker without a second PATH
lookup. The single-operator trust boundary still permits the same owner to
replace that canonical file between probe and exec; eliminating that residual
would require an fd-based execution contract. An absent agent_base is compatible with legacy custom
profile inventory, but a present historical label (a value outside the runtime-id
grammar) is presentation-only and not launchable.
Runtime Package Methods
Section titled “Runtime Package Methods”The package.* methods manage the runtime packages installed on the host the
daemon runs on. They are local-only: the daemon serves them on its local Unix
control socket and refuses them on a remote overlay TCP connection with
local_only_method, because installing a package extends the owner’s launch
authority on that machine. They are additive: the protocol version does not
change. A package is addressed by digest (sha256: plus 64 hex characters),
the content address of its verified archive. Payloads never carry file
contents, package paths or secret values.
Shared payloads:
PackageInfo:digest,package({id, version}from the runtime descriptor),origin(official,explicit_digestorlink),enabled,selected,installed_at_unix_seconds, optionalruntime_id, optionalfault,referenced(a live, lost or resumable session or a host profile pins the digest) and optionalselection_blocked({reason, retained}: why an installed, unselected package cannot be selected yet; see “Compatibility-gated selection” below).PackageFault:root_missing,root_modified,root_unsafe,root_unreadable,descriptor_missing,descriptor_invalid,identity_mismatch,runtime_not_claimable,runtime_conflict.faultis absent when the root verified and the descriptor loaded.PackageRuntimeInfo:runtime_id,display_name,program,args,resumable,forkable,prompt_argumentand the optionallaunch_args(the arguments appended at a fresh launch to pass the generated reference),resume_args,fork_args,fork_launch_args(the arguments appended afterfork_argsto pass a fork’s own generated reference, present only for a runtime that declaresassign_fork; all templates in which{reference}marks the reference slot),version_probe,integration_handlerandhook_schema(the core-owned handler and hook schema ids of the descriptor’s[integration]table, absent for a runtime without one). The program, the fixed arguments and every template are exactly what the daemon can put in the argv of a process it launches as the owner (the first prompt is appended whenprompt_argumentis true), so all of it can be reviewed before an install. A descriptor carries no environment values, setup commands or hooks.PackageTrust, a tagged object:{"kind":"explicit_digest","digest":...}requires the archive digest to equaldigestand never authorizes an official runtime alias;{"kind":"catalog","catalog_path":...}names a signed catalog at an absolute path on the daemon host.
| Method | Params | Result | Behavior |
|---|---|---|---|
package.list |
null |
{generation, packages[]} |
generation counts committed registry changes. |
package.inspect |
{digest} |
{package, runtime?} |
runtime is absent when the descriptor cannot be loaded. |
package.doctor |
{package?} or null |
{generation, findings[]} |
package restricts the report to one package id. A finding is {kind, digest, package?, fault?, referenced}; kind is fault, unregistered_root (a root on disk without a record; installing the same archive again adopts it after verification), pinned_not_installed (a session or host profile pins a digest the registry does not record) or selection_blocked (an installed, unselected package that cannot be selected until a retained reference to an incompatible version of the same package is gone; blocked_by is that digest). A finding carries blocked_by? besides the fields above. An empty list means everything verified. |
package.install |
{archive_path, trust, enable, select, dry_run} |
{status, package, runtime, reloaded} |
All fields are required. archive_path is absolute on the daemon host. status is preview (dry run, nothing changed), installed, already_installed (recorded with a verified root) or root_restored (recorded, root was missing and extracted again). A package that is already recorded keeps its recorded enabled, selected and origin: enable, select and trust apply to a new record only. With select: true, a package whose integration is incompatible with a retained version of the same package is still installed, but unselected: the call succeeds with package.selected: false and package.selection_blocked set (a dry run reports the same). |
package.link |
{directory, dry_run} |
{status, package, runtime, reloaded} |
Copies the directory into content-addressed storage and installs it disabled and unselected; the daemon never loads the directory itself. |
package.set_enabled |
{digest, enabled} |
{package, reloaded} |
Disabling blocks fresh launches only; a session already pinned to the digest still resumes. Enabling first proves the package root verifies and the package may serve its runtime id, so it cannot create a runtime id conflict. |
package.select |
{digest} |
{package, reloaded} |
Bare requests of the package id resolve to this version afterwards. The same root and runtime id checks as enabling apply. Refused with package_integration_incompatible while a retained reference uses a version of the same package whose integration differs; nothing changes. |
package.uninstall |
{digest, remove_modified} |
{digest, reloaded} |
Refused with package_referenced while a session or host profile pins the digest. remove_modified: true removes only a root that fails verification; a verified root is refused with package_root_intact. |
package.bind_profile |
{profile, digest?, dry_run} |
{status, profile, base, previous?, package, runtime, reloaded} |
profile is the file name under the agents directory without .toml. With digest the installed package of that digest must serve the profile’s base runtime and load without a fault; without it the target is the one selected, enabled package serving the base. Only the package and digest keys are rewritten, every other byte is kept, and the new file replaces the old with one rename(2) so the profile name never stops resolving; a profile edited after it was read is left as found. status is preview (dry run, the pin would change), bound or unchanged (already pinned; nothing written). previous is the digest pinned before. reloaded is true only for bound: profile files are read at every resolution, so the pin is live at once. Runs under the same exclusive authority as package.uninstall, so an uninstall of the target either precedes the bind (package_profile_target_invalid) or is refused with package_referenced. A pin of a version whose integration differs from the selected version of its package, or from another referenced version (a session or another profile’s pin; the pin being replaced does not count), is refused with package_integration_incompatible, because the pin would be a retained reference that coexists with an incompatible selected version. |
Compatibility-gated selection. An integration handler owns one active asset set
per runtime, and sessions launched from an older version keep reporting through
that version’s handler and hook schema until they end. The integration of a
package is the pair of its handler id and hook schema id, or none. Two versions
of one package are compatible when their integrations are equal: every compiled
handler drives exactly one schema and the registry declares no coexistence
between different pairs, so any difference, including one side having no
integration, is incompatible. A version whose integration cannot be determined
(its root or descriptor is unreadable) is treated as incompatible; a descriptor
that predates hook_schema resolves through the schema its handler drives. A
package is held back while a retained digest of the same package (a live, lost
or resumable session, or a host profile pin; the same set that blocks
package.uninstall) has a different integration: package.select is refused
with package_integration_incompatible, package.install with select: true
installs it unselected, and the selected version keeps serving, with no fallback
to another package. A retained pin still resolves from exactly its pinned digest.
The state is not stored: package.list, package.inspect and package.doctor
derive selection_blocked from the registry and the retained set at the moment
of the call, so it survives a daemon restart and clears on the first listing
after the last such reference is gone (session removed, profile unpinned or
rebound, old version uninstalled). Nothing selects the package then; the owner
selects it explicitly. Selection changes commit under the exclusive lifecycle
authority, and a launch that resolved the previously selected version before a
selection committed is refused with runtime/runtime_package_changed (retry the
request) instead of pinning the version that was just replaced; a digest a host
profile pins launches whatever is selected.
Install and link validate the package in memory before anything is extracted:
runtime.toml is parsed from the verified archive, and the package identity
(id and version) comes from that descriptor. shell is never claimable. The
official aliases (the reserved runtime ids other than shell) are served only
by a package the signed catalog authorizes as official for that exact package
id, runtime id and archive digest; such a package serves the alias while it is
installed, enabled and selected. The daemon compiles none of Codex, Claude Code and Hermes in, so
codex, claude and hermes are each served by the official package or not
at all, and disabling or uninstalling the package leaves the alias unserved: a
host without it answers a bare codex, claude or hermes with
agent_profile_not_found, an unpinned host profile over it with
runtime_not_installed (runtime_incompatible with a doctor recovery while the
installed official package of that runtime, pohunek.runtime.<runtime>, cannot
be loaded), and integration.install for codex or claude with
runtime_not_installed, each with a recovery hint naming
pohunek plugin install runtime/packages/<runtime>.tar.zst --catalog runtime/runtime-catalog.json from the extracted release archive (and, for the
profile, pohunek plugin profile migrate <name> after it). integration.status,
integration.doctor and integration.uninstall that name codex or claude
work without the package: the core-owned handler keeps the config home of the
earlier built-in (CODEX_HOME, else ~/.codex; CLAUDE_CONFIG_DIR, else
~/.claude), so hooks pohunek installed there are reported and removed (only
the entries it owns); profile and all_profiles reach the homes of host
profiles over that runtime through each profile’s own [env], although such a
profile does not resolve; without agent, status and doctor cover only served
runtimes. A runtime id served by a
loaded package of a different package id is a conflict. A claim of an alias by
an explicit-digest or linked package is answered with
package_runtime_not_claimable, and so is a descriptor that names shell; a
catalog that names shell or does not authorize the alias for the archive is
package_untrusted. The package that serves an alias is the selected, enabled
official package whose runtime_id is that alias in package.list and
package.inspect; no such package means nothing serves it.
A session launched from the built-in runtime of an official alias this daemon
does not compile in (the built-in Codex, Claude Code or Hermes of an earlier
release, pinned or from a record without a launch binding) while nothing serves
the alias is refused with runtime/runtime_builtin_removed on resume, fork and
every mutation (input, attach, resize, rename, metadata); only stop and remove
still work. Its recovery hint names the official package install and the
new-session fallback. Once the official package serves the alias, it adopts a
session whose launch pin names that package id, live or stopped; the package
version may differ. The adopted session accepts input again, and resume and fork
run through the package when it honors the session’s recovery contract (the same
native reference kind, a hook-reported reference, and for fork, fork support in
both the frozen spec and the package). The relaunched run is pinned to the
package digest. A session whose contract the package cannot honor, and a
session record that names no package, are refused with
runtime/runtime_served_by_package, whose recovery hint names the one
migration (start a new session with pohunek session new --agent <runtime>,
which the official package serves; no command rebinds the session to another
runtime source), and the stored binding is kept unchanged. A fork of a Hermes
session answers agent_fork_unsupported, because Hermes declares no native fork.
session list and session inspect report each of resume and fork as false,
with its refusal code in capability_refusals, when the session’s recovery
resolution refuses that operation. A session pinned to a runtime
source the daemon does not serve otherwise (a built-in of a runtime id that is
not an official alias, a package digest that is not installed) is refused with
runtime/runtime_not_installed. Stop and remove still work. A session the
daemon launched keeps the definition it was launched from while that daemon
runs. A built-in pin that the installed official package adopts is served by
that package, live or stopped. A session adopted at startup whose pin the daemon
does not serve is observed only through its launch-time facts: the generic shell manifest (a
manifest override of the session’s host profile still applies), the input
framing persisted at launch, and the hook schema its worker journaled. Hook
reports and processes naming its own runtime are not attributed through the
package that serves the runtime id now: session.report_agent and
session.release_agent for that runtime are ignored (logged at INFO as
session.callback.unserved_pin), and the public socket admits a hook report (a
notification, for example) only by the schema the session’s worker journaled,
the rule the worker applies on its own socket. This holds until a daemon
restart adopts the session again, also when the pinned source is served again
in between (the package uninstalled, for example). A recovery binding whose
launch spec the store migration left unresolved is completed only from the
definition its pin names; while the pin is not served it stays unrepaired and
the session carries a native_recovery warning.
A worker of a release before 0.34.0 journals no hook schema, so a live session
whose worker journaled an active identity on such a pin cannot have it verified:
the upgrade preflight reports it would_not_be_adopted with
worker_identity_unverified, and startup does not adopt it (the identity import
fails with active_identity_provider_invalid and the runtime is reported as a
conflict).
Catalog trust fails closed with
official_trust_unavailable on a host without a trust anchor and with
official_trust_anchor_invalid on a host whose anchor cannot be trusted. The
daemon reads the anchor once at startup from runtime-catalog-anchor.json in
the directory of its executable (public keys, validity windows and revoked key
ids; see the runtime catalog concept page); the file must be a regular,
non-linked, size-bounded file that only the daemon’s user or root can write,
under directories only they can write. daemon.doctor reports the state as the
catalog_trust_anchor check: ok when loaded, warn when absent, fail when
untrusted.
A catalog install checks the catalog signature, the persisted high-water
sequence and revoked key ids, then that an entry binds the package id, runtime
id, version and digest and supports this core version and platform; before the
package is published it persists the catalog’s sequence and revocations, and a
failure to do so is answered with package_registry_failed with nothing
installed, enabled or reloaded.
After each committed change the daemon rebuilds its runtime registry and
reports reloaded. Every mutation holds the lifecycle guard, so launches never
interleave with it. A modified root makes enable, select and uninstall fail with
package_root_invalid; reinstall the package or uninstall it with
remove_modified.
Errors use the error contract below. Every code has fixed message and
recover text that carries no path, package content or caller-supplied value.
| Code | Class | Meaning |
|---|---|---|
local_only_method |
daemon |
The request arrived on a remote overlay connection. |
package_archive_invalid |
runtime |
The archive is not a valid canonical package archive, breaks a limit or has another digest than the trust names. |
package_source_unreadable |
runtime |
The archive file, package directory or catalog could not be read or is not an absolute path. |
package_untrusted |
runtime |
The trust does not authorize the archive. |
official_trust_unavailable |
configuration |
The host has no catalog trust anchor, so an official package cannot be authorized. |
official_trust_anchor_invalid |
configuration |
The host’s catalog trust anchor exists but cannot be trusted (unsafe location, permissions or ownership, or invalid content), so an official package cannot be authorized. |
package_incompatible |
runtime |
The package does not support this core version or platform. |
package_descriptor_invalid |
runtime |
The package’s runtime descriptor is not a valid runtime definition. |
package_runtime_not_claimable |
runtime |
The package claims a runtime id it may not serve. |
package_runtime_conflict |
runtime |
Another package or a built-in runtime serves the runtime id. |
package_not_installed |
runtime |
The digest is not installed. |
package_identity_installed |
runtime |
The package id and version are installed from another archive. |
package_identity_conflict |
runtime |
The digest is installed under another package identity. |
package_referenced |
runtime |
A session or host profile still pins the digest. |
package_root_invalid |
runtime |
The recorded package root fails verification. |
package_root_intact |
runtime |
The recorded package root verifies, so it is not removed as modified. |
package_registry_busy |
daemon |
Another writer holds the package registry. |
package_registry_failed |
runtime |
The package registry or its storage failed. |
package_limit_reached |
runtime |
The registry holds the maximum number of packages. |
package_reload_failed |
daemon |
The change was committed but the runtime registry was not rebuilt. |
package_profile_not_found |
runtime |
The host has no agent profile of that name. |
package_profile_unusable |
runtime |
The profile fails the daemon’s profile acceptance rule, is a symbolic or hard link, or is not valid TOML. |
package_profile_base_builtin |
runtime |
The profile’s base runtime is the built-in shell, so there is no package to pin. |
package_profile_target_invalid |
runtime |
The package is not installed, is faulted, does not serve the base runtime, or is not the unique selected package. |
package_profile_changed |
runtime |
The profile changed while it was being rewritten and was left as found. |
package_integration_incompatible |
runtime |
The package’s integration handler or hook schema is not compatible with another version of it that is still referenced or selected; nothing changed. |
Daemon Runtime Configuration
Section titled “Daemon Runtime Configuration”POHUNEK_OBSERVE_EXTERNAL_AGENTS is an opt-in daemon environment flag. Accepted
true values are 1, true, yes, and on; accepted false values are 0,
false, no, off, or an unset variable. When true, the daemon watches the
operator’s Claude and Codex transcript trees and same-user process table for
agents started outside pohunek. The transcript trees are the projects (Claude)
and sessions (Codex) directories of every config home the host launches those
agents with: each runtime’s own home and the home of every host profile of it,
resolved as a launch resolves it (see “Config home”). The set is re-read on every
reconciliation pass, so a profile added, edited or removed is followed without a
daemon restart, and homes that resolve to one canonical directory are watched
once. A profile whose home does not resolve or has no transcript tree yet is
skipped and logged once at debug; a tree that cannot be inspected (permissions,
I/O error) stays a root, keeps its indexed transcripts and degrades the watcher; a transcript below a root that left the set
leaves the index. An observed external session is still matched to a candidate by
agent base alone, never attributed to a profile. The directory of a profile-derived
home is private: an external session matched to a transcript below one omits
native_session_path, and the watcher’s log lines name such a root by a label,
not its path. The daemon’s own process
environment steers nothing unless the launch allowlist forwards the variable. The
corresponding SessionRegistryConfig setting is observe_external_agents,
default false.
Observation limits are validated together when the session registry starts.
Defaults are 783,240 raw output bytes, an 8,000 ms output wait, an 8,000 ms
session wait, 200 rows, 500 columns, a 1,048,418-byte serialized screen result,
128 global waiters, and 8 waiters per session. Turn waits have their own limits:
a 5-minute default duration, a 30-minute ceiling, 128 global and 8 per-session
turn waiters, and a 1,024-event journal. Values must be non-zero, must not
exceed the shared protocol ceilings (the turn-wait ceiling at most
MAX_SESSION_WAIT_TURN_MS, one day), the per-session waiter caps must not
exceed their global caps, and the turn-wait default must not exceed its
ceiling. Invalid combinations fail fast with
runtime/observation_limits_invalid; the daemon does not silently substitute
defaults.
Core Payloads
Section titled “Core Payloads”This section names the high-value fields clients commonly branch on. The full
wire shapes are the exported crates/protocol structs.
HostRecord
Section titled “HostRecord”overlay: required stable transport ID used to qualify peer identity.peer_id: optional stable provider peer identity; absence is preserved. NetBird usespublicKeyor legacypubKey, never the mutable peer IP.address: optional dialable IP address without a port.nullkeeps an address-less or rejected-spoof candidate visible but non-dialable.port: required effective daemon port for this overlay route.nameandfqdn: optional display selectors, never sufficient by themselves to bypass collision checks.- The flattened host class remains
candidate,reachable_daemon,unreachable, orversion_mismatch. A peer isreachable_daemonwhen it answers a health probe inside the protocol range the asking client advertised (a current Rust client or CLI:3..=4), andversion_mismatchwhen it answers outside it.host.discoverclassifies for the range of the asking request.
HostGovernanceStatus
Section titled “HostGovernanceStatus”host.governance.inspect has no parameters: explicit params: null and
omitted params both succeed under the general parameterless-method rule.
Every non-null JSON value, including an object, array, string, number, or
boolean, returns the canonical daemon/bad_request error. Its result is a
strict, public-safe object with exactly these six required fields. All six keys
are always present. host_id and approval_key_reference are always non-null
canonical IDs; only enrollment, owner, owner_revision, and quarantine
are conditionally nullable according to the lifecycle invariants below. Unknown
or missing fields are not a valid result:
host_id: the daemon’s stable opaqueHostId. It is distinct from a CLI or overlay route selector and from every other typed identity.enrollment:nullfor a never-enrolled host, otherwise the one local enrollment’s relay ID, status, and canonical non-zero enrollment revision.owner:nullfor a never-enrolled host, otherwise exactly one tagged principal or team owner. Co-ownership is not representable.owner_revision:nullexactly when bothenrollmentandownerarenull; otherwise a canonical non-zero decimal string.quarantine:nullunless the enrollment status isquarantined; then it contains the explicit quarantine reason.approval_key_reference: the required public reference to the host approval verification key. It is safe to inspect but is not signing material.
The public result never contains an approval private key or seed, a proposal, transfer outcome, signature, nonce, retired-enrollment summary, relay credentials, or other private persistence data. The daemon validates all of those local records before publishing the snapshot. IDs are opaque canonical values and must not be parsed for routing or authorization meaning. Revisions are checked monotonically by the local governance state; JavaScript clients receive their decimal strings rather than lossy numbers.
If the daemon cannot safely obtain the retained governance snapshot, the method
returns exactly daemon/host_governance_unavailable with the fixed message
host governance status is unavailable and recovery hint reload or restart the daemon, then retry. It does not reveal a state path, raw I/O error, record
contents, key material, proposal, nonce, signature, or previous snapshot.
Native reference strategies
Section titled “Native reference strategies”A runtime definition declares in [native_reference] how core obtains the native
session reference that resume and fork consume. The table is required.
strategy |
Reference source | Resume and fork |
|---|---|---|
none |
none | frozen off; requires resume.supported = false |
hook |
a validated integration report (process-bound, ancestry, sequence and expiry checked) | on once a report arrived; requires a supported resume; may declare [native_reference.existence] for an id-kind reference |
assigned |
generated by core and passed to the agent at launch (and at fork with assign_fork = true) |
on from the first moment; requires a supported resume |
The built-in shell declares none; the official Codex, Claude Code and
Hermes packages declare codex, claude and hermes = hook.
Config home
Section titled “Config home”A runtime may declare where its agent keeps its configuration, hook registration
and conversations, apart from [integration] because the home concerns every
runtime:
[config_home]env = "CLAUDE_CONFIG_DIR"default = ".claude"env names the variable the agent reads to relocate its home (upper-case, not
in the reserved POHUNEK_ namespace) and default is the directory below the
user’s home used while the variable is unset or empty (a relative path of plain
components: no .., no absolute path, no glob or shell characters, bounded).
Both keys are required and unknown keys are refused (descriptor_invalid). The
daemon-run integration handlers install into this home, so a runtime that names
one needs the table. The official Codex and Claude Code packages declare it;
any package may too. Pi keeps declaring its conversation root under [native_reference.existence].
Transcripts
Section titled “Transcripts”A runtime may declare where below its config home the agent writes JSON Lines conversation transcripts and which compiled parser reads them; the external observer watches that tree of every config home the host launches the runtime with:
[transcripts]parser = "jsonl-head-v1"dir = "sessions"parser is a closed set compiled into core (jsonl-head-v1: the first session
id, transcript path and absolute working directory in the first 32 records or
64 KiB, with session_id before sessionId, conversation_id and
conversationId). dir follows the [config_home] default rule (relative,
plain components, no . or .., no empty segment, bounded). The table requires
[config_home]; an unknown parser, an unsafe dir, a missing or unknown key,
or a missing [config_home] is descriptor_invalid. The official Codex
package declares sessions and the official Claude Code package declares
projects; a runtime without the
table contributes no transcript root. Trees of two different runtimes that are
the same directory or nested in each other are attributed to neither; the
daemon logs the warning external.transcript_roots.ambiguous with code
transcripts_ambiguous_owner.
Hook schemas
Section titled “Hook schemas”A runtime that reports lifecycle hooks (identity, release, notification, subagent) declares an integration in its descriptor. The package names only the compiled handler and the hook schema its reports follow; it never carries schema contents:
[integration]handler = "codex-hook-v1"hook_schema = "identity-subagent-v1"Both keys are required. The schemas are a closed set compiled into core:
identity-v1 (identity report and release, notification) and
identity-subagent-v1 (the same plus subagent start, stop and snapshot). Installing or
parsing a descriptor refuses an unknown schema id, an unknown handler id, and a
handler/schema pair the handler does not drive (descriptor_invalid). A
runtime without [integration], such as Pi, has no schema, so its sessions
admit no hook report. The official Codex and Claude Code packages use
identity-subagent-v1, hermes uses identity-v1, and the shell, which can
host Codex and Claude Code in the foreground, uses identity-subagent-v1.
A schema declares the provider ids that may report, the admitted actions, the
native-reference kinds (id, path), the process-ancestry matcher, the
launch-process rule, whether a nested provider may become the active identity, the optional subagent claim
fields (parent_id, agent_type, and the outcome of a stop), and the
subagent sequence rule (a record’s phase is the phase implied by the
highest-sequence claim admitted for its id: a start newer than a terminal
record reopens it, a stop newer than a running record ends it, a newer start
for a running record or a newer stop for a terminal one changes no phase but
advances the record’s journaled sequence, and a claim not newer than that
sequence is ignored; the journaled sequence is capped at the worker’s own
CLOCK_MONOTONIC reading when the claim arrives, so a far-future claim cannot
make the subagent’s later genuine claims stale, while comparisons use the
claim’s own sequence), and the subagent providers whose stop is
admitted only after a recorded start (claude in identity-subagent-v1, keyed
by the reporting provider, so it also covers a Claude in a shell session’s
foreground). A schema that admits subagent
operations must declare the sequence rule; one without it admits no subagent
record on either side. The worker applies the declared matcher, launch-process
rule, field set and sequence rule when a hook reports, and the daemon applies
the matcher to the
imported identities, the provider set, the field set and the outcomes (a
failed or cancelled subagent needs the outcome field) to the imported
subagents, and the nested-provider rule to identities and releases. The
sequence order itself is checked by the worker only, because the inspect
snapshot carries no claim sequence for subagents. The worker orders identity
claims and releases per reported process (its pid and kernel start identity):
a claim or release must be newer than every claim and release the worker
accepted for that same process, and reports naming different processes are
never compared, so a report naming one process, even at u64::MAX, cannot
make a report naming another process stale. A hook reaches the worker as a
descendant of the agent it reports, so under NamedForProvider a descendant’s
report naming an ancestor is that ancestor’s report and is ordered with it;
under FrozenRootEntrypoint only the root itself may name the root. This
binding is not an authentication boundary: every process in the agent’s PTY
subtree runs as the owner, so under NamedForProvider any process the launch
process starts can file the launch process’s report, and on the worker path
only FrozenRootEntrypoint (identity-v1) binds the root’s report to the
root’s own kernel peer (see the trust boundary in the
architecture). Across
processes the latest accepted claim, in arrival order at the worker, is the
active identity, and the worker’s imported active claim carries that process’s
own sequence, which can be lower than the previous import’s. The launch
process’s native reference is ordered only by reports naming the launch
process. A release answered ok: false because its process is not the active
identity still orders that process’s later-delivered claims. The worker
remembers the sequence of every live reporting process and never evicts one:
the mark of a process that exited (including one its parent has not reaped
yet), or whose pid now names another start identity, is pruned when room is
needed, and the PTY root and the launch process are always kept. When live
reporting processes besides those two fill the worker’s bound (16), a report
from a further process is refused (ok: false, logged as
worker.identity.rejected with reason identity_reporters_exhausted) until
one of them exits. Before the launch identity exists, a launch agent’s report
with a native reference is verified as a launch claim even then, and is kept
only when it establishes the launch identity, which pins its process; when the
verification is deferred, only its pending launch claim is kept, so the retry
can still establish it. The sequence contract of a worker
claim is therefore a value strictly increasing across every hook event that
reports the same process. Every hook event runs its own short-lived
interpreter, so the shipped Codex and Claude hooks read the host’s system-wide
monotonic clock in nanoseconds (CLOCK_MONOTONIC) with Python’s
time.clock_gettime_ns(time.CLOCK_MONOTONIC) rather than
time.monotonic_ns(), which is defined only within one process (Apple’s
Python 3.9 on macOS counts it from interpreter start). The Hermes reporter
stamps its claims in its long-lived plugin process from time.monotonic_ns(),
kept strictly increasing by a counter. The launch-process rule decides which process may
set the launch identity, and the worker alone applies it; the daemon holds an
imported launch identity to the core ancestry matcher. identity-subagent-v1
uses NamedForProvider (the earliest process at or below the PTY root whose
executable is named for the provider, or its declared hook-helper child), which
is also the core default for a session without a schema, never reached because
such a session admits no hook report. identity-v1 uses FrozenRootEntrypoint:
only the PTY root, reporting in process, whose report’s kernel peer must be the
root’s PID and start time, so a report naming the root from any other process,
a descendant included, is refused; and only while its command line, executable
and entrypoint files match what the worker froze before spawning it, which the
worker rechecks for every later native reference of the root. The public
session.report_native_id fallback applies the same rule by attestation: it is refused for every report of a session on this schema (see the native-id report attestation rules below the schema section). Core keeps peer binding,
expiry, and self-target validation for every schema; a schema only chooses
which of the other rules applies and with which finite enumeration. The daemon
sends the
resolved schema id to the worker in Initialize.hook_schema (an additive
optional field of the private worker protocol), the worker journals it as
hook_schema and returns it in its inspect snapshot, and the daemon validates
the imported identity, release, and subagent state with it. A schema the
worker journaled always outranks the one the session’s pinned runtime resolves
to. A worker that predates the field journals none; the daemon then validates
its state with the schema its session’s pinned runtime resolves to (for a
package whose descriptor predates hook_schema, the schema its handler drives
when exactly one does). A report from an asset set newer than a session’s
schema (a subagent hook reaching a session on identity-v1) is refused, and
the reports of an older asset set stay admitted by a newer schema.
The schema is also enforced on the public socket, the fallback the managed
hooks use when the worker refuses a report. session.report_agent,
session.release_agent and session.report_native_id answer recorded: false
/ released: false for a session whose runtime has no schema, whose schema
does not admit the action, or whose provider is not admitted, and
notification.create answers hook_not_admitted for a create that
names a session and a hooked provider (shell, codex, claude, hermes) on
such a session. The provider is judged by the name the record would be stored
and ranked under (control characters dropped, whitespace collapsed), and a
create whose raw provider only normalizes to a hooked provider (for example
" codex") is refused with hook_not_admitted whatever the session’s schema.
A session’s own runtime is always an admitted provider for identity reports. No other public wire shape changes.
Native-id report attestation. session.report_native_id is attested before any session state is read. The daemon takes the kernel peer of each local Unix connection once, when it is accepted (SO_PEERCRED on Linux, LOCAL_PEERCRED with LOCAL_PEERPID on macOS, with the process start identity read at that moment); a remote TCP connection carries no peer. A report is admitted only when all of these hold: the peer is present; its user is the daemon’s user; its process id and start identity are the ones the report names; and the peer is still that process when the report is decided, checked again immediately before the native reference is written. The report then goes through the ordinary checks, and only the session’s PTY root can pass them. A report that fails the attestation, or any report for a session whose hook schema takes its launch identity only from the worker (identity-v1, FrozenRootEntrypoint, even from the root itself), returns the error native_report_unattested (class runtime), and its message names no request value. The recovery hint says to report the native reference through the worker hook socket from the launch process. A report that passes the attestation but fails a later check keeps the existing recorded: false. Each refusal writes one warn log record, session.native_report.unattested, with a reason of no_peer, peer_uid, peer_not_root, peer_identity_changed or frozen_root_schema; the record carries the session id and no request value. Hook processes are children of the agent, so the shipped Claude and Codex hooks’ daemon fallback is refused, and the Hermes reporter’s fallback is refused by the identity-v1 rule; a native reference is recorded through the worker hook socket. No protocol version changes.
Rule for runtime packages without an integration handler: such a package gets
terminal launch and activity detection. It gets resume and fork only when its
agent CLI accepts a caller-chosen session id and the definition declares
strategy = "assigned"; with hook or none it is not resumable. A package
cannot obtain a reference through session.report_native_id, because that
method accepts only a report from the launch process itself; an assigned
runtime that declares an integration also takes reported references (see
below).
assigned takes, in addition to a supported [resume] with
reference_kind = "id" (core can generate an id, not a path):
[native_reference]strategy = "assigned"launch_args = ["--session-id", "{reference}"] # passed at launchassign_fork = true # optional; forks get one too
[native_reference.existence] # required, explicitcheck = "file" # or "none"root_env = "PI_CODING_AGENT_DIR" # config home variableroot_home = ".pi/agent" # used when the variable is unsetdir = "sessions" # directory below the rootfile_name = "_{reference}.jsonl" # exactly one {reference}name_match = "ends_with" # or "exact"max_depth = 1 # directory levels below dirstore_env = "PI_CODING_AGENT_SESSION_DIR" # optional; names the store itselfstore_max_depth = 0 # required with store_envrelocating_args = ["--session-dir"] # optional; refused at launchrelocating_settings = [ # optional; refused at launch { base = "root", path = "settings.json", key = "sessionDir" }, { base = "cwd", path = ".pi/settings.json", key = "sessionDir" },]launch_argsfollows the same whole-token{reference}rules and bounds as[resume] args. At launch the daemon generates a fresh hyphenated UUID (never taken from a client or user), appends the renderedlaunch_argsafter the runtime’s fixed arguments and before the initial prompt argument, and stores the reference in the session and its recovery binding before the agent starts. Resume and fork use the declared[resume]/[fork]argv with that reference; the start flag is never repeated on resume.assign_fork(a boolean, defaultfalse, accepted only withassignedand[fork] supported = true, otherwisedescriptor_invalid) gives every fork a reference of its own, for an agent that acceptslaunch_argstogether with its fork arguments.session.forkgenerates a fresh UUID for the fork and appends the renderedlaunch_argsafter the rendered[fork]args (Pi:pi --fork <source> --session-id <fork>); the fork holds that reference with provenanceassignedfrom its creation on, so it resumes like a fresh launch and its own existence check guards its recovery. Withoutassign_forka fork holds no reference until a validated report names one. The flag is frozen into the session’s native launch spec, so a session keeps the fork behavior it was launched with.- The binding records a provenance:
assignedfor a generated reference,reportedfor one a validated report delivered (also the value for records written before the field existed). An assigned reference is not identity evidence: it creates no ordering key and takes no part in ancestry, sequence, expiry or pid-identity validation, which stay mandatory for reports. Any write of the stored reference by a validated report labels itreported, so the provenance is never stale. - An assigned reference goes stale when the user changes conversation inside the
agent (for example
/clearor an in-session resume), and it names nothing when the agent never wrote the conversation. Beforesession.resumeorsession.forklaunches anything, the declaredexistencecheck runs against the frozen declaration.check = "none"skips verification and relaunches from the reference unchecked.check = "file"lists directories below the config home (the environment variable named byroot_env, elseroot_homebelowHOME; both are read from the environment the agent is launched with: the daemon’s allowlist-filtered base environment, overridden by the session’s profile environment, so a variable orHOMEthe agent would not see is not used), descends at mostmax_depthlevels belowdir(at most 4) visiting at most 50 000 entries, and matches regular files whose name equals (exact) or ends with (ends_with)file_namewith the reference substituted. It runs no shell and expands no glob. It opens the root once and reads every directory through descriptors opened relative to their parent without following symlinks, so a symlink, or a directory swapped for one during the scan, is never entered and fails closed.root_env,root_home,dirandfile_nameaccept only plain components (ASCII letters, digits,.,_,-), so no path,.., glob orPOHUNEK_*variable is expressible. A missing conversation, an unset or relative root, an unverifiable reference or a store over the entry bound fails closed withruntime/agent_native_reference_missing; recovery never falls back to another runtime, to the shell or to “continue latest”. Theends_withform matches any file whose name ends with the rendered text, so the template must start with the separator the agent writes (_above). - The optional relocation keys describe how the agent can move its store, in
the agent’s own order of precedence. When the launch environment sets the
variable named by
store_env(an upper-case name outsidePOHUNEK_*) to a non-empty value, that directory is the store: the scan starts there, skipsroot_env/root_home/dir, and descendsstore_max_depthlevels (at most 4; required exactly whenstore_envis set).relocating_argslists option names (-then ASCII letters, digits and-) that move the store; a launch whose program arguments contain one, as a token or asoption=value, is refused.relocating_settingslists JSON settings files (baserootfor the config home orcwdfor the launch directory,patha relative path of plain components) and a top-levelkey; while thestore_envvariable is unset, a file whose key holds a value the agent acts on (anything butnull,false,0and""), or a path that is not a regular file that parses as JSON (opened without blocking, bounded to 1 MiB, a UTF-8 byte order mark is skipped), refuses the launch; an absent file, or arootsetting below an unresolvable config home, sets nothing. Both lists hold one to 8 entries. A first launch (session.new) of a runtime with native recovery runs this refusal before anything is spawned, andsession.resumeandsession.forkrun it against the session’s arguments and working directory before the existence check; a variable that is set to a relative path is refused the same way. The refusal isruntime/agent_native_store_relocated. At a first launch itsrecoverhint names thestore_envvariable when one is declared (for a relocating argument, which wins over the variable, it says to remove the argument). At resume or fork the launch passed the same refusal, so something changed since (a setting or argument appeared, or the variable the launch had is gone after a daemon restart or an accepted profile edit), and the hint says to undo that change: remove the setting or argument, or restore the variable to the absolute path the session was launched with and accept the profile change. A crash of the check itself isdaemon/native_store_check_task_panicked, and nothing is launched. The keys are frozen with the rest of the declaration, so a session launched before they were declared keeps searching only the declared store. - A hook runtime may declare the existence check recovery runs on an id-kind
reference under
[native_reference.existence](same shape as theassigneddeclaration; a path-kind hook reference cannot carry one). Beforesession.resumeorsession.forklaunches, the declared check runs with the same rules as below. The official Claude Code package declares it: its hook reference is checked against a regular transcript file in theprojectstree below the runtime’s effective declared config home. An absent or unverifiable file returnsruntime/agent_native_reference_missingwithout a new worker generation or fork child; a crash of the check returnsdaemon/native_store_check_task_panicked, also before anything launches. - A fork starts a new conversation whose reference core cannot learn without an integration report, so the child holds no reference and is not resumable until its own launch process reports a validated target.
- A host profile on an
assignedbase inherits the assignment; one that restates[resume]is rejected withinvalid_profile, andresumable = falseswitches recovery off (no reference is generated). - A reported reference always supersedes an assigned one. An
assignedruntime that also declares an[integration]handler and hook schema may report, and the report replaces the stored value and sets the provenance toreportedin the session, its durable record and its resume binding, so a/clearor in-session resume inside the agent is followed instead of leaving a stale id. Two reports reach the daemon.session.report_native_idreplaces the reference when it passes the caller attestation and the usual process-identity, expiry and sequence checks (a refused report leaves the assigned reference in place). The worker’s launch claim replaces it even when the value is equal, which only confirms it, and the worker’s journaled native reference replaces it: the worker keeps the latest reference reported by the verified launch process (below a wrapper process, the provider process it verified) with its hook sequence, independently of the active claim’s lease and of a release, so a switch made while the daemon was down is still read when it returns. The reference must name the runtime itself, never a nested process, carry the declared reference kind and be newer than the last worker report accepted for the runtime generation. The journaled reference is trusted by its generation binding (the record’s own worker instance and the verified launch process), never by whether that process or its wrapper is still alive; an exit, an explicit stop and a lost worker read the generation’s final snapshot before they commit. A public report is ordered against the worker’s journaled claim at acceptance: the daemon inspects the worker first, and the observed claim is older than the report whether or not it was imported yet (a worker that cannot be inspected makes the report retryable). A worker that crashed or is unreachable still hands over the reference of its own generation’s journal when the session is classified, stopped or reconciled. A report of the launch process that arrives while the worker is still verifying it is kept and promoted with the claim, and a session whose runtime ended while the daemon was down imports the journaled reference too, when it belongs to the record’s own worker instance and the verified launch process. A worker that journals no such reference is read through its launch and active claims instead, for as long as the active claim’s lease lasts. Every replacement carries an ordering key, and a recovered session keeps the key of the reference it recovers. Sequences are compared only within one transport and one runtime generation (worker claims and the public reports of the shipped hook reporters are stamped from different clocks), so a public report is never stale because of a worker claim or the other way round, and a recovered or restarted generation starts fresh: its first launch claim supersedes the reference it was relaunched with. A report older than the stored one of its own transport, an expired or overlong claim, or a claim from another runtime never replaces a reference, and an assigned value never replaces a reported one: relaunches, daemon restarts and rebinding start from the stored reported reference. Resume and fork then launch with the reported reference. The existence check runs only for anassignedreference, so a reported one is recovered unchecked, as the agent vouched for it. Ahookruntime uses the same generation, verified launch process, provider, frozen kind and sequence checks for the worker’s journaled native reference, so an in-agent conversation switch can replace its first immutable launch claim. A package without an integration cannot send a report, so its reference staysassigned. - A forked session of an assigned reference still shows the frozen
capabilities.resume = truebut holds no reference, sosession.resumeanswersnot_resumablefor it. - The values in the example are those of the repository’s
pipackage (runtime-packages/pi), verified against a real Pi. Verify any other runtime’s flag and on-disk layout before declaring them.
Version probe
Section titled “Version probe”A runtime definition may declare a version probe in [runtime]:
version_probe = { parser = "semver-v1", args = ["--version"], min = "1.0.0", below = "2.0.0" }parser names a compiled parser. semver-v1 is data-driven: args (one to
four tokens) is the probe argv, and [min, below) is the supported release
range, so a package update can move the range without a daemon release. The
official packages declare min as their tested minimum (the verified release,
except Pi, which accepts from 1.0.0 and is verified on 1.0.2) and below as
the next major release (codex [0.160.0, 1.0.0), claude
[2.1.289, 3.0.0), hermes [0.20.0, 1.0.0), pi [1.0.0, 2.0.0)), because hosts
update the agent CLIs independently of pohunek. The
declaration is rejected unless args, min and below are all present, min
is below below, and each bound is exactly MAJOR.MINOR.PATCH. The daemon
compiles only the data-driven semver-v1 and semver-line-v1 parsers; a
descriptor that names any other parser id still parses, but its runtime reports
supported: false and a launch is refused with agent_runtime_unsupported.
semver-line-v1 is the same data-driven parser for a runtime whose banner
wraps the release in fixed text. It additionally requires line, a literal
template PREFIX{version}SUFFIX with an optional trailing {annotation}
({version} exactly once; 1 to 128 printable ASCII bytes; no other braces; no
leading or trailing space; the prefix must not end in a digit or ., the
suffix must not start with a digit, ., - or +; at least one literal
character next to {version}). The official runtimes read as
codex-cli {version} (codex-cli 0.160.0), {version} (Claude Code)
(2.1.289 (Claude Code)) and Hermes Agent v{version} {annotation}
(Hermes Agent v0.20.0 (2026.8.3)). The trimmed first output line (at most
256 bytes) must match the whole template; later lines are ignored. The
template is literal text, not a pattern, and is matched without backtracking.
line is rejected unless the parser is semver-line-v1 and is required by it;
an invalid template is rejected when the package is validated, naming
runtime.version_probe.line. Pre-release and build suffixes
(0.160.0-rc.1) never match: a pre-release cannot be ordered against
[min, below), so such a runtime is unsupported.
Before session.new, session.resume and session.fork, and for the host.inspect runtime inventory
(version, supported), the daemon resolves the program once, runs it with the
declared argv, and parses the trimmed first line of its standard output as one
release (ASCII digits only, no leading zeros, no pre-release or build suffix;
bare for semver-v1, inside the line template for semver-line-v1).
Standard error is discarded. The probe runs with a cleared environment, a
private HOME, XDG and temporary directories and a private working directory,
a 2 second deadline and a 4 KiB output cap; only PATH is inherited from the
daemon, so an interpreter script such as #!/usr/bin/env node resolves the
interpreter from the same PATH that the default agent environment allowlist
forwards to the launched agent (an allowlist without PATH leaves the agent
without one). A program that does not resolve to an executable refuses the launch with
agent_binary_missing before any probe runs. Every other refusal is
agent_runtime_unsupported, and the exact probed executable is the one
launched. Its message tells the cases apart without echoing probe output: a
release outside the range names the parsed release and the declared
[min, below) (reports version 1.0.0, outside its runtime's supported range [0.160.0, 1.0.0)); output that exits successfully but does not match the
grammar says the output does not match the runtime’s declared format; a
failing or timed-out probe, or a parser id this daemon does not compile in,
keeps the generic message. The first two carry a recover hint. pohunek doctor reports the same three cases in the profile and optional-runtime
checks.
SessionInfo
Section titled “SessionInfo”Important fields:
id: stable session id.external: optional bool.falsemeans a normal pohunek-owned PTY session;truemeans an opt-in observed external agent. External sessions are read-only: attach, input, resize, stop, remove, rename, metadata updates, and resume returnruntime/session_external_read_only.capabilities: requiredSessionCapabilitiesobject withresumeandforkbooleans frozen for the logical session. Both derive from one frozen native-session launch spec (reference kind, resume argv, optional fork argv), soforkistrueonly whenresumeistrue. Clients must use these flags instead of inferring provider behavior fromagentoragent_base. Records that predate the field load both flags asfalse.session listandsession inspectreport a flag asfalse, even though the frozen value istrue, when the session’s recovery resolution refuses it at the time of the request: the runtime it was launched from is not served, its package fails verification, or the serving runtime cannot honor the frozen launch spec. Refusals that depend on the session’s current state (a live runtime) or on checks of one request (a changed profile, the native reference) come only from the operation itself. Host profiles declare the spec in[resume](reference_kind,args, optionalfork_args, accepted only on bases whose runtime declares a native fork, such asclaude); see the agent-profiles concept page. The capability object itself is unchanged in this release: it stays strict, and the refusal reasons ride incapability_refusals.capability_refusals: optional{resume?: string, fork?: string}object onsession listandsession inspectresponses, present only when the daemon cleared a frozentruecapability. Each value is the error codesession.resumeorsession.forkreturns for that recovery resolution (for exampleruntime_served_by_package,runtime_builtin_removed,runtime_not_installed,runtime_incompatible,agent_not_resumableoragent_fork_unsupported), derived from the same recovery resolution that clears the flag, so for a refusal of that resolution the listing and the operation name the same reason. A frozenfalsecapability carries no reason. Stored records, events and other results never carry the field. The object is open: clients ignore operations they do not know. Protocol 3 connections never receive it. Humansession listoutput adds arefused <id>: resume=<code> fork=<code>line, andsession inspectacapability_refusalsrow.name: optional owner-set display name; absent means the session is shown by its id. Set atsession.newand changed viasession.rename.agent: profile name.agent_base: theRuntimeRefof the runtime backing the session, for exampleshell,codex,claude, orhermes; a historical label outside the runtime-id grammar remains presentation-only.active_agent: optional runtime agent profile currently active inside the session. Present for nested agents reported through hooks or inferred from process facts.active_agent_base: optionalRuntimeRefof the runtime backingactive_agent.active_agent_pid: optional process id backingactive_agent. When present, the daemon validates it with kernel process-start identity and auto-releases the active agent if that exact process exits. Foreground reconciliation uses the terminal PGID as the primary selection hint; structured daemon diagnostics report the selectedfocus_pidandforeground_pgidwhen either changes.active_agent_session_id/active_agent_session_path: optional native metadata for the active nested agent. These fields are display/runtime metadata only and do not make the parent session resumable as that nested agent.subagents: provider-managed child agents observed through current Claude and Codex lifecycle hooks. Each item containsid, optionalparent_id,provider, optionalagent_type,lifecycle, optional coarseactivity, a decimal-stringrevision, worker timestamps, and optional terminal time. Multiple children may run concurrently. The bounded recent history survives daemon/client reconnects; running children becomelostwhen the owning PTY runtime ends. A terminal child returns torunningwith a newrevisionwhen its provider starts it again (a resumed or self-woken Claude background agent):finished_at_msis cleared,started_at_mskeeps the first run’s time, andupdated_at_msmarks the reopen. A delayed claim older than the latest one the worker admitted for a child never changes that child. A ClaudeSubagentStopwithout a recorded start (the prompt-suggestion helper Claude runs after every main turn) produces no item; a Codex one records a completed child. At the end of every Claude main-agent turn the rows of that Claude process are reconciled against Claude’s own list of running background tasks. The list only settles rows it proves ended: a running row missing from this list whose newest listing by the same process falls within its run and is older than this list becomescompleted, and any other missing running row becomescompletedonly when the list holds no task that can host an unlisted agent and the same process’s newest list is older than this one and newer than the row (Claude builds a list before the hook reads its clock, so only an earlier list proves the agent had started). A list older than that newest evidence, delivered out of order, settles nothing from it. A listed row is left unchanged, whether running or terminal; a self-woken agent reopens through its own start. A completed row keeps its own claim sequence, so a resume start ordered before the list still reopens it, and a row with a newer claim than the list is never touched. A reconciled row has the same shape as any other; the reason is only in the worker log. A row whose Claude process never ends another main turn staysrunninguntil its own stop or the end of the runtime (lost). No prompt, result, transcript path, or raw hook payload is exposed. Subagent rows carry lifecycle fields only; the sanitized task label and shell program name appear inbackgroundinstead.background: optional latest snapshot of the agent’s background tasks, or absent ornullwhen no snapshot is held: no agent has reported one, or the snapshot was cleared because the runtime ended, the reporting Claude process exited or released its session (except for/clear), it started or resumed a session, or another agent process started one. It holds a decimal-stringrevision,observed_at_ms(the worker’s observation time, from which clients render the snapshot age), anditems. Each item hasid,kind,status, optionalagent_type(subagent items), optionallabel(control and format characters removed, whitespace collapsed, bounded), optionalprogram(shell items: the program name only, never its arguments or the full command line),first_seen_ms, andlast_seen_ms.kindis one ofsubagent,workflow,shell,monitor,mcp_task,teammate,dream,auto_mode_scan,memory_import,cloud_session, orother;othercarries the provider’s task type inprovider_kind.statusisrunning,pending, orother.first_seen_msis pohunek’s first observation, not a start time: the task started at or before that instant. A subagent item shares itsidwith the matchingsubagentsrow, which stays the authority for subagent lifecycle. The snapshot is replaced wholesale on every change.cwd: current host-local working directory. It starts as the launch directory and can change while the session runs when procwatch observes the focus process in a new directory or the PTY emits an OSC 7 cwd hint.cwd_source: optional source of the currentcwd:launch,procwatch, orosc7.procwatchis authoritative;osc7is an immediate hint that the next procwatch tick can overwrite if the focus process disagrees.pid: root process id, or the observed external agent process id.runtime: optional durable runtime object. It is absent for observed external sessions and peers predating worker-backed sessions.runtime_generationis a canonical unsigned decimal JSON string, not a JSON number.runtime.stateisstarting,live,reconnecting,terminal,lost,conflict, orincompatible;worker_ididentifies the PTY owner andworker_instance_ididentifies the PTY generation.started_at,last_connected_at, andloss_reasonare optional. Daemon reconnection preserves both identities; explicit native recovery changes them.cols,rows: current PTY size. External sessions have no PTY and report0x0.state:starting,running,stopped,done, orfailed.state_source:osc_title,osc_progress,screen,process, orreport.activity: optionalworking,blocked, oridle.native_session_id/native_session_path: optional agent resume binding. These belong to the verified launch agent and follow its accepted conversation switches; nested active-agent reports cannot replace them. A fork reads the source’s current target but starts without a recovery reference of its own. Hermes resumes only when this valid reference exists, ashermes chat --resume <reference>; it never infers an ambient Hermes session.native_last_activity_at: optional RFC3339 timestamp of the selected conversation’s verified regular transcript file modification (the check the session’s runtime descriptor declares, currently the official Claude Code package).session.inspectandsession.listderive it on read without following symlinks. It is absent when no native file is verified and never falls back toupdated_at. Humansession.inspectlabels target presencenative_binding; the authoritative recovery decision is made bysession.resumeorsession.fork. Humansession.listshows the target and timestamp in arecoverydetail line for every session with a native binding, using<unknown>when no file activity is verified.project_id,project_label,repo,branch,worktree_path: optional git and project context for the currentcwd. A cwd change re-resolves this context. When a session leaves every known active worktree,worktree_pathis cleared;repoandbranchremain populated when git detection still finds a repository at the new cwd.warnings: non-fatal warnings, each{kind, message, detail?}. The Rust client of this build drops a warning whosekindit does not know, so a newer daemon can add kinds without breaking its decode; earlier Rust clients reject an unknown kind. A daemon of this build that loads a store record written by a newer daemon drops such a warning too and keeps the session; when it later rewrites that session’s record, the dropped warning is not kept, so a later upgrade does not show it again.kindisfetch,base_branch_fallback,setup_scriptorhookfor worktree setup.base_branch_fallbackmarks a requested base branch that was not used: either it did not exist and the repository’s default branch was used, or the session’sbranchalready existed locally at another commit and was checked out as it is, withdetailnaming both commits.kindisnative_recoveryfor a session whose stored native recovery record could not be completed after an upgrade: it cannot resume or fork,messagesays to start a new session and resume the native conversation, anddetailcarries the stored native reference.hookalso marks a launch (new, resume or fork) whose agent’s installed Claude or Codex hook assets differ from the ones the daemon embeds (an older version marker or changed content):messagenames thepohunek integration install --agent <agent>command (with--profilefor a host profile’s home) anddetailcarries the status warnings; the session still starts, and an agent whose hooks are not installed carries no such warning. For a failingpre-createorpost-createlifecycle hook, thesession.newresult’sdetailends with; stderr tail:and the lasthooks.stderr_tail_bytesbytes (service.toml, default 4096,0disables) of the hook’s stderr: lossy UTF-8 with terminal escape sequences and control characters other than line feed and tab removed, prefixed by[N earlier bytes omitted]when cut. Only that result carries the tail;session.list,session.inspectand the logs keep the detail without it. Hook authors must not print secrets to stderr.output_force_closed: optional boolean, omitted unlesstrue. A terminal session (stopped,doneorfailed) whose PTY output was force-closed carriestrue: a process outside the session’s process group kept the terminal open past the stop deadline, or past the drain window after the root exited on its own, so output written after the close is not retained, while the exit is exact. The runtime ends this way only after the worker swept the runtime’s marked processes, as described forsession.stop. It is additive in protocol 4; the protocol 3 adapter drops it.metadata: owner-controlled strings; must not contain secrets. The daemon treats every key opaquely; clients own the convention. One such client-defined convention is thelink.*key family (link.provider,link.kind,link.id,link.url,link.branch) written by external clients (such as the native GUI) and the launch scripts to tie a session to a work item — no protocol surface is dedicated to it.created_at,updated_at: RFC3339 timestamps.exit_code: optional process exit code.
RuntimeRef wire values (agent_base, active_agent_base, the subagent
provider, the inventory agent_base, the integration agent and the
notification agent_kind) are forward-compatible for presentation. An unknown
string round-trips through Rust and TypeScript clients as a neutral value.
Agent-targeted mutation, resume and fork resolve the value through the daemon’s
runtime registry: a value outside the runtime-id grammar is rejected with
runtime/agent_kind_unsupported, and a grammar-valid id that no enabled runtime
resolves is rejected with runtime/runtime_not_installed. A session’s
active_agent_base of another runtime than the session’s own (an agent a shell
session hosts in its foreground) is only checked against the grammar: mutations
act on the session’s own runtime, so a shell session stays interactive while
the hosted agent’s package is missing or uninstalled. Unknown values never
silently become a supported launch, resume, or fork runtime. A session or
recovery record whose runtime is not installed is kept inert: it stays listed
with its stored recovery binding, refuses resume, fork, input and every other
mutation with runtime_not_installed (or agent_kind_unsupported for a
historical label), and resumes again once the runtime is installed. The
exception is a session launched from the built-in runtime of an official alias
this daemon does not compile in (the built-in Codex, Claude Code or Hermes of
an earlier release): it is refused with runtime/runtime_builtin_removed while
no package serves the alias. The official package then adopts the session and
resume and fork run through it when the recovery contract is honored; otherwise
they are refused with runtime/runtime_served_by_package.
session.stop and session.remove (also
session.remove_accepting_unconfirmed) are the exceptions: they only release
what the session owns and need no runtime definition, so the owner can end the
session’s worker and delete the session while its runtime is missing. Removal
keeps its supervision rules (a worker not proven gone refuses), and an external
session stays refused with session_external_read_only. The
binding records the runtime identity (LaunchBinding) the session was
launched with; a binding written without one may only resume through a
built-in runtime. A session launched from an installed runtime package pins the
package archive digest and resumes only from exactly that digest, verified
again; installed content that is missing, modified or contradicts the pin is
rejected with runtime/runtime_incompatible and never replaced by another
package or a built-in.
A launch resolves its agent through a launch source. The owner-local source
accepts a host profile name or an installed runtime id. A relay-selected launch
(connected by the HostShare work of #82; no relay caller exists yet) resolves
only a locally approved host profile by name and approved revision. It cannot
name a runtime id, package, program or argv: a bare runtime id is rejected with
runtime/agent_profile_not_found, and a profile whose file, detection
manifest, base-runtime launch binding or effective program and arguments
changed after approval is rejected with runtime/agent_profile_revision_stale.
A revision is a keyed MAC (host-local secret), 64 lowercase hex digits, so it
reveals nothing about profile [env] values. A malformed revision is
runtime/agent_profile_revision_invalid; an unreadable host key is
runtime/agent_profile_revision_unavailable (no unkeyed fallback).
Frozen profile revision. A session launched from a host profile freezes the
profile’s keyed revision (the same MAC a relay approval uses) into its resume
binding when it is created, so the binding names which profile the session ran
under without storing its [env]. session.resume and session.fork resolve
the profile once and use that single resolution for the version probe, the
native-reference check, the launch and the relaunched session’s snapshot. They
relaunch only while the profile’s current revision equals the frozen one:
| Frozen revision | Current resolution | Outcome |
|---|---|---|
| present, equal | profile | relaunch under the resolved profile |
| present, different | profile | agent_profile_changed |
| present | no profile, or the bare runtime | agent_profile_missing |
| absent (legacy binding) | profile | agent_profile_changed |
| absent | profile that does not resolve | agent_profile_missing |
| absent | bare runtime | relaunch without profile env |
The owner’s decision accept_profile_change relaunches under the current
profile’s env and detection manifest and freezes its revision, so a following
resume without the decision succeeds. The launch shape frozen at creation
(program, arguments, native resume spec, input rules, runtime pin) is not
re-derived from the edited profile. A missing profile is refused even with the
decision, because there is no current profile to relaunch under. The decision is
accepted on the local control socket only (agent_profile_change_local_only
otherwise); the control socket does not distinguish the operator from an agent
running inside a session on the same host. plugin profile migrate and
package.bind_profile rewrite the profile text. An adopted built-in session keeps
its frozen revision over the migration pin (the package and digest keys), so
it resumes without the decision; any other rewrite of the text needs the decision
once for the existing sessions of that profile. The optional profile_revision field of a
resume binding (store schema 3 and later; the current store schema is 10) is
absent for sessions without a profile and for bindings written by schema 2.
A host profile over a package-served base carries the top-level keys
package (package id) and digest (sha256: plus 64 hex characters), both or
neither; a profile over a built-in base carries neither. The profile resolves
from exactly that digest, so package.select and an update of the package do
not change what it launches and do not change its revision, while a migration
that changes the digest does. A package-served base without the keys is
runtime/invalid_profile with the migration command
pohunek plugin profile migrate <name> in its message. A fresh launch through
a pinned profile still requires its package to be enabled and verified
(runtime/runtime_not_installed or runtime/runtime_incompatible); a session
already pinned to the digest resumes. A digest pinned by a host profile counts
as referenced for package.uninstall, package.list and package.doctor, and
host.inspect reports a pinned profile available: false while its package is
disabled, uninstalled or fails verification.
Agent runtime identities use two Rust types over the same string namespace.
The fields listed above (agent_base, active_agent_base, the subagent
provider, the inventory agent_base, the integration agent and the
notification agent_kind) are RuntimeRef values, so their TypeScript type is
the RuntimeRef string alias and their wire form is a bare string; the daemon
validates them against the registry before a mutation.
RuntimeId is always valid: lowercase ASCII alphanumerics plus ., _ and
-, 1 to 64 bytes, no leading . or -, no ..; deserialization rejects
anything else. RuntimeRef is lenient: any string deserializes, a
grammar-valid value is an Id and every other value is a historical label,
displayable but rejected with runtime/agent_kind_unsupported (a fixed message
that never echoes the label). The wire form is a bare string, so a reference
round-trips to the same kind. Id does not mean launchable: installation is
decided by the daemon registry at resolve time, and a valid RuntimeId that no
enabled runtime resolves is rejected with runtime/runtime_not_installed.
PackageId, PackageVersion, PackageDigest and DescriptorDigest
(sha256: plus 64 lowercase hex characters) identify where a runtime
definition came from. PackageIdentity pairs a package id with its version.
LaunchBinding pairs a RuntimeId with a provenance: builtin carries an
optional package identity (id and version together or not at all) and a
descriptor digest that covers only structural launch fields and is not a
package digest, package carries a package identity and a package digest.
All are TypeScript strings except PackageIdentity, LaunchBinding and
BindingProvenance.
Session Observation
Section titled “Session Observation”Observation is available only for Pohunek-managed terminals. It does not attach,
take input ownership, or change terminal dimensions. runtime_generation, all
output offsets, terminal watermarks, background revisions, process-start
identities, and report sequences are canonical unsigned decimal strings. They remain exact beyond
JavaScript’s safe-integer range and reject signs, whitespace, overflow, and
redundant leading zeroes.
session.screen accepts:
{"session_id":"s-42"}A successful result has this exact shape (optional title and progress may be
omitted):
{ "session_id": "s-42", "worker_id": "worker-1", "worker_instance_id": "runtime-1", "runtime_generation": "3", "watermark": "7", "dimensions": {"cols": 80, "rows": 24}, "cursor": {"row": 1, "col": 4, "visible": true}, "alternate_screen": false, "title": "terminal", "visible_lines": ["pohunek"]}Visible lines are plain UTF-8 terminal cells with control sequences removed.
The protocol ceiling for the serialized result is
MAX_SESSION_SCREEN_RESPONSE_BYTES (1,048,418 bytes), derived from the 1 MiB control-line cap
with response-envelope headroom. The daemon additionally defaults to at most
200 rows and 500 columns. Oversize results return the payload-free
runtime/session_output_limit_exceeded error.
session.detection accepts the same single-session parameter shape:
{"session_id":"s-42"}It asks the live detector task to render its active manifest regions on demand; previews are not copied for every PTY output chunk. The result lists every region kind supported by this engine, then only the regions required by the active manifest in manifest order:
{ "session_id": "s-42", "supported_regions": [ "osc_title", "osc_progress", "whole_recent", "bottom_lines", "bottom_non_empty_lines", "top_non_empty_lines", "last_non_empty_above_prompt_box", "after_last_prompt_marker", "prompt_box_body", "after_last_horizontal_rule" ], "previews": [ { "kind": "top_non_empty_lines", "region": "top_non_empty_lines(8)", "text": "Do you trust the contents of this directory?" } ]}Parameterized manifest syntax is bottom_lines(N),
bottom_non_empty_lines(N), or top_non_empty_lines(N); the preview’s
region preserves the canonical count while kind stays count-independent.
top_non_empty_lines(N) returns the first N non-empty visible rows.
last_non_empty_above_prompt_box returns the nearest non-empty row above the
second horizontal rule counted from the bottom, or an empty preview when a
complete prompt box or preceding content is absent. All screen regions use the
same visible-grid, wide-glyph, and soft-wrap semantics as activity matching.
Unknown region names remain a typed manifest parse failure, so older engines
reject manifests that use regions they do not implement instead of silently
over-matching another surface. A preview observes every accepted detector
configuration update before it renders, so it cannot return regions from the
previous active manifest after an agent report or release succeeds. A stopped
or unavailable detector returns session_terminal_unavailable. If the complete
success envelope would exceed the 1 MiB control-line cap, the daemon returns the
payload-free runtime/session_detection_response_too_large error instead.
session.read accepts an optional source (visible, recent,
recent_unwrapped, or detection; default visible) and optional lines
(1..=1000, default 1000). Current workers expose only the current rendered
screen and do not expose scrollback, soft-wrap metadata, or one canonical
detection text. Requests for recent, recent_unwrapped, or detection
therefore safely fall back to visible rows and report source_used: "visible".
alternate_screen always reports the captured terminal’s real buffer state, so
callers can recognize alternate-screen fallback without a false history claim.
Line and byte truncation both retain the newest tail.
The result carries exact runtime identity after post-snapshot verification,
canonical decimal-string revision, effective lines_requested, and
truncated. The daemon caps the complete JSON-serialized result at
MAX_SESSION_READ_RESPONSE_BYTES (1,048,418 bytes). format: "ansi" is
reserved for future raw capture support and currently returns
runtime/session_read_ansi_unavailable.
session.output uses an optional nested runtime identity and an exclusive
cursor. Omitting after_offset requests the newest retained tail. A cursor
requires its exact runtime identity, and wait_ms requires a cursor:
{"session_id":"s-42","max_bytes":65536}The initial-tail request deliberately has no runtime or offset. Persist the
returned worker_instance_id, runtime_generation, and next_offset before issuing a
cursor-based read:
{ "session_id": "s-42", "runtime": {"worker_instance_id": "runtime-1", "runtime_generation": "3"}, "after_offset": "2", "max_bytes": 65536, "wait_ms": 5000}The result returns standard base64 and every cursor needed to continue:
{ "session_id": "s-42", "worker_instance_id": "runtime-1", "runtime_generation": "3", "history_start_offset": "4", "start_offset": "4", "next_offset": "6", "runtime_end_offset": "6", "data_base64": "AJ8=", "gap": {"start_offset": "2", "end_offset": "4"}, "has_more": false, "timed_out": false}gap is omitted unless the requested retained history was evicted. Continue
immediately while has_more is true. The shared raw-data ceiling is
MAX_SESSION_OUTPUT_BYTES (derived from the 1 MiB line limit after base64 and
metadata headroom) is 783,240 raw bytes; the daemon may configure a lower
positive value. max_bytes
must be 1..=MAX_SESSION_OUTPUT_BYTES. wait_ms must be 1..=8000 and is used
only when the explicit cursor is at the current end. A waiting output read uses
a dedicated SDK connection. In the shown gap result, offsets 2..4 are no
longer retained: callers must discard the old cursor and restart from a fresh
screen or newest tail, never synthesize the missing bytes.
session.wait requires a non-zero timeout_ms no greater than 8000 and at
least one predicate. Runtime-scoped terminal/output cursors require runtime;
after_updated_at is RFC 3339; present states and activities arrays cannot
be empty:
{ "session_id": "s-42", "runtime": {"worker_instance_id": "runtime-1", "runtime_generation": "3"}, "after_updated_at": "2026-08-04T10:00:00Z", "after_terminal_watermark": "7", "after_output_offset": "8", "states": ["stopped"], "activities": ["blocked"], "timeout_ms": 8000}It returns reason, the current redacted SessionInfo, and optional current
terminal_watermark / output_offset. Reasons are state_matched,
activity_matched, session_updated, terminal_changed, output_advanced,
runtime_changed, or timeout. Registration follows snapshot-register-recheck
and holds no registry write lock while sleeping. Each wait uses a dedicated
connection and consumes one waiter slot; defaults are 128 concurrent waiters
globally and 8 per session. When the client closes the connection (EOF on the
read side) while a session.wait, a session.output with wait_ms, or a
session.wait_turn is in flight, the daemon cancels that read-only wait,
releases its slots, and closes the connection without a reply; closing only the write side counts as closing.
Other methods, including session.input with wait, run to completion. The
timeout stays the guaranteed release bound for a peer that vanishes without
closing the connection, and for a client that pipelines another request line
behind the wait: that line is answered in order after the wait’s reply, and
the read side is not watched again until then.
A wake and a timeout use the same result shape; callers branch only on the typed reason:
{ "reason": "output_advanced", "session": { "id": "s-42", "external": false, "capabilities": {"resume": false, "fork": false}, "agent": "shell", "agent_base": "shell", "cwd": "/workspace/project", "cwd_source": "launch", "pid": 4242, "cols": 120, "rows": 40, "state": "running", "state_source": "process", "warnings": [], "metadata": {}, "created_at": "2026-06-17T10:00:00Z", "updated_at": "2026-06-17T10:01:00Z" }, "terminal_watermark": "8", "output_offset": "9"}{ "reason": "timeout", "session": { "id": "s-42", "external": false, "capabilities": {"resume": false, "fork": false}, "agent": "shell", "agent_base": "shell", "cwd": "/workspace/project", "cwd_source": "launch", "pid": 4242, "cols": 120, "rows": 40, "state": "running", "state_source": "process", "warnings": [], "metadata": {}, "created_at": "2026-06-17T10:00:00Z", "updated_at": "2026-06-17T10:01:00Z" }, "terminal_watermark": "7", "output_offset": "8"}The timeout means that no selected predicate changed before the requested deadline. It does not imply a healthy, idle, or terminal session.
| Result field | Type | Notes |
|---|---|---|
reason |
SessionWaitReason string |
The first satisfied reason, or timeout. |
session |
SessionInfo |
Current redacted public snapshot, including capabilities. |
terminal_watermark |
decimal string, optional | Current rendered-terminal revision when a managed terminal is available. |
output_offset |
decimal string, optional | Current exclusive output end when a managed terminal is available. |
Observation errors are stable and payload-free: session_terminal_unavailable,
session_has_no_managed_terminal, session_runtime_changed,
session_read_ansi_unavailable, session_output_limit_exceeded,
session_wait_limit_exceeded,
session_waiter_limit_reached, and worker_feature_unavailable. Restart from a
fresh screen/tail after runtime change. A worker on the immediately preceding
private protocol remains usable for existing lifecycle and attach operations,
but observation returns worker_feature_unavailable.
Turn Waits
Section titled “Turn Waits”session.wait_turn blocks until one of the targeted sessions records a turn
event newer than a cursor, or until the timeout elapses. It is the primitive for
a manager agent that starts sessions and follows them: chain waits with the
returned cursor and branch on the reason. The CLI form is pohunek session wait <target>... [--until <kinds>] [--after <cursor>] [--timeout <duration>] --json
(see docs/cli.md).
{ "session_ids": ["s-41", "s-42"], "until": ["turn_completed", "agent_blocked", "approval_required", "exited"], "after": "t1.17.1791800000000000000.d-4242-1791799000000000000-0", "timeout_ms": 300000}| Param | Type | Notes |
|---|---|---|
session_ids |
SessionId[] |
Required, 1 to MAX_SESSION_WAIT_TURN_TARGETS (32), no repeats. Every target must exist when the call starts, or it fails with session_not_found before taking a waiter slot. |
until |
SessionTurnEventKind[] |
Required, non-empty, no repeats: turn_completed, agent_blocked, approval_required, exited. |
after |
opaque string, optional | The cursor of an earlier result; 1 to MAX_SESSION_TURN_CURSOR_BYTES (256) printable ASCII bytes. |
timeout_ms |
u32, optional |
Non-zero. Omitted means the daemon default (5 minutes unless service.toml [waits] turn_default_ms sets another). Above the daemon ceiling (turn_max_ms, 30 minutes by default) the call fails with session_wait_limit_exceeded; it is never clamped. |
The wait is edge-triggered: it completes only with an event recorded after
after, or after the call started when after is omitted, never with the
current state. A turn event is recorded when its debounce window ends (see
“Event sources”), so a turn whose event was recorded before the call is not
returned, while a stop reported shortly before the call whose window ends
during the call is. An event recorded between two chained waits is returned by
the second one.
With several targets the oldest matching event wins and names its session. The
call sends no heartbeat frames; the result is the only response line.
{ "reason": "event", "event": { "session_id": "s-42", "kind": "turn_completed", "at": "2026-10-12T10:05:00.123456789Z" }, "cursor": "t1.18.1791800700000000000.d-4242-1791799000000000000-0", "progress": [ { "session_id": "s-41", "state": "running", "activity": "working", "last_output_at": "2026-10-12T10:04:59.5Z", "output_advanced": true, "turn_started_at": "2026-10-12T10:01:12Z" }, { "session_id": "s-42", "state": "running", "activity": "idle", "last_output_at": "2026-10-12T10:04:58Z", "output_advanced": true, "turn_started_at": "2026-10-12T10:03:40Z" } ]}| Result field | Type | Notes |
|---|---|---|
reason |
SessionWaitTurnReason |
event, timeout, cursor_reset, or targets_ended. |
event |
SessionTurnEvent, optional |
Present exactly when reason is event: session_id, kind, at (RFC 3339 time the daemon recorded the event, after any debounce), and notification_id when the notification policy kept an inbox record for the same event. |
cursor |
opaque string | Pass it as after to the next wait. |
progress |
SessionTurnProgress[] |
One entry per target, in request order, in every result. |
Each progress entry carries state, activity (omitted when unknown),
last_output_at (the last terminal output this daemon observed, omitted when
none), output_advanced (output arrived after the cursor was issued, or after
the call started without a cursor), and turn_started_at (the last transition
into working this daemon observed, omitted when none). A timeout with
output_advanced: true and activity: "working" reads as “still working, wait
again”; a long-quiet last_output_at reads as “stuck or waiting on an empty
prompt, investigate”.
Reasons:
event: a selected event newer than the cursor was recorded.timeout: the duration elapsed without one. The cursor covers every event recorded so far, so the next wait does not see unselected events again.cursor_reset: the cursor is not one this daemon instance can place: it was issued by another daemon instance (for example before a restart), is older than the retained journal window, or is not a cursor this daemon issued. The call then returns at once with a fresh cursor at the current end and progress. A wait already in progress also resets when events after its position are evicted from the journal while it waits. Events between the old cursor and the reset are not reported; never treat a reset as “no event happened”.targets_ended: every target is in a terminal state (stopped,done,failed) and no selected event is newer than the cursor. It returns at once, because no event can follow without an external resume.
The cursor is opaque. Store it, pass it back, and never parse or construct one; only the issuing daemon instance can place it. Turn events live in a bounded in-memory journal (1,024 events by default) and do not survive a daemon restart.
Event sources:
turn_completed,agent_blocked, andapproval_requiredcome from two trusted producers only: provider hook reports throughnotification.createwith theturn:<session_id>orattention:<session_id>dedupe key, from a provider with a hook schema (Claude, Codex, Hermes) whose report the session’s runtime admits, for a session this daemon holds (the worker relays its hook socket reports the same way); and the daemon projector’sagent_blockedfor a detected blocked activity. A create from any other producer, or naming a session the daemon does not hold, is handled by the inbox only and records no turn event. Trusted signals are recorded whether or not the notification policy keeps an inbox record. With the default policy,turn_completedis disabled for the inbox, sonotification.createstill answersnotification_kind_disabledand no record is listed, yet the turn event is recorded and completes waits.- They follow the attention debounce: an event is recorded when
attention_debounce_secs(5 seconds by default) has passed since the last signal for the same session and kind, so it arrives no earlier than that window after the agent stops, and later when signals repeat. Anagent_blockedorapproval_requiredis dropped when the session resumes work or ends inside the window. Aturn_completedis dropped when the session isworkingwhen its window ends: that is the stale stop of an earlier turn, and the running turn reports its own stop. A turn that starts and stops within the window (a short turn) is recorded once, after its stop. exitedis recorded when a session the daemon holds moves from a running state into a terminal lifecycle state, at the moment the terminal state becomes visible; a wait that sees every target terminal therefore already finds their exits. A session that ended before the daemon started does not record one; a wait on it returnstargets_ended.
Errors: bad_request (invalid params), session_not_found (an unknown target
at the start, or a target removed during the wait),
session_wait_limit_exceeded (timeout_ms above the ceiling),
session_waiter_limit_reached (the turn waiter pool is full), and
daemon_shutting_down (daemon shutdown started during the wait). The turn
waiter pool is separate from the session.wait pool: a call takes one global
slot and one slot on every target, all or none, and releases them when the
call returns or when the client closes the connection during the wait (no
reply is sent then). The timeout stays the guaranteed release bound for a
client that vanishes without closing the connection. session.wait is a
separate method with its own 8,000 ms limit.
A protocol 3 connection does not define the method: the daemon answers
method_not_found, and a current SDK client fails with
daemon/daemon_protocol_too_old before sending it. A protocol 4 daemon of an
earlier release answers daemon/method_not_found.
Active-Agent Hook Payloads
Section titled “Active-Agent Hook Payloads”Managed PTY children inherit these reserved environment values. The worker injects them itself, after any base, profile, or daemon-supplied variable, so nothing else can shadow them:
POHUNEK_ENV=1POHUNEK_SESSION_IDPOHUNEK_WORKER_IDPOHUNEK_WORKER_INSTANCE_ID(identifies one worker instance, the PTY generation that the public session runtime reports asworker_instance_id)POHUNEK_WORKER_SOCKET_PATHPOHUNEK_WORKER_HOOK_PROTOCOL_VERSION(private worker-hook protocol version)POHUNEK_SOCKET_PATHfor daemon-targeted notification deliveryPOHUNEK_PROTOCOL_VERSION(public daemon RPC protocol version required by the provider hooks)POHUNEK_NATIVE_REFERENCE_KINDwhen, and only when, the launch carries a native reference kindPOHUNEK_HOOK_TRACE_PATH(absolute path of the hook failure trace,pohunek-hooks.jsonlin the pohunek log directory; see “Hook failure trace” under Provider Notification Hooks)POHUNEK_HOOK_TRACE_MAX_BYTES(size in bytes at which the state hooks rotate that trace)POHUNEK_BACKGROUND_LABEL_MAX_BYTES(byte limit of a background task label the ClaudeStophook forwards; a missing or invalid value forwards no label)POHUNEK_BACKGROUND_PROGRAM_MAX_BYTES(byte limit of a background shell’s program name; a missing or invalid value forwards no program)
A managed child does not inherit the daemon’s or the worker’s process
environment. Its environment is built from an empty base: the allowlisted
variables the daemon passes from its own environment (the [environment] allowlist of service.toml, by default PATH, HOME, USER, LOGNAME,
SHELL, LANG, LC_*, TMPDIR, SSH_AUTH_SOCK, DISPLAY,
WAYLAND_DISPLAY, DBUS_SESSION_BUS_ADDRESS, and XDG_*), TERM, the agent
profile’s environment, and the reserved values above. TMPDIR names the
session’s private temporary directory,
<cache_dir>/session-tmp/<store key>/<session id> below the daemon’s cache
directory ($XDG_CACHE_HOME/pohunek or the platform cache path), on Linux and
macOS alike. The store key is the first 16 hex digits of the SHA-256 of the
metadata store’s canonical path (its directory resolved through
realpath, then metadata.jsonl), so daemons with different stores that share
a cache directory never share a root. It replaces a TMPDIR the allowlist
forwards, so concurrent sessions never share temporary names; a profile that
sets TMPDIR in its [env] keeps its own value and gets no private directory.
The directory and the directories above it up to session-tmp have mode 0700 and must
be directories of the daemon’s user reached without a symlink; a launch whose
directory fails that check fails with runtime-class session_tmpdir_unsafe,
one whose directory cannot be created, or whose path is not valid UTF-8 and so
cannot be exported, with session_tmpdir_failed, before
any worker starts. A stop keeps the directory and a resume of the same session
reuses it (recreating it when it is gone); a fork gets its own. Every removal
(session.remove, retention, a rolled-back create) deletes it with everything
an agent left in it; a delete that fails is logged with its path and leaves
the removal itself successful. Daemon startup deletes every entry of its own
store’s root that no startup evidence names: a session record, a resume
binding, a store line the daemon rejected that names a session, a worker
runtime slot or a worker’s claimed session (also of an orphaned worker that
keeps running), a service-manager job of the session, or a worker journal.
That is what a crash, a failed delete, or a reset store leaves behind. It
deletes nothing, and logs why, when worker discovery, the service manager’s
job listing (which must list every job it holds), or the journal scan fails
(WARN), or when a rejected store line names no session (INFO). An entry it cannot delete, such as a symlink, is logged and
kept, and startup continues. Other stores’ roots are never entered. Ambient
POHUNEK_*
markers coming from the daemon or the profile are stripped first, so only the
worker’s authoritative reserved values reach the child. Service-manager
variables (NOTIFY_SOCKET, WATCHDOG_*, INVOCATION_ID, JOURNAL_STREAM,
MANAGERPID, SYSTEMD_EXEC_PID, XPC_SERVICE_NAME, XPC_FLAGS,
__CFBundleIdentifier, LaunchInstanceID) and the worker-authentication
tokens POHUNEK_CONTROLLER_TOKEN and POHUNEK_BOOTSTRAP_TOKEN are always
removed, even when a profile or an allowlist would supply them; the token
values never reach agent code. The [environment] table of service.toml also
requires a search_path list: the absolute, normalized, non-repeating
directories the installer resolved and hands to the daemon job as its PATH
(an empty list keeps the service manager’s own PATH; the joined value is
bounded). Each directory is recorded as its canonical path and was trusted when
resolved (owned by the user or root, not writable by group or others). The path is
recorded at install and reaches the daemon job only when the installer writes
the job definition (install, or an upgrade to a different version); upgrades
reuse the recorded list, so a manual edit takes effect only at the next
version-changing upgrade, and an in-place refresh is tracked in #319. This key
raised the service.toml schema_version to 2, the [input] table added
for #496 raised it to 3, and the [waits] table added for #858 (turn_default_ms
and turn_max_ms, the default timeout and the ceiling of session.wait_turn,
each in 5000..=3600000 with the default at most the ceiling; fresh installs
and migrations write 300000 and 1800000) raised it to 4: the current release
refuses to serve a schema-2, schema-3, schema-4 or schema-5 file to the daemon, reads it
only at the pohunek service upgrade boundary, and upgrades it as a migration
that assigns the defaults of the missing keys and tables and keeps every
recorded value. The input write stall bound ([input] write_stall_timeout_ms, 3000 ms on fresh
installs and migrations, 1..=600000) raised it to 5; a schema-4 file is
migrated the same way, with only the stall bound assigned. The [hooks] table
(stderr_tail_bytes, the bytes of a failing worktree-create hook’s stderr kept
for the session.new warning, 0..=32768 with 0 disabling the capture, 4096
on fresh installs and migrations) raised it to 6; a schema-5 file is migrated
the same way, with only the hooks default assigned.
service check and the success reports of pohunek service upgrade name that
migration in an additive config_schema_migration field (the source schema,
2, 3, 4 or 5, or null when no migration runs); the check runs it read-only. The migration stops the daemon before it rewrites
service.toml, carries the exact pre-upgrade bytes in an owner-private backup
digest-verified against its transaction record, and restores those bytes on
rollback; a resume or rollback that cannot prove the exact pre-upgrade bytes
fails with service_config_backup_invalid and changes nothing (see
update after release). A version
1 file still has no migration: it is refused with a message to uninstall with
the pohunek that wrote it and install again. pohunek service install --json
reports the
outcome in an additive search_path object: source (login_shell,
fallback, recorded, unmanaged), entries, shell_used,
shell_defaulted, login_shell_failure (the rendered typed reason when the
fallback list was used), and dropped ([{path, reason}], directories refused
as untrusted); the human output prints a warning: line for a failed login
shell and for each dropped directory. A set but relative $SHELL, or a
non-UTF-8 HOME, USER, or LOGNAME, fails the install
(service_environment_invalid, service_environment_not_utf8). When the
installer cannot resolve the search path, pohunek service fails with the CLI
error code service_search_path_unavailable; an invalid list in an existing
file is service_config_invalid; resuming an interrupted install whose
service.toml names another prefix or version fails with
service_resume_config_mismatch. See
environment resolution. A worker kept
running across an upgrade from the previous private protocol version keeps
starting its session’s children from its own sanitized environment until that
session gets a new worker generation.
pohunekd ensure-official-runtimes is the offline runtime step of the release;
--dry-run computes its plan without changing anything. --dry-run --detect also
plans every runtime of the release catalog for this platform whose program is on
the daemon’s search path, each with the reason detected (a runtime the host uses
keeps its other reasons). --detect without --dry-run is a usage error. A host
without an installed service.toml is planned with the launch environment service install gives its daemon: the default allowlist over the process environment. An
existing service file that is unreadable or malformed still refuses, and a plain
dry run still refuses a missing file. Its
gaps list, one per runtime with runtime, code and a detail that names the
one next step: agent_not_on_search_path (the program is not on the search path;
the runtime is planned only when the host uses it), config_home_missing (the
declared config home does not exist; the package is still planned and the step
never creates the home), config_home_is_symlink (the config home is a symlink; hooks
are installed only into a real directory, so no integration is planned into it and
the step follows no symlink), integration_path_untrusted (the installer refuses the
path: it is not a normalized absolute path to a real directory), a profile’s own
resolution error such as runtime_not_installed (a profile pinned to a package the host
cannot serve, which integration.install would reject), runtime_served_by_other_package (another package serves
the runtime id; it is left untouched), and official_package_unavailable_for_platform
(the release catalog lists no package for this platform; a refusal without
--detect). A gap never blocks an apply, and gaps is always empty without
--detect. integrations lists the config homes the installer reinstalls hooks
into: without --detect, the homes that already hold hooks; with --detect, each
existing home of a planned runtime whose hooks the setup installs, whether or not
hooks are present yet (Pi declares none). Each distinct home appears once. reason
values are profile, session, integration and detected. The report layout
stays at report_version 1.
pohunek service upgrade and pohunek service check of an existing service run
a read-only preflight before any effect by invoking pohunekd upgrade-preflight
of the staged archive. That mode takes no other argument, is an internal contract
between the two binaries of one archive rather than a public client interface,
and prints one JSON document (PreflightReport in crates/service-config,
report_version 1): store (state missing, up_to_date, would_migrate or
refused, with schema_from, schema_to, records, error_code, error) and
sessions (per live session session_id, verdict adoptable,
would_lose_recovery or would_not_be_adopted, a stable code, and a human
detail, plus the owner-set name when there is one), stopped_sessions (the
same verdicts, plus already_not_resumable, for sessions whose runtime has ended
but that keep native resume or fork; at risk under the same flag), runtimes (absent when the release carries no
runtime/ tree; otherwise the EnsureReport of the release’s official runtime
plan with packages, profiles, integrations and refusals), and
unmanaged_workers (ids of live worker journals the store has no record of; the
running daemon does not manage them either, so they never refuse the upgrade),
and profile_warnings (host profiles that load with a reduced guarantee, ordered
by profile, each with a code and a detail naming the profile and its
manifest; the code profile_without_screen_readiness marks an override manifest
without the idle rule on a screen region that its screen_idle base needs). The
field is omitted when empty and never refuses the upgrade or changes a verdict. A session that is neither adoptable nor already_not_resumable fails the command with the CLI error
code service_upgrade_sessions_at_risk unless --accept-runtime-loss is given;
a refused store fails it with service_upgrade_store_unusable and a preflight
that cannot run or be parsed with service_upgrade_preflight_failed, neither of
which the flag overrides. The official runtime step adds five codes, none overridden by --accept-runtime-loss: service_upgrade_runtimes_refused (the release’s plan cannot be applied; nothing was stopped), service_upgrade_runtimes_failed (the release’s runtime step did not produce a finished report), service_runtime_backup_invalid (a registry, catalog state or host profile cannot be backed up exactly), service_staged_runtime_invalid (the release’s runtime/ tree is not the layout the release assembles, or a sealed release lists a runtime/ file it does not carry), and service_upgrade_runtimes_unconfirmed (the step’s daemon run could not be ended and its exit confirmed, so the upgrade is kept and not rolled back). The --json success reports of both commands carry
preflight (the report, or null when no preflight ran) and
accepted_runtime_loss (boolean, true only when sessions were at risk and the
flag accepted them). The preflight reads the store, the worker
journals and the process table only; it never connects to a worker socket. A
live session whose worker journal names a release before 0.33.1, or no release,
is would_not_be_adopted with code worker_marker_legacy. A live session that
can resume or fork natively and whose launch pin names a runtime source the new
binary does not serve (resolved as resume resolves it, against that binary’s
built-in runtimes and the installed packages: a built-in it does not ship, or a
package digest that is not installed) is would_lose_recovery with code
runtime_source_not_served; its detail names the resume error and the
migration to a new session. A built-in pin that the official package serving its
alias adopts is adoptable instead. Every
live session, whether or not it can resume, is also judged with the mutation
guard: one whose runtime resolves nowhere on the new binary (a built-in it does
not compile in while no package serves the alias, runtime_builtin_removed, or
a runtime that is not installed) is would_lose_recovery with the same code,
and its detail says every interaction (input, attach, resize, rename,
metadata) is refused until the runtime is installed, naming the official
package install for a removed built-in. A live or stopped session whose host
profile resume would refuse (the profile is missing, or its frozen revision
changed by more than the migration pin) is would_lose_recovery with resume’s
code (agent_profile_missing, agent_profile_changed or
agent_profile_revision_unavailable), and a stopped session record the new
binary cannot load is would_lose_recovery with record_unreadable. A session
whose frozen program resume would not find is would_lose_recovery with
agent_binary_missing. A stopped session whose conversation is absent from its declared store
(agent_native_reference_missing), or whose stored reference is malformed, is
already_not_resumable with resume’s code when the upgrade does not change the
check, which then needs no flag. A relocated store (agent_native_store_relocated)
or a store-condition refusal (agent_native_reference_missing from an unresolvable
root, a scan limit or a changed store) is would_lose_recovery with resume’s code,
because the running release may detect it differently. A reported reference (one the runtime reports rather than the launch
assigns) keeps its check across the upgrade only when the preflight can show it identical: a package pinned to the session’s digest, which
content addressing fixes, or a built-in that the upgrade adopts from a 0.34.0 to 0.34.2 store (store schema 5) whose adopted package declares
the existence check of the v0.34.2 golden. Any other reported reference is would_lose_recovery with resume’s code and a detail that names the
reason: a built-in the upgrade does not adopt, an adopted package from a store that is not schema 5, or a package whose check differs from the
golden. Hermes has no golden here, so its reported references are fail-closed too. A stopped session whose reference the preflight budget
reached before its check is would_lose_recovery with reference_unverified_within_budget: the reference is not known to be present, so it is
never adoptable, and the detail gives the number of such sessions in the report. The budget is 20 seconds by default, bounds the version probes and
the reference checks together, and is raised with service upgrade|check --preflight-budget <SECONDS> (1 to 300; the CLI’s preflight deadline
grows with it). The flag reaches only this release’s upgrade preflight: a rollback’s preflight runs the previous release’s daemon with the default
budget. The flag is sent to the daemon as --probe-budget-secs only when it differs from the default. An installer user (packaging/install-daemon.sh) cannot pass a budget, so it runs the archive’s own commands: <archive dir>/pohunek service check --preflight-budget <SECONDS>, then <archive dir>/pohunek service upgrade --from <archive dir> --preflight-budget <SECONDS>. The wrapper runs both under pohunek service lock and passes --accept-runtime-loss to both when given; after a successful run it prints the hook asset step (pohunek integration doctor and pohunek integration install --agent <agent>), which a direct service upgrade does not print. For a runtime that declares a version probe, the
preflight runs the same isolated, bounded probe resume runs (the frozen program
with the probe’s arguments, a private HOME and working directory), so
service check executes those programs. It writes no pohunek state. Identical
probes (same program, PATH and package) run once, and all probes of one run
share a budget below the installer’s preflight deadline; a session whose probe
the spent budget cuts off is would_lose_recovery with probe_budget_exhausted.
The release plan is judged only when the installer passes --release-plan (the
upgrade’s own gates and service check of an upgrade); without it, as for a
rollback, which never applies the plan, the preflight judges the installed
packages only. The preflight runs with the PATH the daemon job runs with, byte
for byte: the one service.toml records; else, on Linux, the systemd user
manager’s Environment PATH; else, on macOS, launchd’s active PATH of the job:
the domain’s environment PATH (launchctl print gui/<uid>, set by launchctl setenv), else the default environment of the loaded job (launchctl print gui/<uid>/<label>), else, for a job that is not loaded, the launchd default an
upgrade journaled while the job was loaded, else the persisted launchd
configuration, the best evidence available. A rollback judges with the same order,
so the PATH it uses is the one the restored job gets where launchd can still
report it; a warning: line names a journal that gave no default. An
unrecognized launchctl print output refuses the upgrade with service_manager_environment_unavailable.
A job whose service manager’s variables cannot be read takes none: on Linux a job that records
its PATH whose systemd user manager’s bus is not reachable, and on macOS any job when launchd has
no gui/<uid> domain, as without a GUI session (a job without a recorded PATH then takes the
persisted launchd configuration’s PATH). service check is not refused: it prints a warning that
native references are judged at the default store locations, so a verdict may read
already_not_resumable or would_lose_recovery for a conversation stored under a variable-set
location, or adoptable for one present only at the default location. When such a judgement
covers a stopped session, whose native reference check reads a store location, service upgrade
and a rollback refuse it with service_manager_variables_unread before any effect, unless
--accept-runtime-loss is given; a judgement without stopped sessions does not depend on those
variables and proceeds with the warning. The
preflight resolves programs with the daemon’s own resolver, which skips an
untrusted candidate the same way at launch. See
update after release.
Identity hooks prefer the owner-private worker endpoint so an accepted launch
or active identity is retained while the daemon is unavailable. Notification
hooks continue to use the public daemon socket; notifications produced during a
daemon outage are not durable. POHUNEK_DAEMON_ID remains additive compatibility
data but is not the stable runtime identity and must not be used for
self-feedback decisions by new clients.
When the worker-private native-identity claim cannot be delivered, shipped
Codex and Claude hooks fall back to the public session.report_native_id
method, which the daemon attests against the session’s PTY root (see the
native-id report attestation rules above). A hook is a child of the agent, so
that fallback is refused with native_report_unattested; the native reference
is recorded only through the worker hook socket. The origin-session guard deliberately allows
this lifecycle report to target its own session. Its
strict params are session_id, worker_instance_id, agent, non-zero pid, decimal
string pid_start_identity, decimal string monotonic sequence, RFC 3339
expires_at, native_session_id, and optional transcript_path. The daemon
records only an unexpired claim for the current logical session/runtime whose
agent matches the frozen launch profile or base kind, whose PID and kernel
start identity match the launch process, and whose sequence is newer than the
last accepted claim. Stale runtime, PID reuse, expired, duplicate/out-of-order,
wrong-provider, and wrong-session reports are ignored. Native identifiers and
transcript paths are redacted from Debug and must not enter logs or errors.
The claim lifetime is capped at 60 seconds from receipt.
{ "session_id": "s-42", "worker_instance_id": "runtime-42", "agent": "codex", "pid": 4242, "pid_start_identity": "7", "sequence": "1", "expires_at": "2026-08-04T10:00:00Z", "native_session_id": "provider-native-id"}The result is exactly {"recorded":true} or {"recorded":false}.
session.report_agent accepts the nested agent source, agent, optional
activity, optional seq, optional pid, and optional active native metadata.
seq is a decimal string. A session keeps the protocol version of its launch
(POHUNEK_PROTOCOL_VERSION), so a session launched under protocol 3 keeps
reporting after a daemon upgrade: its hooks send the range 3..=3 and spell the
worker instance key runtime_id in session.report_native_id, which the
daemon serves through the protocol 3 adapter. pid is the OS process id for the active nested agent. When present, the daemon
binds the active claim to that process and clears the claim when procwatch sees
the process exit. The shipped integration state hooks use
POHUNEK_INTEGRATION_VERSION=14, run their interpreter in isolated mode (-I,
so the session working directory never shadows the standard library), read the
worker instance from POHUNEK_WORKER_INSTANCE_ID, read
provider JSON through a bounded direct pipe without staging it on disk, and send the hook process’s parent PID on
SessionStart. An installed hook whose content differs from the shipped asset,
for example one of an earlier release that reads another environment name,
is reported by integration.status as
outdated with reinstall recovery, and by integration.doctor as an asset
finding, until it is reinstalled. POHUNEK_WORKER_INSTANCE_ID is the only
worker-instance marker: a worker sets it on every child and strips every
inherited POHUNEK_* variable from them, and the hooks, the process inspectors
and the process sweep read no other name. Workers of releases before 0.33.1
mark their children with another name, so the daemon does not adopt such a
live worker (incompatible, worker_marker_legacy), and a sweep of a
session’s runtime never signals a process that carries that session’s
POHUNEK_SESSION_ID and POHUNEK_WORKER_ID (set only by a worker) but no
worker-instance marker: it leaves the process running and the cleanup
unconfirmed. A lifecycle hook the daemon runs for the session carries no
POHUNEK_WORKER_ID and is ignored by the sweep. An operation refused on such a
session (session.stop, session.resize, input, attach) keeps the state’s code
worker_protocol_incompatible, names worker_marker_legacy in msg, and its
recover says to remove the session with pohunek session rm and accept
losing its runtime: the release that started such a worker is outside the
supported upgrade window, and the daemon does not notice when it ends.
session.release_agent accepts the same source/agent identity plus an
optional seq. A release clears only the current matching active-agent claim;
stale releases do not clear newer reports. Claude installs a SessionEnd state
hook that sends session.release_agent with a fresh timestamp sequence, so
clean exits normally clear active state promptly. Codex has no installed
session-end release path because its Stop hook is turn completion, not process
exit; procwatch remains the Codex release backstop.
Session and Project Filters
Section titled “Session and Project Filters”session.list and project.list filters are exact-match predicates combined
with AND semantics. Session agent filters match the immutable launch profile
or base kind, and also match the current active_agent profile or base kind
when a nested agent has reported itself. They are tagged objects, for example:
{ "filters": [ {"key":"state","value":"running"}, {"key":"agent","value":"codex"} ]}With the example above, a shell session that currently has
active_agent: "codex" also matches {"key":"agent","value":"codex"} even
though its launch agent remains shell.
Session metadata filters use the same opaque string keys and values as
session.set_metadata. metadata_eq matches one key to one exact value;
metadata_has matches when the key is present, including when its value is an
empty string. Their tagged shapes are:
{"key":"metadata_eq","value":{"key":"ticket","value":"ABC-1"}}{"key":"metadata_has","value":{"key":"ticket"}}Metadata predicates combine with each other and all other session predicates
using AND semantics. The daemon applies the same key and value validation used
by session.set_metadata; it does not interpret or normalize either string.
They filter the sessions returned by the existing list operation without
changing its visibility or response constraints. A protocol-3 client cannot
express these filters. A current Rust or TypeScript SDK returns
daemon/daemon_protocol_too_old for a protocol-3 daemon before it can send a
request whose metadata predicates compatibility translation would drop.
NotificationRecord
Section titled “NotificationRecord”Important fields:
id: stable host-local notification id.source: sanitized producer identity withprovider,provider_event, andhost_local_source_id. Provider hooks usecodexorclaude; daemon projectors usepohunek.kind:agent_blocked,approval_required,turn_completed,session_finished,error, orsystem.severity:info,success,warning,error, oraction_required.status:unread,read,acknowledged,archived, ordeleted.title/body: bounded, sanitized user-facing text. Notification payloads must not contain raw terminal output, prompts, secrets, environment dumps, or full tool results.metadata: safe producer tags. The daemon accepts at most eight entries, with values at most 512 characters, and only allowlisted keys:action_url,detail_url,provider,provider_event,reason,summary,hook_event_id,matcher, andtool_name. Secret-shaped keys such astoken,secret,password,api_key,authorization, andcookieare rejected.session_id: optional linked session id. It is shape-validated when supplied bynotification.createand may point to a session that no longer exists.agent_kind,project_id: optional display and filtering context;agent_kindis aRuntimeRef.source_id: optional producer-specific id used for idempotence within one source namespace.dedupe_key: optional source-independent id for one logical event. Session attention notifications useattention:<session_id>; session turn-completion notifications useturn:<session_id>.read_at,acked_at,archived_at,deleted_at: lifecycle timestamps set by status transitions.superseded_by: optional replacement link. Older unreadturn_completedrecords are acknowledged withsuperseded_bypointing at the newer turn or attention record that made them stale.
notification.list sorts by created_at descending, then id, and omits
deleted records by default. NotificationListParams can filter by status, kind,
severity, provider, session id, and creation time range, plus limit and
cursor.
A result whose records do not fit one control line answers
runtime/notification_list_too_large instead, with a recovery hint to request
fewer records with limit and follow next_cursor; next_cursor is set only
when limit cut the result. A caller that omits limit gets the whole
matching set only while it fits one response, so a client that must list a
growing history pages. pohunek notifications list without --limit does so:
it follows next_cursor with pages of 200 records and halves the page size
when the daemon answers notification_list_too_large. A daemon of an earlier
release sends a line longer than the control-line limit instead, which ends
the connection; the listing then starts again from the first page at half the
size on a new connection, so no cursor crosses connections.
NotificationPolicy
Section titled “NotificationPolicy”Important fields:
attention_dedupe_window_secs: window for source-independent attention dedupe. The default is 120 seconds.attention_debounce_secs: shared window a deferred session attention or turn-completion notification is held pending before it is allowed to surface. The default is 5 seconds. Additive: a policy JSON written before this field existed loads the default.enabled: base per-kind flags used when a provider has no explicit entry.providers: optional deterministically ordered object mapping provider wire names to complete per-kind overrides. Missing keys fall back toenabled. The default policy has no overrides, and an empty map is omitted on the wire.retention: automatic daemon-owned cleanup and physical-compaction settings. Persisted policies without this additive field receive the defaults.
For example, a policy with one operator-set override that enables
turn_completed for the pi runtime:
{ "attention_dedupe_window_secs": 120, "attention_debounce_secs": 5, "enabled": { "agent_blocked": true, "approval_required": true, "turn_completed": false, "session_finished": false, "error": true, "system": false }, "providers": { "pi": { "agent_blocked": true, "approval_required": true, "turn_completed": true, "session_finished": false, "error": true, "system": false } }, "retention": { "sweep_interval_secs": 21600, "info_ttl_secs": 259200, "warning_ttl_secs": 1209600, "resolved_attention_ttl_secs": 604800, "resolved_error_ttl_secs": 2592000, "archived_ttl_secs": 7776000, "compaction_min_actions": 1000 }}Provider names are open strings so adding a provider does not change this wire
shape. The former fixed codex / claude fields are not accepted and have no
compatibility shim.
Default policy enables agent_blocked, approval_required, and error.
turn_completed, session_finished, and system are implemented but disabled
by default. The default policy has no per-provider entries, so every provider,
whatever runtime it names, uses enabled until an override is set for it. A
policy file the daemon already persisted is loaded as written, including any
per-provider entries it holds; those overrides keep shadowing enabled for
their providers, so change them with policy set --provider <runtime-id> to
the wanted value.
Every retention duration and compaction_min_actions must be greater than zero.
The daemon runs one sweep at startup and then every sweep_interval_secs using
the current policy. Informational/success, warning, acknowledged attention,
acknowledged error, and archived records use their respective TTLs. Unread or
read action-required and error records are never deleted automatically. After
eligible records receive normal deletion events, the store atomically rewrites
the action log to one current action per non-deleted record once the configured
action threshold is reached.
Policy is enforced daemon-side for all notification producers. If a producer
creates a disabled kind, notification.create returns
runtime/notification_kind_disabled.
A source ranks as a provider hook exactly when its provider is an identity
provider of a registered hook schema (today shell, codex, claude, and
hermes, matched case-sensitively). These are the providers whose
session-scoped notification.create reports are checked against the session’s
hook schema; that admission additionally admits a session’s own runtime, which
does not change its rank. pohunek and daemon rank as daemon projectors.
Every other producer, including a runtime that no hook schema lists, ranks as a
user/external source.
Provider hooks have higher source priority than daemon projectors for the same
attention:<session_id> key inside attention_dedupe_window_secs. Every
notification.create on an attention:<session_id> key is deferred, so it
answers created: true with a minted id; the dedupe happens when the debounce
flush commits the record. A provider hook record then upgrades an existing
projector attention record in place and emits notification_updated. A
projector record for a key that already has a provider-backed record is
dropped. Provider hooks of equal rank dedupe against each other: a hermes or
shell record for a key that already has a provider-backed record inside the
window (from the same or another hook provider) is dropped. A dropped record
never persists and emits no event, so its minted id is never listed,
notification.update on it returns notification_not_found, and
notification.delete returns deleted: false.
User/external sources do not automatically supersede provider records. While
records for one session key are held pending in the debounce window, the
higher-ranked record is kept and an equal-ranked later record replaces the
earlier one, so a provider hook report pending beside a user report is the one
committed.
turn:<session_id> has different semantics: it is not time-window deduped. A
new unread turn_completed for the same session acknowledges any older unread
turn immediately and sets the older record’s superseded_by to the newer id.
The older record remains in history but disappears from default unread inbox
views. When an attention record for the same session becomes visible, it
acknowledges any unread turn:<session_id> twin with superseded_by pointing at
the attention record because agent_blocked/approval_required subsumes “the
turn completed and is waiting”.
When a session enters working, or reaches a terminal lifecycle state, the daemon resolves both
attention:<session_id> and turn:<session_id>. Pending records with those
keys are dropped before they ever persist, and already-visible unread/read
matching records are acknowledged with notification_updated. An idle
observation alone does not resolve attention because a live approval prompt can
be technically idle while still requiring owner input.
Session notification debounce
Section titled “Session notification debounce”agent_blocked, approval_required, and session-scoped turn_completed are
held pending rather than persisted immediately when they carry
attention:<session_id> or turn:<session_id>. The daemon mints the
notification id and returns notification.create result created: true with
the full record, but the record is not written to the store and does not appear
in notification.list until it flushes. No notification_created event is
emitted for a pending record.
The daemon holds the pending record for attention_debounce_secs. If the
session enters working or reaches a terminal lifecycle state within that
window, the pending record is dropped entirely: nothing is ever persisted, and no event fires. Only if the
window elapses with the session signal still outstanding does the daemon commit
the record through the store and emit notification_created, exactly as an
immediate create would. Debounce does not apply to session_finished, error,
or system.
attention_dedupe_window_secs and attention_debounce_secs are independent and
answer different questions:
attention_dedupe_window_secscontrols whether two producers reporting the same attention moment (a provider hook and the daemon projector) collapse into one record instead of two.attention_debounce_secscontrols whether and when a pending session notification is allowed to surface at all, regardless of how many producers reported it.
Provider Notification Hooks
Section titled “Provider Notification Hooks”integration.install runs the Codex and Claude integration handlers, which
install durable state and notification hooks for current Codex and Claude
builds only. There is no fallback for older provider hook APIs.
Both integration handlers also register the state hook for SubagentStart and
SubagentStop. These callbacks target only the owner-private worker endpoint; when
it is unavailable or refuses the claim they exit 0 with no output and record
the failure in the hook failure trace (see “Hook failure trace” below). They copy only
lifecycle identifiers and agent type. A Claude SubagentStop with no recorded
start is not a subagent and adds no row, while a Codex one records a completed
child; see subagents under SessionInfo. The Claude integration handler also
registers the state hook on the main-agent Stop (action stop): it sends the worker one
subagent_snapshot claim with, for each entry of background_tasks, the
required id, type and status, and the optional fields that sanitize:
label from description (every type; control and format characters removed,
whitespace collapsed, cut to the byte limit), program from command (shell
tasks only; the basename of the command word, after skipped NAME=value
assignments, cd <dir> && or ;, and env options, accepted only when it
matches [A-Za-z0-9][A-Za-z0-9._+-]*; a command longer than 64 KiB gets no
program, because shell tokenizing grows superlinearly with token length, about
32 ms at 64 KiB and 6 s at 1 MiB, and a cut command word could name another
program), and agent_type (subagent tasks only; a value with
a control, format, line or paragraph separator or surrogate character is
dropped). An invalid optional field is omitted from its task alone. The command
and its arguments are never forwarded. The label and program byte limits come from the
worker-injected reserved variables POHUNEK_BACKGROUND_LABEL_MAX_BYTES and
POHUNEK_BACKGROUND_PROGRAM_MAX_BYTES; when one is missing or invalid, the hook
sends no label or program at all. The hook sends nothing for a Stop with a
non-empty agent_id, a missing or invalid list, a list over 256 entries, or
input over 1 MiB, and a missing or invalid required field voids the list. A
worker that predates the claim refuses it and nothing is applied. Codex Stop
carries no task list, so the Codex integration handler registers no state hook
on Stop. The hook
validates its action and Pohunek handshake before reading, rejects oversized
JSON, and never stages the payload on disk; provider prompts, results,
messages, and transcript paths are discarded.
On SessionStart (action session) the hook’s identity report carries the
hook input source as session_source only when it is startup, resume,
clear or compact; any other value, or none, omits the key. The worker clears
the background snapshot only on startup and resume. fork is not forwarded:
a forked session is a new process and must not clear the snapshot of the session
it was forked from. On SessionEnd (action release) the identity release
carries the hook input reason as release_reason only when it is clear,
resume, logout, prompt_input_exit or other; any other value omits the
key. The snapshot belongs to the reporting Claude process: any process in the
managed PTY tree may report, the latest turn end wins, the owner’s release with
a reason other than clear clears it, and a SessionStart from another process
clears it. A /clear keeps the snapshot, because Claude follows that release
with a SessionStart of the same process.
The four integration methods classify an explicit agent before acting: a
value outside the runtime-id grammar is runtime/agent_kind_unsupported, a
valid id no enabled runtime backs is runtime/runtime_not_installed, and an
installed runtime with no daemon-managed hook integration (the shell, Hermes,
or any other registered runtime) is runtime/agent_not_installable.
Install, status, doctor, and uninstall dispatch through the integration handler
the runtime definition names (integration.handler), never through the runtime
id. The handlers are compiled core code and a closed set (codex-hook-v1,
claude-hook-v1, and hermes-hook-v1, whose lifecycle the CLI runs, so the
daemon answers agent_not_installable for it). A handler owns one active asset
set, its accepted paths and modes, conflict detection, and rollback; a package
never supplies installer logic. Bare integration.install, status, and
doctor visit each distinct config home of the daemon-run runtimes once (the
same handler on the same canonical directory is one home), in runtime-id order
of the first runtime naming it, and reports carry the runtime the request
addressed.
Hook failure trace
Section titled “Hook failure trace”The Codex and Claude state hooks never break the provider: every path exits 0
and writes nothing to the provider. Once a state hook has established that it
runs inside a Pohunek session (POHUNEK_ENV=1 and a non-empty
POHUNEK_SESSION_ID) and has started its Python body, every reason it fails to
deliver a report on a route is recorded as one line in a hook failure trace, so
a lost claim, snapshot or identity report leaves evidence. An exit before that
point writes no line; in particular a hook without python3 on its PATH
writes no trace, because a POSIX shell cannot open the trace without following
a symlink.
The worker sets two reserved variables on every managed child (see
Active-Agent Hook Payloads): POHUNEK_HOOK_TRACE_PATH, the absolute path of
pohunek-hooks.jsonl in the pohunek log directory (by default
~/.local/state/pohunek/logs/), one file shared by every session of the host,
and POHUNEK_HOOK_TRACE_MAX_BYTES, the size at which the hooks rotate it
(1 MiB). A hook traces only when the path is absolute and the size is a
positive decimal integer of at most 15 digits without a leading zero;
otherwise it writes no trace and still exits 0.
Each line is one JSON object of at most 512 bytes; a line that would be longer is dropped, never truncated. Fields:
ts: the time of the line, RFC 3339 in UTC with milliseconds;session: the Pohunek session id fromPOHUNEK_SESSION_ID;provider: the runtime id the hook asset was rendered for, such ascodexorclaude;action: the hook action (session,release,subagent-start,subagent-stop, orstopfor Claude);reason: a stable snake_case code, listed below;detail(optional): a short fixed code, an errno name such asECONNREFUSEDor one of the codes listed below, never text taken from the input or an error message.
Reason codes, with their detail values:
input_too_large: the provider input exceeded the hook’s read bound (64 KiB, 1 MiB for the Claudestopaction);input_invalid: the input could not be used;detailisempty,not_json,not_utf8,too_deep,not_object, an errno name when reading it failed,agent_id_missing(a subagent action without a non-emptyagent_id) orsession_id_missing(asessionaction without a native session id);no_snapshot: the Claudestopaction sends no snapshot;detailissubagent_stop(aStopwith a non-emptyagent_id),tasks_missing(nobackground_tasks),tasks_invalid(not a list, or an entry without a non-empty stringid,typeorstatus) ortasks_over_bound(more than 256 entries);action_invalid: a defensive code for an action the Python body does not handle; the managed entry point exits on an unknown action before the session handshake, so it never writes this line;worker_socket_missing:detailunsetwhenPOHUNEK_WORKER_SOCKET_PATHis not set for a report that needs the worker, orENOENTwhen no socket exists at that path;worker_instance_missing:POHUNEK_WORKER_INSTANCE_IDis not set;agent_pid_invalid: the hook could not determine the agent’s process id;agent_identity_unavailable: the start identity of the agent process could not be read;worker_unreachable: connecting to the worker socket failed (detailis the errno name);worker_timeout: the worker did not accept or answer within the 0.5 s socket timeout;worker_closed: no usable answer line;detailis an errno name,emptyormalformed;worker_refused: the worker answered withoutok: true;daemon_socket_missing,daemon_unreachable,daemon_timeout,daemon_closed: the same failures on the daemon-socket fallback that thesessionaction (and the Claudereleaseaction) uses;daemon_refused: the daemon answered with an error;daemon_protocol_invalid: no worker socket is set andPOHUNEK_PROTOCOL_VERSIONis not a usable integer, so the daemon fallback cannot run.
A line never carries provider payload content, ids taken from the input
(agent, task or native session ids), task data, paths, or environment values;
the only identifier is the Pohunek session id. One hook run can write more than
one line: a session or release report that the worker does not accept and
the daemon fallback then delivers keeps the worker’s line, and a run whose
first route succeeds writes none.
The hook queues its lines and writes them when it exits, after every exchange
with the worker or the daemon, so tracing adds no delay to a report. It opens
the trace with O_APPEND, O_CREAT, O_NOFOLLOW and O_NONBLOCK and mode
0600, so a symlink at the path is never followed and a new file is
owner-private, and takes an exclusive lock on the opened file for each line
without blocking: it tries the lock at most 5 times, 5 ms apart, and when the
lock stays contended it drops the run’s remaining lines, so a held lock never
stalls the hook. It writes nothing unless the file is a regular file owned by
the current user, with a single link and no group or other permission bits. Each line is one
write, so lines of concurrent hooks never interleave. When a line would take
the file past POHUNEK_HOOK_TRACE_MAX_BYTES, the hook renames it to
pohunek-hooks.jsonl.1 (replacing an earlier rotated file) and writes to a new
pohunek-hooks.jsonl, so the trace holds at most twice the bound across the
two files. A failure to write the trace is ignored: tracing never changes the hook’s exit code or output.
Config homes and host profiles
Section titled “Config homes and host profiles”The directory a handler installs into is the config home its runtime declares
(the [config_home] descriptor table, see “Config home” under the runtime
package format), resolved the way a launch resolves it: the declared variable
read from the environment the launched agent sees (the daemon’s base
environment, which only forwards variables its allowlist names, overridden by
the host profile’s own [env]), else the declared default below that
environment’s HOME. A value that is not an absolute path (a literal ~
included) is agent_config_dir_invalid; it is never expanded with the daemon’s
HOME. A runtime that names a daemon-run handler but declares no
[config_home] is agent_config_home_undeclared. The official Claude Code and
Codex packages declare CLAUDE_CONFIG_DIR / .claude and CODEX_HOME /
.codex.
The daemon’s own process environment steers nothing unless the allowlist
forwards the variable, so an install, a status report and a launched agent
always agree on the directory. A daemon that relied on such a variable in its
service environment must add it to the environment allowlist (so launched agents
see it too) or move it into a host profile’s [env] and select the profile;
hooks installed in the directory the daemon’s own variable names are not
reported. A package runtime without [config_home] is left out of bare
install, status and doctor.
profile (a host profile name) and all_profiles (boolean, exclusive with
profile) on integration.install, status, doctor and uninstall select
which home a request acts on; without either the runtime’s own (profile-less)
home is used and the request is unchanged. profile must be a host profile of
the selected runtime (integration_profile_runtime_mismatch otherwise; a name
that is no profile file, a bare runtime name included, is
agent_profile_not_found), and without agent the profile’s runtime is the
selection. all_profiles covers the runtime’s own home and the home of each of
its host profiles. A profile over the runtime that does not resolve (unpinned
over a package-served alias, pinned to a version that is not installed, or over
an alias nothing serves) is still covered: status, doctor and uninstall
address the home its own [env] names, read with the same containment and
strict parse as a load, and install lists it in failed with the profile’s
resolution error. A file that cannot be parsed names no runtime and is skipped
and logged, as host.inspect does. Selectors that resolve to the same canonical
directory share one home and one transaction; the report’s home
({profiles, bare, selector}) names every selector it stands for and carries
selector ({kind: "default"} or {kind: "profile", name}), the one the
daemon verified reaches the directory the installer uses: an alias can resolve
through a symlink the installer refuses, so recovery commands are built from
selector, never from the order of profiles or from bare. A request without
agent that reaches one directory through one handler for several runtimes (a
package that declares claude-hook-v1 and Claude’s [config_home], say)
attributes the directory to none of them: each of those runtimes keeps one home
whose resolution fails with configuration/integration_home_ambiguous_owner, so
a bare install fails with it before writing any home (under all_profiles it
is listed in failed). status reports the home as unresolved with a warning
that carries the code and its hint; integration.doctor reports it as a
config_root_invalid finding whose remediation names --agent <runtime> or
separate config homes, and daemon.doctor reports that home’s
agent_integration_<runtime> check as warn, not as malformed state. The hint
names --agent <runtime>; a request that names agent addresses only that
runtime and is not refused.
IntegrationStatusResult and IntegrationDoctorResult carry
home_selectors: true; a daemon that predates the selectors omits it (and an
older integration.install silently ignores the selector fields), so a client
checks the marker before it sends a mutation that carries a selector. Install under
all_profiles skips homes whose directory does not exist and fails with
agent_config_dir_missing only when none exists. Each home is its own
transaction under its own installer lock, so a failing home rolls back to its
exact prior tree and leaves the others as they are; install and uninstall
list a failing home in failed and the CLI then exits non-zero, and status
and doctor report an unresolvable home as a degraded report instead.
profile and all_profiles are accepted only from the local control socket:
reports, errors and installed paths name directories derived from a profile’s
environment, which can hold secrets. A remote overlay connection gets
daemon/local_only_method before any parameter is acted on. The team relay is
not a daemon transport yet; a relay-originated path must never arrive as local.
An install over an existing asset set is an update transaction: the handler
stages the new set against the active one without changing it, proves
compatibility, activates atomically, and restores the exact prior tree when any
step fails, so the active set stays in place until activation succeeds.
Compatibility requires the runtime’s hook schema to be driven by the handler and
to admit every operation the new set reports, as derived from the staged
registration (the commands that run the managed scripts, restricted to the
arguments each embedded script accepts) and not from a declaration, and every hook schema of the
package versions that live, lost or resumable sessions and host profile pins
still reference for that handler to admit them too (an undeterminable retained
version, whose handler is unknown, refuses an update of every handler’s asset set); otherwise the update is refused
with runtime/integration_update_incompatible before anything is written. An
active set of any version is replaceable, so a rolled-back release reinstalls
its older set.
integration.status is the corresponding read-only drift report. Bare status
reports every served runtime whose daemon-run handler has a config home (the
Codex and Claude Code packages once installed); --agent <runtime> selects
one. --agent takes any runtime id: the CLI sends it unchanged and the daemon
resolves it to the handler the runtime definition names (an id no installed
runtime backs is runtime_not_installed, a runtime without a daemon-run handler
is agent_not_installable), so a package runtime is addressed by its own id and
the recovery commands in status and doctor output name that id. hermes keeps
its local CLI-run lifecycle. Every managed script is checked independently against its embedded asset
and executable mode. Claude must also contain every exact managed registration
in settings.json. Codex must contain every exact registration in hooks.json,
have the hooks feature enabled, and retain the position-derived trust hash for
each managed hook in config.toml. The managed Codex trust-key set must be
exact: a trust record is installer-owned when its trusted hash is that of a
managed command for its event, so a stale managed record at an old position
makes the integration outdated and reinstallation removes it before writing only
the currently expected keys, while trust records of a user’s own hooks are never
removed or flagged and follow their hooks: a trust key embeds the group and
handler index, so when an install, reinstall, or uninstall shifts a user hook’s
position its record moves to the key of the new position (the trusted hash covers
only the handler and its matcher, so it stays valid). Two claims on one key fail
closed with configuration/integration_trust_conflict and nothing is written. A scalar anywhere
in the managed trust namespace requires configuration repair, including when
hook drift temporarily prevents that key from being position-derived.
not_installed means no Pohunek-managed asset or registration was detected;
A missing agent config directory is available: false with not_installed, recovery none, and no warning; a path that exists but is not a directory, has a symlink at any component (live or dangling, which install and uninstall refuse), is refused by the same trusted descriptor walk the installer performs (foreign owner, group/world-writable directory or ancestor), cannot be resolved, or cannot be inspected is outdated with repair_configuration.
current means the complete contract above matches; outdated covers every
partial, modified, malformed, unreadable, or otherwise unverifiable detected
installation. A supported agent’s path-resolution failure becomes an outdated
report with a non-secret warning; in aggregate mode it does not suppress the
other agent. Explicit unsupported agents still return a typed error. The response
reports real installed and expected version fields. installed_version is
unknown when any readable managed asset lacks a valid marker. The typed recovery
is reinstall only when the installer can repair every finding; malformed,
unreadable, oversized, or unresolved provider configuration instead reports
repair_configuration. Symlinked, special, foreign-owned, or group/world-writable
managed assets also require manual repair, while an explicit reinstall replaces
installer-owned assets atomically without following an existing symlink. Managed
asset type, effective-UID ownership, mode, and content are inspected through one
non-following descriptor. The relevant parent chain starts at the explicitly
resolved agent config root and ends at the asset’s direct parent; every path in
that chain must be a real directory owned by the daemon’s effective UID without
group or world write access. System ancestors above the config root are outside
this check, so ordinary safe home/XDG roots are not rejected merely because an
ancestor such as /tmp is shared. Each Claude settings.json, Codex
hooks.json, and Codex config.toml is opened without following symlinks and
must be a regular file owned by the daemon effective UID without group/world
write access; metadata and bounded content come from the same descriptor.
Provider inspection is nonblocking so FIFOs cannot stall the daemon worker. A
missing Claude hooks/ child under an otherwise trusted config root is a
reinstallable absence and can still report not_installed; an existing symlink,
wrong type, foreign owner, or group/world-writable child requires
repair_configuration. The installer creates a missing Claude hooks/
directory with exact mode 0700 regardless of the inherited umask and removes
that newly created directory if mode enforcement or safe opening fails, but
never changes permissions on an existing real user directory. Codex trust
records describe a canonical single-handler managed
group, so adding a sibling handler makes status non-current until reinstall
separates the managed handler while preserving the user sibling. Agent config
roots selected through CLAUDE_CONFIG_DIR or CODEX_HOME must be absolute and
UTF-8 representable after tilde expansion. Invalid roots fail with
agent_config_dir_invalid before registration commands are constructed, so a
daemon working directory cannot change where a provider process resolves them.
Before any mutation, installation opens and validates every existing config and
hook parent as a no-follow, effective-UID-owned real directory without group or
world write access. An unsafe parent fails with
configuration/integration_path_untrusted. Provider files are prepared in
memory, then replaced through the already-opened directory descriptor by
displacement: the original is renamed aside into a private quarantine name
bound to its inode, its identity, mode, and complete content are verified on
that inode against what was read, and the new file, written to an exclusive
temporary file and synchronized, is activated with a no-replace rename. A
foreign change at any moment is therefore an
integration_destination_collision, and a foreign file created in the gap is
never overwritten; on any mismatch the original is moved back untouched. The
originals are deleted only after every file is in place; if that cleanup cannot
finish, the install still succeeds and cleanup_incomplete lists the quarantine
paths that remain, which integration.doctor reports as
displaced_original_left_behind. Parent or pathname swaps cannot redirect the
write. Existing safe provider-file modes are preserved,
while new registration files use mode 0600 and managed executable assets use
mode 0755. An oversized file is outdated with an actionable warning. The CLI
routes Codex and Claude status to the effective global --host; mutating hook
installation and every Hermes lifecycle action remain local. Human recovery
hints and warning commands for a remote report explicitly name the daemon host
where the operator must run the local installer; passing that host back to
integration install is not a remote mutation. A JSON registration root that
is not an object, an inline TOML table where the installer requires a regular
table, a scalar value anywhere in the installer-owned trust namespace, and
managed-asset metadata errors other than absence require
repair_configuration. A missing hooks object remains reinstallable, and a
handler with an exact installer-owned command identity is safely replaced even
when its type field drifted. All enum values use snake_case on the wire.
Codex notification support requires modern lifecycle hooks for
PermissionRequest and Stop. The installer writes managed command hooks to
hooks.json and records trust metadata in config.toml; the legacy Codex
notify key is not used and is not sufficient for approval notifications.
Claude notification support requires hook events for Notification, Stop,
and StopFailure. Notification matcher values map as follows:
permission_prompt and elicitation_dialog create approval_required,
while the normal idle_prompt creates no notification. auth_success,
elicitation_complete, and elicitation_response create system. The
notification hook’s Stop creates turn_completed; StopFailure creates
error. The separate state hook entry on Stop creates no notification; it
sends only the subagent snapshot to the worker and has no daemon fallback.
When POHUNEK_SESSION_ID is valid, the managed hooks add
attention:<session_id> to attention events and turn:<session_id> to
Stop/turn_completed events. Invalid session ids are dropped before either
session_id or dedupe_key reaches the daemon.
The managed hooks read at most 64 KiB from provider stdin (1 MiB for the Claude
state hook’s main-agent Stop, whose input carries the final assistant message
and the task list), validate action and
environment before reading input, silently drop an invalid POHUNEK_SESSION_ID,
and exit successfully without output on local failures so agent sessions are not
disrupted; the state hooks also record each such failure inside a Pohunek
session in the hook failure trace (see “Hook failure trace”). Reinstalling hooks removes only exact command shapes managed by
Pohunek; user hooks that merely reference the managed script path are preserved.
SessionDiffResult
Section titled “SessionDiffResult”Important fields:
diff: unified diff text of the session’s worktree againstbase. Covers tracked changes plus untracked files (rendered as added-file diffs); binary files appear as git’s usual “Binary files differ” stanza.base: the base ref the diff was actually computed against — the caller’s explicitSessionDiffParams.basewhen given, otherwise the resolved worktree/repository default. Always present even when the request omittedbase, so a client can display which ref it diffed against.truncated:truewhendiffwas cut short at a file boundary to stay withinMAX_SESSION_DIFF_BYTES(half ofMAX_CONTROL_LINE_BYTES, chosen so the full response envelope always fits one control line). Whentrue, later files in the change set are omitted fromdiffentirely; a client should surface this rather than treat the diff as complete.
Error Contract
Section titled “Error Contract”Every error body has this shape:
{"class":"runtime","code":"session_not_found","msg":"session not found: s-1","recover":"optional hint"}Fields:
| Field | Type | Required | Notes |
|---|---|---|---|
class |
string | yes | Broad category. |
code |
string | yes | Stable machine-readable code. Treat the set as open. |
msg |
string | yes | Human-readable, non-secret diagnostic. |
recover |
string | no | Optional recovery hint. |
Classes:
| Class | Meaning |
|---|---|
configuration |
Missing or invalid required configuration. |
daemon |
Daemon-level failures: bad request, unknown method, version mismatch, daemon unavailable. |
transport |
Framing, connection, or host reachability failures. |
runtime |
Session, PTY, agent, project, worktree, or assistant runtime failures. |
discovery |
Overlay CLI/state/host discovery failures. |
Canonical public codes currently emitted include:
| Class | Codes |
|---|---|
configuration |
paths_unavailable, netbird_configuration_invalid, overlay_registry_invalid, invalid_discovery_options, agent_config_dir_invalid, integration_path_untrusted, integration_trust_conflict, integration_home_ambiguous_owner |
daemon |
version_mismatch, native_store_check_task_panicked, version_adapter_failed, daemon_protocol_too_old, version_translation_failed, method_not_found, bad_request, daemon_unreachable, remote_daemon_unavailable, host_governance_unavailable, session_input_wait_contract_mismatch, projects_not_configured, serialize_failed, json_error, project_task_panicked, doctor_task_panicked, assistant_materialize_task_panicked, assistant_method_unsupported, attach_self_feedback, daemon_shutting_down |
transport |
framing, host_unreachable, request_timeout |
discovery |
<overlay>_cli_missing, <overlay>_state_unavailable, <overlay>_listener_address_missing, overlay_discovery_failed, overlay_peer_collision, overlay_host_ambiguous, overlay_host_unavailable, overlay_error, host_unknown, remote_discovery_failed |
runtime |
agent_binary_missing, agent_profile_not_found, invalid_profile, agent_not_resumable, agent_native_reference_missing, agent_native_store_relocated, native_identity_missing, native_identity_unverified, native_identity_uncertain, native_identity_evidence_unavailable, native_report_unattested, not_resumable, invalid_session_ref, no_capable_agent, integration_no_managed_runtime, bundle_unavailable,assistant_bundle_mismatch, materialization_failed, agent_cannot_read_bundle, session_not_found, session_not_running, session_not_terminal, session_external_read_only, session_exit_timeout, session_runtime_commit_stale, session_runtime_conflict, session_runtime_reconnecting, runtime_supervision_unavailable, runtime_supervision_ambiguous, runtime_identity_mismatch, migration_manifest_missing, attach_not_found, attach_expired, worker_attach_stream_failed, worker_protocol_incompatible, worker_controller_busy, worker_identity_mismatch, worker_invalid_state, worker_invalid_request, worker_invalid_data_token, worker_write_outcome_unknown, worker_runtime_fault, session_input_stalled, session_cwd_missing, session_tmpdir_unsafe, session_tmpdir_failed, client_file_descriptors_exhausted, system_file_descriptors_exhausted, pty_alloc_failed, spawn_failed, pty_error, io_error, project_store_error, project_detect_failed, not_a_git_repo, project_not_found, project_ambiguous, prompt_not_found, template_not_found, action_not_found, invalid_name, invalid_template, invalid_action, path_escape, config_read_failed, agent_not_installable, agent_config_dir_missing, integration_settings_invalid, integration_io_failed, worktree_store_error, worktree_path_conflict, invalid_base_branch, worktree_branch_in_use, worktree_add_failed, invalid_branch, invalid_branch_slug, notifications_not_configured, notification_task_panicked, notification_store_error, notification_not_found, invalid_notification_transition, invalid_notification_metadata, invalid_notification_session_id, invalid_notification_dedupe_key, notification_kind_disabled, invalid_notification_timestamp, invalid_notification_cursor, invalid_notification_policy, integration_install_in_progress, integration_destination_collision, integration_recovery_required, integration_update_incompatible |
Protocol v4 emits these runtime codes for provider-neutral agent and
observation behavior: agent_kind_unsupported,
agent_fork_unsupported, session_terminal_unavailable,
session_has_no_managed_terminal, session_runtime_changed,
session_read_ansi_unavailable, session_output_limit_exceeded,
session_wait_limit_exceeded,
session_waiter_limit_reached, worker_feature_unavailable, and
plugin_self_target_denied, agent_runtime_unsupported,
session_input_rejected, session_input_blocked, session_agent_blocked,
session_input_invalid_wait, session_input_wait_unsupported,
session_input_timeout, session_input_stalled, session_input_not_ready, and the CLI-local session_input_interrupted. Daemon startup may additionally return
observation_limits_invalid. runtime_not_installed is emitted when a runtime
id resolves to no enabled runtime: a host profile whose base names an
uninstalled runtime, and resume, fork or mutation of a session whose runtime is
not installed or no longer matches its recorded launch binding. A bare
session.new agent name that is neither a profile nor an installed runtime
stays agent_profile_not_found. For an official alias nothing serves (codex,
claude or hermes without its package), that agent_profile_not_found and the
runtime_not_installed of an unpinned host profile over the alias carry a
recovery hint naming the catalog install of the official package. While the
installed official package of that alias (pohunek.runtime.<alias>) cannot be
loaded (after a registry rebuild left it out), that unpinned profile’s launch is runtime_incompatible instead, with
the recovery pohunek plugin doctor and then pohunek plugin profile migrate <name>; the install is not offered, because the same archive is refused.
runtime_builtin_removed is emitted by resume, fork and mutation of a session
launched from the built-in runtime of an official alias this daemon does not
compile in while no package serves the alias. Once the official package serves
it, the package adopts the session, and resume and fork answer
runtime_served_by_package only when the recovery contract is not honored
(fork of a Hermes session: agent_fork_unsupported). runtime_incompatible is emitted when a
runtime package is installed but its root is missing, modified or contradicts
the session’s pin, for fresh launch, resume, fork and integration changes (the
message names only the runtime id). agent_profile_revision_stale and agent_profile_revision_invalid are emitted only by the relay-selected launch
source (see above), which no wire method reaches yet.
agent_profile_revision_unavailable is also emitted by session.new for a profile-backed session, and by session.resume and session.fork, when the host’s revision key cannot be read.
agent_profile_changed, agent_profile_missing and the daemon-class agent_profile_change_local_only are emitted by session.resume and session.fork (see “Frozen profile revision”).
Observation request errors intentionally carry no terminal
payload or current-runtime payload; refresh session.inspect or restart
observation from a fresh screen/tail when recovery requires new coordinates.
session_input_wait_contract_mismatch means the SDK cannot prove whether a
same-version daemon honored the wait contract. Delivery outcome is unknown: do
not retry blindly. Upgrade daemon and client together, inspect the session, and
only resend when the observed terminal state proves the original input was not
applied.
session_input_timeout also treats delivery as potentially unknown. Inspect the
current session before deciding whether to resend, and do not retry blindly.
session_input_stalled means the PTY accepted no input for the configured stall
bound, so the agent is not reading its terminal. Part of the plan may already
have been written and the rest was not. A session.new with initial input
removes the session it was creating, so there is nothing to inspect there; a
session.input leaves the session running and the caller inspects it before
resending. The submit is never sent after a stall, but for an agent with
bracketed-paste framing the accepted part can leave a paste open in its input,
so a resend would land inside it: clear the agent’s input (attach and use the
agent’s own clear or cancel key) before sending again. Attached terminal input
is not bounded; it blocks like a real terminal until the agent reads.
session_input_not_ready from session.new is definite: the initial input was
not written and the session was not kept, so the create can be retried once the
agent can accept input. The message names the grace and the last observed
activity; a blocked activity means the agent waits on a prompt that needs an
operator answer.
session_input_interrupted means SIGINT or SIGTERM interrupted a waited CLI
input after delivery may have begun. Its JSON error intentionally has no retry
hint because the delivery outcome is unknown; inspect the session before taking
any further action.
session_runtime_commit_stale means a lifecycle or runtime transition lost a
concurrent durable commit: another runtime is already authoritative for the
logical session because the candidate generation is older or a different
runtime owns the same generation. The losing candidate is not published to the
in-memory registry or subscribers and emits no success event. Refresh with
session.inspect, then retry only if the operation is still valid against the
current authoritative runtime identity, generation, and state; never reuse the
losing candidate’s stale runtime coordinates.
This code does not report post-rename durability uncertainty. If the atomic
rename made a session record authoritative but syncing the parent directory
then failed, the daemon treats the commit as applied and logs a sanitized
durability warning internally. That condition is not returned as
session_runtime_commit_stale.
Governance inspection also preserves the structured error taxonomy. A daemon that cannot safely open, revalidate, or reload its owner-private host state returns a typed error rather than publishing a guessed snapshot. Error messages and debug rendering are redacted: they do not export approval signing material, proposal nonces, signatures, or private record bytes. Clients must treat an inspection failure as unavailable state and must not synthesize an owner, revision, or enrollment from an earlier response.
Delegated Task Errors
Section titled “Delegated Task Errors”The delegated task layer (docs/design/delegated-task-runs-rfc.md section
13.1) defines the codes below. They are part of the public contract; the
daemon raises them once the task methods are served (until then task methods
answer method_not_found). Every one has a fixed msg and, where the table
says yes, a fixed recover hint; task_snapshot_retired and
task_review_limit_reached carry no hint. Task errors never echo a prompt,
answer, path, task id or secret. The text is fixed in the daemon’s response;
the Rust SDK’s remote path (ClientError::RemoteProtocol) prepends
host '<host>': to msg and leaves class, code and recover unchanged.
ProtocolError has no data field, so worktree_in_use and task_worktree_busy
cannot name the users or the blocking task; the method slice decides where that
data is carried. Session methods
raise worktree_busy, task_session_ended, task_fork_unsupported,
worktree_in_use, worktree_users_changed and task_snapshot_retired for
sessions that belong to tasks.
| Code | Class | Raised by | Meaning | recover |
|---|---|---|---|---|
task_turn_open |
runtime |
task.continue, task.answer |
The latest turn is still open, queued, or already resumed. | yes |
task_attention_open |
runtime |
task.continue |
The latest turn settled attention and was not answered. |
yes |
task_agent_busy |
runtime |
task.continue |
The agent is still visibly working on an earlier prompt. | yes |
task_worktree_busy |
runtime |
task.start, task.continue, task.answer, task.extend |
Another task or a live non-task session occupies the worktree. | yes |
worktree_busy |
runtime |
session.input, attach with terminal control, session.resume, session.fork (cwd_mode: "same"), in-place session.new |
A task occupies the session’s worktree; only observation is admitted. | yes |
task_worktree_unavailable |
runtime |
task.start, task.continue |
The shared worktree no longer exists. | yes |
task_worktree_mode_conflict |
runtime |
task.start |
worktree_of combined with in_place or branch. |
yes |
task_session_ended |
runtime |
session.resume, session.fork, session recovery |
The session belongs to an ended task; continue with task.start { worktree_of }. |
yes |
task_session_unavailable |
runtime |
task.continue, task.answer |
The task is ended or its runtime is not live. |
yes |
task_turn_queued |
runtime |
task.extend |
The turn is queued behind a re-open window and has no deadline yet. | yes |
task_worktree_via_investigate |
runtime |
task.start |
An executor worktree_of start named an investigate-mode task. |
yes |
task_turn_ceiling_reached |
runtime |
task.extend |
The turn’s total open time reached tasks.turn_open_ceiling_ms. |
yes |
task_answer_unsupported |
runtime |
task.answer |
A degraded attention whose manifest declares no input for the answer. | yes |
task_answer_unverifiable |
runtime |
task.answer |
A keystroke answer without allow_unverified_delivery. |
yes |
task_payload_mismatch |
runtime |
retried task.start, task.continue, task.answer |
The resubmitted payload does not match the stored fingerprint; nothing is dispatched. | yes |
task_stop_precondition_failed |
runtime |
task.stop |
if_latest_turn or require_idle did not hold; nothing changed. |
yes |
worktree_users_changed |
runtime |
session.remove |
expected_worktree_users differs from the current set. |
yes |
task_attention_stale |
runtime |
task.answer |
The named attention is not the current pending one at that revision, or the provider already resolved it. | yes |
task_result_unknown |
runtime |
task.review, task.result |
No result with that result_id. |
yes |
task_snapshot_retired |
runtime |
session.diff with turn |
The turn’s snapshots were retired with the session content. | no |
task_cursor_expired |
runtime |
task.list, task.inspect |
The paging cursor is too old or from another daemon epoch. | yes |
worktree_in_use |
runtime |
session.remove, worktree.remove |
session.remove: other active tasks use the session’s worktree. worktree.remove: a live session uses the worktree. The code is shared; the removal is refused either way. |
yes |
task_result_pending |
runtime |
task.result, task.continue |
The turn settled but its checks have not finished. | yes |
task_check_unconfined |
configuration |
task.start, task.continue |
No kernel-enforced check containment and checks.allow_unconfined is not set. |
yes |
task_check_not_permitted |
runtime |
task.start, task.continue |
A requested check is not enabled or not permitted for the caller’s origin. | yes |
task_store_full |
runtime |
task.start, task.continue, task.review |
A task store cap would be exceeded. | yes |
task_review_limit_reached |
runtime |
task.review |
tasks.max_reviews_per_result distinct reviewers already hold a verdict on the result. |
no |
task_waiter_limit_reached |
runtime |
task.wait |
The task waiter pool is full; session waits are unaffected. | yes |
task_fingerprint_key_missing |
runtime |
retried task.start, task.continue, task.answer |
The key version a stored fingerprint names is unavailable; nothing is compared or dispatched. | yes |
task_request_conflict |
runtime |
every idempotent task method | A reused request key with different parameters; nothing is executed. | yes |
task_investigate_no_checks |
runtime |
task.start, task.continue |
checks or checks_baseline requested for an investigate-mode task. |
yes |
task_fork_unsupported |
runtime |
session.fork |
The task’s agent cannot fork its native session (OpenCode). | yes |
task_investigate_unsupported |
runtime |
task.start |
The profile cannot enforce investigation mode. | yes |
check_cleanup_stuck |
runtime |
task.start with worktree_of |
A daemon-owned check process scope in the worktree cannot be confirmed empty; the worktree stays occupied. | yes |
task_check_unconfined is the only configuration code: the request is
valid and only the owner’s checks.allow_unconfined opt-in admits it. The
other codes are state conflicts, limits, or refusals of the caller’s request.
A queued turn that settles cancelled reports the reason task_turn_reopened,
task_stopped or session_ended in its result through task.wait and
task.result; those are result reasons, not error codes. task_settled and
task_attention are notification kinds.
Clients must not parse msg. Branch on class and code, then display msg
and recover for unknown codes.
Events
Section titled “Events”subscribe turns the control connection into a one-way event stream after this
ack:
{"v":4,"id":"sub-1","ok":{"subscribed":true}}The daemon then writes these events:
| Event | Payload | Meaning |
|---|---|---|
session_created |
{session: SessionInfo} |
A new logical session was created or forked. Daemon reconnection and native recovery use their dedicated runtime events. |
session_updated |
{session: SessionInfo} |
Session metadata, active-agent report/release, cwd/worktree/project association, state, resize, resume binding, or terminal state changed. |
session_stopped |
{session: SessionInfo} |
A user-requested stop completed. |
session_removed |
{session: SessionInfo} |
A session was evicted from the registry; clients drop it from their view. |
session_runtime_reconnected |
{session: SessionInfo} |
A replacement daemon adopted the same worker and runtime generation. This is not a new session and does not imply provider-native recovery. |
session_runtime_lost |
{session: SessionInfo} |
The worker or host runtime is gone. The logical record remains visible and may support explicit recovery. |
session_runtime_conflict |
{session: SessionInfo} |
Runtime discovery found duplicate, mismatched, or otherwise ambiguous live identity. The daemon quarantines the conflict and does not kill a worker automatically; it re-checks a conflict whose record names a worker generation in the background, and session.stop can end one whose recorded identity the journal proves. |
session_runtime_discovered |
{entry: RuntimeInventoryEntry} |
Startup reconciliation classified a discovered durable worker that is not a plainly managed runtime (orphaned, conflicting, incompatible, or identity-mismatched). Emitted once per non-managed discovery so operators can inspect quarantined runtimes. |
session_native_recovered |
{session: SessionInfo, previous_worker_instance_id?: string, worker_instance_id?: string} |
Explicit provider-native recovery created a new worker and runtime generation for the same logical session. previous_worker_instance_id can be absent for a one-time migrated legacy session; production worker recovery includes the new worker_instance_id. |
agent_state |
{session_id: SessionId, activity: AgentActivity, source: StateSource, runtime?: SessionRuntimeIdentity, activity_epoch?: string, revision?: ActivityRevision} |
Agent activity changed. source may be report when a hook report supplied explicit active-agent state. Current daemons emit runtime, activity_epoch, and decimal-string revision, making (activity_epoch, runtime, revision) exact reconnect-safe evidence rather than a hint to re-read only the latest snapshot; the fields remain additive for general v2 subscribers, while input-wait success requires them through SessionInputResult. |
subagent_state |
{session_id: SessionId, subagent: SubagentInfo, runtime?: SessionRuntimeIdentity} |
One provider-managed subagent changed. Current daemons emit runtime; clients ignore an event without a runtime identity or whose runtime id/generation does not match the session snapshot, then apply only a newer decimal-string subagent.revision for the same provider/id. A newer revision may move a terminal subagent back to running, so a terminal lifecycle is not final. session.list and session.inspect remain the reconnect seed through SessionInfo.subagents. |
background_state |
{session_id: SessionId, background: SessionBackground | null, runtime?: SessionRuntimeIdentity} |
The session’s background-task snapshot changed. background: null clears it: the runtime ended, or the reporting Claude process exited, released its session (except for /clear) or started or resumed one, or another agent process started a session. Current daemons emit runtime; clients accept the event only when its runtime id and generation match the current session snapshot, the same rule as subagent_state. session.list and session.inspect seed the snapshot through SessionInfo.background after reconnect. This event is withheld from protocol 3 subscribers. |
attach_opened |
{session_id: SessionId, stream_id: string} |
A pending attach token was redeemed and a raw stream opened. |
attach_closed |
{session_id: SessionId, stream_id: string} |
A raw attach stream ended or was detached. |
notification_created |
{record: NotificationRecord} |
A durable notification record was created. |
notification_updated |
{record: NotificationRecord} |
A notification record changed lifecycle status, was upgraded by higher-priority source dedupe, or was acknowledged by resolve/supersede processing. |
notification_deleted |
{notification_id: NotificationId} |
A notification record was logically deleted. |
Subscription connections ignore further client input after the ack. A slow
subscriber may miss older events if the daemon’s internal event channel lags; it
should reconcile by calling session.list, session.inspect, or
notification.list.
CLI Process API
Section titled “CLI Process API”Commands with --json write exactly one pretty-printed document to stdout and
reserve stderr for diagnostics. Success exits zero with:
{ "cli_version": "0.x.y", "protocol": {"minimum": 4, "maximum": 4}, "ok": {}}A typed operational or usage failure exits non-zero and replaces ok with the
standard err object. Usage failures retain exit code 2. No human text is
mixed into stdout. Session output bytes are never logged; non-JSON
session output decodes standard base64 and displays UTF-8 lossily, while JSON
preserves the exact SessionOutputResult.
pohunek host governance inspect <host> [--json] is the CLI surface for the
read-only v4 governance snapshot. Human output labels the stable HostId,
approval-key reference, never-enrolled absence, enrollment relay/status/revision,
tagged principal-or-team owner and owner revision, and quarantine. --json
emits only the public HostGovernanceStatus result through the normal CLI
envelope. The command has no mutation sibling; it does not enroll, transfer an
owner, unenroll, or expose approval or transfer secrets.
session new accepts either --input <text> or bounded UTF-8 stdin through
--input-stdin / --stdin, never both. session input accepts either
positional text or --stdin, never both. Stdin payloads do not appear in argv,
diagnostics, or logs. The CLI validates observation byte/wait bounds and paired
runtime coordinates before dialing. session wait and waiting session output
use the Rust SDK’s dedicated connections and preserve inherited request-origin
markers. session new --request-timeout-ms <u32> overrides the response deadline
for that creation request; zero is rejected. Without that flag, every creation
uses the Rust SDK’s dedicated creation budget described below, which covers
the daemon’s bounded worker startup and create hooks. session new sends one
session.new request and never resends it. Only its own refusals before that
request (cli_usage, confirmation_required, confirmation_declined) prove
that nothing was created. Any other failure leaves the outcome unknown:
request_timeout, whose recovery hint says so; a transport error such as
framing locally or remote_daemon_unavailable for a remote host, which may
be a reply lost after the request was sent; or a typed daemon error returned
after the session was recorded, such as session_runtime_reconnecting. A
create in progress is listed only once its target, the pre-create and
post-create hooks included, and its launch are done. To find the session,
pass a caller key such as --meta launch=<id> and look it up as described in
Creation outcome: the lookup, how to read its result, and
why a retry can create a second session apply to session new as well.
Initial input waits for an editable prompt or bracketed-paste enable signal,
up to the bounded startup grace for a silent agent; the body and submit key
are sent as separate PTY writes under the runtime input rules. A runtime that
declares ready = "screen_idle" waits for the editable prompt alone and fails
with session_input_not_ready when none shows before the grace ends.
Current SDK and CLI clients set extended_input_ready_wait on a typed
session.new with initial input. The daemon then uses the configured host
startup grace, or the runtime’s longer [input] startup_grace_ms when it
declares one (the Hermes package declares 30000 ms). A request that omits the optional field keeps its silent-reader
wait capped at 500 ms, within the previous release’s five-second client
request deadline; a visible readiness signal still ends either wait at once.
For example, keep untrusted prompt text out of argv by writing it on stdin:
printf '%s' 'Redacted input.' | pohunek session input s-42 --stdin --jsonThe resulting stdout remains exactly one JSON envelope. The input bytes do not appear in that envelope, diagnostics, or structured logs.
Assistant request and creation outcome
Section titled “Assistant request and creation outcome”pohunek assistant takes its free-form request either as positional words or
through --request-stdin, never both. With --request-stdin the CLI reads
bounded UTF-8 text from stdin, under the same limits and control-character rules
as session new --input-stdin. It trims surrounding whitespace and embeds the
result in the opening prompt exactly where the positional words go, so both forms
create the same session and print the same --json result. Empty input, input
that is empty after trimming, and invalid input fail with cli_usage before the
CLI starts or dials any daemon. A stdin request cannot also answer the remote
confirmation prompt, so a remote launch with --request-stdin needs --yes
(confirmation_required), as a --json launch does.
--request-timeout-ms <u32> overrides the response deadline of the creation
request only, with the same parser and meaning as on session new. Without it,
the creation uses the Rust SDK’s dedicated initial-input budget. --meta key=value (repeatable) writes caller metadata atomically with the session, as
on session new.
Creation outcome
Section titled “Creation outcome”A launch sends exactly one session.new request, and the CLI never resends it.
Only the CLI’s own refusals before that request prove that no session was
created: cli_usage (including an invalid stdin request),
confirmation_required, no_capable_agent, materialization_failed and
agent_cannot_read_bundle.
Any other failure leaves the outcome unknown, because the caller cannot tell whether it came before or after the request:
request_timeout.- A transport error, such as
remote_daemon_unavailable,host_unreachableor a framing or I/O error. The same code reports both a connection that never opened and a reply lost after the request was sent. - A typed daemon error for
session.new. Most of them mean the create failed and was rolled back, as described forsession.newabove. Some are returned after the session was recorded, for examplesession_runtime_reconnecting.
session.new has no idempotency key, so pohunek cannot make a retry safe. A
retry while the first create is still running creates a second session. A
caller-chosen correlation key makes every attempt visible. Pass one, for
example --meta launch=<unique id>, and keep it for every attempt of the same
launch. The key name is the caller’s convention: pohunek reserves and
interprets none. After an unknown outcome, look the key up before deciding:
printf '%s' 'Why does attach fail on my laptop?' \ | pohunek assistant --request-stdin --meta launch=7f3a9c --json# After an unknown outcome:pohunek session list --meta-eq launch=7f3a9c --jsonA protocol-3 host keeps the metadata but refuses metadata filters with
daemon_protocol_too_old before the request is sent. On such a host, list
without the filter (pohunek session list --json) and match each session’s
metadata instead.
How to read the result:
- A listed session is that launch. It may still be starting: the record is
written after the launch and its pre-create and post-create hooks finish, but
before the opening prompt is delivered. It can still be rolled back by
session_input_not_readyorsession_input_stalled. - An empty list is not proof that nothing was created. A create still in progress is listed only once its launch and hooks finish, which can take many minutes.
- After a retry, more than one session under the key means duplicates. The caller keeps one and stops the rest.
Agent Skill CLI
Section titled “Agent Skill CLI”pohunek agent-skill prints the complete bundled agent skill for coding
agents. The skill bytes are embedded in the CLI binary at compile time, so one
binary version always prints identical output. The command is a local CLI
lifecycle, not a daemon public method: it never contacts a daemon, reads the
filesystem, or touches the network.
Default output is the embedded skill verbatim on stdout, ending with the
artifact’s single trailing newline; stderr stays silent. With --json the
command emits exactly one pretty-printed CLI process envelope whose ok
payload carries the skill text and its lowercase-hex sha256 content hash:
{ "cli_version": "0.x.y", "protocol": {"minimum": 4, "maximum": 4}, "ok": { "skill": "<complete skill text>", "content_sha256": "<64 lowercase hex characters>" }}The global --host flag is accepted and deliberately ignored. The skill is
embedded locally, so no host is resolved, probed, or connected to, and local
and remote invocations print the same document.
Workflow Plugin CLI
Section titled “Workflow Plugin CLI”pohunek workflow is a local CLI lifecycle, not a daemon public method. There
is no protocol method, event, record or SDK type for workflow plugins: the CLI
keeps them in its own package registry under <state>/pohunek/workflow-plugins/,
with per-plugin directories <state>/pohunek/workflow-plugin-state/<id>/ and
<config>/pohunek/workflow-plugin-config/<id>/, and its commands never
contact a daemon; only the reaction dispatcher (below) reads the local daemon’s
subscribe stream.
Every command that changes the store holds the store lock
<state>/pohunek/workflow-plugins/store.lock for its whole run.
pohunek workflow install work.tar.zst --sha256 <digest> --yes --jsonpohunek workflow list --jsonpohunek workflow inspect acme.work --jsonpohunek workflow status acme.work --jsonpohunek workflow enable acme.work --jsonpohunek workflow disable acme.work --jsonpohunek workflow uninstall acme.work --purge --jsonpohunek workflow uninstall acme.work --remove-modified --jsonpohunek workflow run acme.work overview --since 1dpohunek workflow run --json acme.work overview --since 1dworkflow run [--json] <plugin> <action> [<args>...] passes every argument
after <action> to the action verbatim, including --json, --, -h,
--host and empty strings; pohunek’s own options go before <plugin>.
Event reactions are a CLI process too, not a protocol surface. A plugin’s
[reactions.<name>] table names up to 32 distinct public event names from the
Events table above, except attach_opened and attach_closed. The hidden
pohunek workflow __dispatch is the entry point of the dispatcher unit. It
subscribes to the local daemon’s subscribe stream with no origin pair, and
never starts the daemon. A reaction’s context is the action context, with
invocation.kind = "reaction" and these fields:
event:{"event": "<name>", "payload": <the event payload>}, present when the whole context fitslimits.max_context_bytes.event_omitted:"too_large"in place ofeventwhen it does not fit.session_idis then present when the payload names a session:session_idor, for session events,session.id.
Run records of a reaction carry kind = "reaction". A dropped record has
outcome = "dropped", no name, and dropped, the number of events one
overflow episode of a plugin’s queue refused. No new event kind, daemon method,
protocol code or SDK type is added for reactions.
Every --json result is one standard CLI process envelope. The ok payloads:
install:{status, plugin, warnings?}, wherestatusisinstalled,already_installedorroot_restored.warningsis present when the plugin was recorded but the store could not confirm the change is durable.list:{plugins: [plugin...]}.status:{plugins: [{id, version, digest, enabled, units: [unit...], units_error?, run_log_error?}]}, where a unit is{kind, name, state, state_reason?, last_run, consecutive_failures, backoff_ms?, backoff_error?}.kindisjoborservice;stateisrunning,stopped,failedorunknown;last_runis{ts, outcome, exit_code?, signal?, error_code?}ornull;consecutive_failuresisnullwhen it cannot be determined;backoff_msandbackoff_errorare present for services only.units_errorandrun_log_errorare present when the unit record or the run log cannot be read. The guide’s Status section defines each field.enableanddisable: the updatedplugin, pluswarningsfromdisablewhen it could not list the units of a plugin whose files fail verification and no unit record names any unit, or when nopohunek serviceinstallation is recorded, no unit is recorded, and the service manager could not be reached to look for leftover units. Each warning says so; a unit left behind refuses every run.inspect:{plugin, description, files_dir, state_dir, config_dir, actions: [{name, argv, args, timeout_ms?, max_output_bytes?}], jobs?: [{name, argv, every_s, timeout_ms?}], services?: [{name, argv}], reactions?: [{name, events, argv, timeout_ms?}]}.argv[0]is resolved to an absolute path;argsisnoneorpassthrough;jobs,servicesandreactionsare present when declared.run: the action’s stdout, which must be exactly one JSON document (JSON whitespace around it is allowed and dropped), placed asokbyte for byte: key order, number spelling and inner whitespace are the action’s own. It is printed only when the action exited 0.uninstall:{plugin, purged, purge?, warnings?, state_dir, config_dir}.pluginisnullwhen--purgeremoved only the directories of an id that has no record.purgeis{state_dir, config_dir}, eachremovedorabsent.warningsis present when the record was removed but the store could not delete every installed file (the warning names where the residue is) or confirm durability, when a leftover of an earlier purge still cannot be removed (the warning names its location), or for the same unit warnings asdisable. No warning changes the result.
A plugin is {id, version, digest, enabled, installed_at_unix_seconds}.
Typed errors use the standard {class, code, msg, recover?} shape and never
contain an argv value, an environment value, manifest text or a byte of an
action’s output. workflow_action_failed adds exit_code (the action’s exit
code, or 128 plus the signal number) and, when a signal ended the action,
signal. workflow_installed_disabled adds cause, the code of the failure
that left the plugin disabled, and its recover is that failure’s recovery
followed by the pohunek workflow enable step. These fields belong to the
CLI envelope, not to the protocol’s error type:
| Code | Class | Meaning |
|---|---|---|
workflow_local_only |
configuration |
A remote --host was given; nothing was read or written. |
workflow_archive_invalid |
configuration |
The archive file is unreadable, too large, or not a canonical package archive. |
workflow_digest_mismatch |
configuration |
The archive does not have the --sha256 digest; nothing was installed. |
workflow_descriptor_invalid |
configuration |
workflow.toml is missing, too large, or breaks a manifest rule, or the archive also carries runtime.toml. |
consent_required |
configuration |
install ran without --yes; nothing was created. |
workflow_plugin_unknown |
configuration |
No installed plugin has the id. |
workflow_plugin_installed |
configuration |
Another archive of the id is installed. |
workflow_root_invalid |
runtime |
The installed files are missing, changed or unreadable; they are left as found. The hint names the recovery for the cause: reinstall or plain uninstall for missing files, uninstall --remove-modified for changed ones. |
workflow_root_intact |
runtime |
uninstall --remove-modified was given for installed files that verify or are missing; nothing was removed. |
workflow_install_uncertain |
runtime |
An install’s record replacement committed without confirmed durability and could not be read back; the plugin’s directories were kept. Check pohunek workflow list; installing the same archive again is safe. |
workflow_store_busy |
runtime |
Another workflow command holds the store lock; nothing was changed. |
workflow_purge_incomplete |
runtime |
A purge did not finish; the message says whether the plugin was uninstalled, the outcome for each directory, and where remaining data is. |
workflow_dirs_overlap |
configuration |
The store, state and config locations contain each other; nothing was read or written. |
workflow_store_failed |
runtime |
The store or a plugin directory cannot be read or changed safely. |
workflow_config_invalid |
configuration |
<config>/pohunek/workflow.toml is unsafe (not an owner-only 0600 regular file in a directory others cannot write), unreadable, not of the knobs schema, or holds an out-of-range value; nothing was run. |
workflow_plugin_disabled |
configuration |
The plugin is disabled; nothing was run. |
workflow_action_unknown |
configuration |
The manifest declares no action of the name; nothing was run. |
workflow_action_args_refused |
configuration |
Arguments after the action name were given to an action declaring args = "none"; the message counts them and names no value; nothing was run. |
workflow_origin_incomplete |
configuration |
Exactly one of POHUNEK_SESSION_ID and POHUNEK_DAEMON_ID is set; nothing was run. |
workflow_timeout_refused |
configuration |
The action declares a timeout_ms above limits.action_timeout_max_ms; nothing was run. |
workflow_context_too_large |
configuration |
The context JSON is larger than limits.max_context_bytes; nothing was run. |
workflow_run_log_failed |
runtime |
The plugin’s run log cannot be opened; nothing was run. |
workflow_spawn_failed |
runtime |
The action’s program is missing or not executable, or its process could not be started. |
workflow_run_failed |
runtime |
The CLI could not observe the action or read its output; its process group was killed. |
workflow_action_timeout |
runtime |
The action did not finish before its deadline and its group was stopped. |
workflow_output_limit |
runtime |
Stdout or stderr passed its cap and the group was stopped; bytes past the cap were not shown. |
workflow_action_interrupted |
runtime |
The CLI received SIGINT, SIGTERM or SIGHUP and stopped the action’s group. |
workflow_action_failed |
runtime |
run --json only: the action exited non-zero or was ended by a signal the CLI did not send; carries exit_code and signal?; its stdout was not printed. |
workflow_output_incomplete |
runtime |
run --json only: the action exited 0, but a process that left its group still held stdout open, so the capture was never judged. |
workflow_output_invalid |
runtime |
run --json only: the action exited 0, but its stdout is empty, not JSON (including arrays and objects nested deeper than 126 levels, so the envelope stays within the 127 levels serde_json’s default parser accepts), ends inside its document, or has data after it. |
workflow_service_not_installed |
configuration |
The plugin declares jobs, services or reactions, and no verified pohunek service installation (its service.toml and <prefix>/bin/pohunek) describes this process, or the installation records another pohunek version than this CLI, whose units would run a trampoline this CLI cannot vouch for; the message names both versions and the recovery (pohunek service install from this version, or an upgrade to it). enable and an enabling install change nothing; a disable or uninstall has already marked the plugin disabled when its recorded installation turns out to be unusable, and the plugin stays installed disabled. disable and uninstall accept an installation of any version. |
workflow_unit_refused |
configuration |
A job’s every_s is below jobs.min_interval_s or its timeout_ms above jobs.timeout_max_ms; nothing was changed or run. |
workflow_units_failed |
runtime |
The service manager did not install or remove a unit. After a failed enable every unit was removed and the plugin disabled again; after a failed disable the plugin stays disabled; a failed uninstall keeps the plugin installed, disabled. Without a recorded pohunek service installation, a disable or uninstall that cannot reach the service manager while units are recorded fails this way and keeps the unit record. An uninstall whose unit record still names units keeps the plugin record too. |
workflow_installed_disabled |
runtime |
install --yes only: the new plugin was recorded, but a step after that failed: reaching the service manager (cause workflow_units_failed), marking the plugin enabled (workflow_store_busy while another workflow command holds the store lock, workflow_root_invalid, or workflow_store_failed; for a failure that may have written the flag, the plugin is disabled again first), or installing a unit (workflow_units_failed). cause is that failure’s code. When every step was undone the plugin stays installed disabled with no units and the message says so: fix the cause, then run pohunek workflow enable <id>. When undoing the failed step failed too (a unit install, or clearing a flag that may have been written), the message says the plugin may remain enabled or keep units: run pohunek workflow disable <id> until it succeeds, then pohunek workflow enable <id>. Every check that can refuse an enabling install, and the unit lifecycle lock, come before the record is written. |
workflow_unit_stale |
runtime |
A unit names another archive than the installed one; it was refused and recorded. |
workflow_unit_unknown |
configuration |
The installed manifest declares no job or service of the unit’s name; nothing was run. |
workflow_units_installed |
configuration |
Only pohunek service uninstall: a workflow plugin still has jobs or services recorded, or the reaction dispatcher unit is recorded. The message names each plugin to disable and its pohunek workflow disable <id>: the plugins with recorded units, and for the dispatcher each enabled plugin that declares a reaction or fails verification, or every installed plugin when none does; nothing was changed. |
workflow run streams the action’s stdout and stderr, each up to its cap.
With --json it captures stdout up to the same cap instead, still streams
stderr, and prints exactly one envelope on stdout. In both modes it exits
with:
| Exit status | When |
|---|---|
| the action’s status | The action exited by itself; 128 plus the signal number when a signal it was not sent by the CLI ended it. With --json a non-zero status is reported as workflow_action_failed. |
| 124 | workflow_action_timeout. |
| 125 | workflow_output_limit. |
| 128 plus the signal number | workflow_action_interrupted. |
| 1 | Any other workflow error, including every refusal, workflow_output_incomplete and workflow_output_invalid. |
When several apply, an output stream over its cap wins over a received
signal, which wins over the deadline; with --json, after a zero exit, a cut
stdout wins over stdout that is not one JSON document. The action’s environment, its
POHUNEK_PLUGIN_CONTEXT JSON (schema 1), the content-free run log
<state>/pohunek/workflow-plugin-state/<id>/logs/runs.jsonl and the owner
knobs <config>/pohunek/workflow.toml are specified in
docs/knowledge/guides/workflow-plugins.md, which also holds the manifest and
trust rules, and the plugin units of jobs and services: each runs the hidden
pohunek workflow __exec <id> <digest> job|service <name>, an entry point
of the service manager units, not a command to call. Event reactions run
through the dispatcher unit’s hidden pohunek workflow __dispatch (see
“Events” above and the guide’s “Event reactions”).
Hermes Operator Plugin CLI
Section titled “Hermes Operator Plugin CLI”The Hermes operator plugin is a local CLI lifecycle, not a daemon public method.
Historically, M3 did not bump the then-current public protocol v2 or add a
Hermes-specific wire shape. Current builds use public protocol v4. The plugin
assets and generated skill come only from the installed, enabled and selected
official pohunek.runtime.hermes package; the CLI verifies them against the
package’s closed operator sidecar, adds its own core hook reporter, and installs
them only into an explicitly selected Hermes profile or custom absolute home.
Without such a package, install and update fail with
configuration/hermes_package_not_installed and change nothing; status,
doctor, and uninstall keep working on an existing install.
pohunek integration install --agent hermes --hermes-profile default \ --access-mode manage --allow-host local \ --tool-timeout-ms 8000 --max-output-bytes 262144 \ --max-screen-bytes 65536 --max-concurrency 1 --jsonpohunek integration doctor --agent hermes --hermes-profile default --jsonHermes status reports installed, enabled, modified (a managed file differs
from the checksums its ownership marker records), outdated (the marker’s asset
checksums, operator sidecar digest, package id, or package digest differ from
the selected package, so an untouched install from another package version or
from an older CLI’s compiled copy is flagged; never set while no usable package
is selected, because no update could apply), package_source_error (the code
an install or update would fail with on the current package selection) and the
stale-sibling flags. The doctor’s asset_current
check fails for an outdated plugin; integration update replaces it without
--confirm-modified unless modified is true.
--hermes-profile default, a named --hermes-profile, and an absolute
--hermes-home are explicit target selections; a profile and home cannot be
combined. update is Hermes-only and returns
configuration/integration_action_unsupported for another agent; doctor
and uninstall also serve Codex and Claude (doctor follows --host,
uninstall targets the local daemon and requires --agent). Hermes status
uses the same local target contract; daemon-backed Codex/Claude status uses the
new RPC without those local Hermes flags, and their install behavior is unchanged:
pohunek integration status --jsonpohunek integration status --agent codex --jsonpohunek integration status --agent claude --jsonpohunek integration status --agent hermes --hermes-profile default --jsonpohunek integration doctor --agent codex --jsonpohunek integration uninstall --agent claude --jsonThe installation policy is Pohunek-owned, owner-private, and external to the
immutable plugin checksum set. It fixes the absolute pohunek executable,
protocol range, access mode, and host allowlist. read_only exposes only read
tools, manage adds bounded management, and full alone adds stop/remove.
Remote calls use the existing direct NetBird transport. The policy is a
delegated-tool guardrail, not an authorization sandbox for a same-user process.
Install and update accept the non-repeatable bounds --tool-timeout-ms <u32>,
--max-output-bytes <u32>, --max-screen-bytes <u32>, and
--max-concurrency <u8>. Values must be positive and cannot exceed the policy
ceilings. Install defaults omitted bounds to their ceilings. Update inherits
each omitted bound and replaces each supplied bound; it also always refreshes
the stored protocol range from the updating Pohunek binary so it repairs
protocol drift. Other installed policy fields remain unchanged unless their
existing update flags replace them. Status, doctor, and uninstall do not accept
the bound flags.
The plugin never offers raw attach bytes, arbitrary protocol methods, raw argv,
or force bypasses. It repeats the daemon-authoritative origin denial before a
subprocess for exactly session.stop, session.resume, session.remove,
session.fork, session.resize, session.set_metadata, session.rename, and
session.input. The plugin exposes no consent removal, so it has no counterpart for the daemon’s denial of session.remove_accepting_unconfirmed. Exactly three lifecycle reports may target the origin:
session.report_agent, session.release_agent, and
session.report_native_id.
CLI Notification Surface
Section titled “CLI Notification Surface”The CLI exposes durable notifications through pohunek notifications:
pohunek notifications list: list records on one host;--all-hostsincludes local plus reachable daemon hosts from the standalone local-NetBird discovery cache. It does not need a local daemon to expand remote targets.pohunek notifications watch: streamnotification_created,notification_updated, andnotification_deleted;--all-hostsopens one subscription per reachable host.--session,--kind, and--status(or--unread) filter client-side on the record each create or update event carries; while any filter is set,notification_deletedevents are dropped because they carry only the notification id.pohunek notifications read|ack|archive|delete <target>: update one record. Targets accept bareidorhost/id; an explicit target host overrides--host.pohunek notifications policy get|set: read policy or toggle one provider/kind flag.setaccepts--provider default|<runtime-id>, wheredefaultselects the baseenabledflags and any valid runtime id, installed or not, selects that provider’s override (seeded fromenabled).defaultis a keyword, so it shadows a runtime literally nameddefault, whose override can be set only throughnotification.policy.set.setalso takes--kind <kind>and exactly one of--enabledor--disabled.pohunek notifications retention prune: explicitly prune records selected by--status,--before, and--limit, with exactly one of--dry-runor--apply. Automatic age-based retention runs independently in the daemon from the persisted policy returned bypolicy get.
Commands that support --all-hosts render per-host successes and structured
per-host errors. Cross-host notification aggregation is client-side; no central
notification server is introduced.
Attach Stream
Section titled “Attach Stream”The attach byte stream is part of public protocol v4. It is not an implementation detail of the CLI.
Sequence:
-
On a normal control connection, send
session.attachwithSessionAttachParams. Interactive clients should include validatedinitial_dimensionswhen their terminal geometry is known. -
The daemon returns
SessionAttachResultwith a one-shotstream_id. -
Open a second connection to the same daemon and transport family.
-
Send exactly one newline-delimited attach prelude:
{"attach":"a-1"} -
After the prelude newline, the worker applies
initial_dimensionswhen present and the connection switches to raw bidirectional PTY bytes. The daemon first sends one complete ANSI repaint of the current terminal state, followed atomically by live PTY output at the repaint watermark. User input flows client-to-daemon. -
Send
session.resizeandsession.detachon the control connection, not on the raw byte stream.
Attach stream rules:
- The prelude has no
vfield. Its version is governed by the control protocol version that minted thestream_id. - The prelude object must contain exactly one field,
attach, with a non-empty string. stream_idis one-shot and short-lived. The current default TTL is 10 seconds.- If redemption fails, the daemon replies with a normal error response on the second connection and does not switch to raw byte mode.
- After successful redemption, bytes are opaque. Clients must not assume UTF-8.
- A fresh attach never reconstructs the screen by replaying raw output emitted at historical terminal sizes. It starts from the current terminal snapshot, then receives live bytes without a gap or overlap.
initial_dimensionsis optional for non-terminal clients. Omitting it keeps the worker’s current geometry while preserving snapshot-first attach.- Workers negotiated below private worker protocol v3 cannot provide the
atomic resize-and-snapshot guarantee. The daemon rejects such an attach with
runtime/attach_snapshot_unsupported; restart the session on the upgraded worker or fork it into a new session. session.detachcancels an active stream bystream_id; closing the raw socket also ends the attach. If the worker ended the raw stream with a typed failure, the first detach call after EOF returns that failure in the optionalerrorfield. The result is bounded, short-lived, and consumed once.session.attachmay includeorigin_session_id,origin_worker_id, and the additive legacyorigin_daemon_id. New clients read the stable worker id from their managed PTY environment. When the session and worker identify the target runtime the client is already running inside, the daemon rejects the attach withdaemon/attach_self_feedback; this remains correct after daemon replacement.- On the WebSocket relay transport, the attach prelude is
sent as the first bytes on the
/daemon/<host>/attachbinary WebSocket. After redemption, every binary frame remains opaque PTY data.
Rust SDK helpers:
attach_raw(host, socket_path, stream_id)attach_raw_local(socket_path, stream_id)attach_raw_tcp_addr(host, addr, stream_id)*_with_optionsvariants
These helpers open the raw connection and write the prelude before returning a
RawStream.
External clients
Section titled “External clients”The web control center and the native desktop GUI live in
zajca/pohunek-work and are external
clients of this protocol and the SDKs; nothing in them is private. They pin
core by git tag (Rust crates) and by release-tarball URL and integrity (the
TypeScript SDK), and pin the protocol version they were built against: the
TypeScript SDK handshake accepts any version inside CLIENT_PROTOCOL_VERSIONS
and translates an older daemon’s payloads, and a daemon one release newer serves
the previous version. Core crates beyond
pohunek-client that a Rust client links are a pinned, not a stable, API with no
back-compat shims. Three methods
are public obligations whose callers are mostly those external UI clients, so
core keeps server-side contract tests for each of them: host.discover and
worktree.remove are called only by the UI clients, and subscribe is also
called by the CLI (pohunek subscribe, pohunek attach, pohunek notifications).
host.discover: socket-level coverage incrates/daemon/tests/health_socket.rs(public_bind_serves_host_discover_with_supplied_registry) and the version-mismatch contract test incrates/daemon/tests/remote_tcp.rs.subscribe: socket-level coverage incrates/daemon/tests/health_socket.rs(subscribe_streams_session_created_eventand the notification and agent-state subscribe tests) and the client subscription tests incrates/client/tests/subscription.rs.worktree.remove: socket-level coverage incrates/daemon/tests/health_socket.rs(worktree_remove_over_the_socket_succeeds_and_fails_closed): success after stop,worktree_in_usefor a live session,worktree_not_ownedfor the main checkout, andbad_requestfor malformed params.
Rust SDK Surface
Section titled “Rust SDK Surface”The pohunek-client crate is the supported Rust client surface. New Rust
clients should use it rather than hand-writing protocol framing.
Public exports:
protocol: re-export ofpohunek-protocol.Client: framed request/response and subscription client.ClientOptions:request_timeoutandconnect_timeout, both defaulting to 5 seconds, andorigin_source, set withwith_origin_source. A typedsession.newuses a dedicated connection with a response budget of 60 minutes plus 1 second of transport headroom. This covers the valid host bounds of up to 10 minutes each for worker connection, worker initialization, and, with initial input, reader grace, plus the create hooks:pre-createandpost-createeach run a host layer and a repository layer capped at 5 minutes. The rest is room for other creation work.with_request_timeoutexplicitly overrides that budget, including with a shorter deadline; assigning a nondefault value to the publicClientOptions.request_timeoutfield has the same effect. Dropping the Rust request future ends the client’s wait; it does not prove the daemon canceled a creation already in progress. Raw envelope requests retain the ordinary timeout.ConnectionOptions.request_timeoutin the assistant host API isNoneby default, preserving these SDK budgets.Some(duration)is an explicit override even whendurationequals the ordinary 5-second default. An omitted serialized field also meansNone.OriginSource: where a connection takes its request origin from.Environment(the default) readsPOHUNEK_SESSION_IDandPOHUNEK_DAEMON_IDfrom the calling process and rejects a partial or invalid pair;Omittedattaches no origin of the connection’s own and never reads the environment (a request built with an explicit origin keeps it).DiscoveryOptions::with_origin_sourceselects it for discovery probes.Subscription: raw event-line stream after a successfulsubscribe.RawStream: local Unix or remote TCP raw byte stream for attach.ClientError: SDK error enum withto_protocol_error()for structured rendering. An elapsed response deadline maps torequest_timeout, distinct from connection and daemon-discovery failures; the timed-out mutation may still have completed remotely.next_request_id(method): shared correlation-id generator used by SDK-backed clients.remote_host_with_port(host, port): formats a provider-resolved selector with an explicit non-zero daemon port.ConfiguredTransportandOverlayRegistry: validated provider registry with stable overlay IDs and one non-zero daemon port per provider.OverlayTransport: provider contract whose required typed identity resolver must match only the requested peer-ID or FQDN field; implementations cannot inherit an untyped selector fallback.discover_hosts(registry): aggregated configured-overlay discovery with default bounded probes.discover_hosts_with_options(registry, options): the same discovery with an explicit per-probe timeout, overall deadline, and concurrency bound. Provider failures are isolated unless every configured overlay fails. Providers return remote peers only; clients that need a local entry add their Unix-socket target explicitly.- Raw and attach helpers:
connect_raw*andattach_raw*.
Public daemon construction is registry-explicit. DaemonState::new and
ControlServer::bind require a validated OverlayRegistry, and the shared
discovery cache cannot be constructed without one. This keeps host.discover
usable through the public server constructor instead of creating a latent
registry-less state.
Connection APIs:
Client::connect(host, socket_path):""and"local"use the Unix socket; any other host is resolved through the default configured overlay registry and dialed over its exact per-overlay route.<overlay>:<selector>restricts resolution to one configured provider. The optional<overlay>:<selector>@<port>form retains an explicit discovered daemon port while still resolving the selector through current provider state. The@character is reserved by this route grammar. Generated stable selectors usepeer~<base64url>orfqdn~<base64url>without padding; the registry decodes the value and preserves its identity kind before provider resolution. Providers must implement that typed resolution explicitly and cannot reinterpret an FQDN as a peer ID or short name. This keeps raw provider IDs containing/,+,=, or@out of target grammar. A bare IPv6 literal remains an unqualified selector; only an explicit configured-overlay prefix qualifies it. A socket-address literal is not a bypass and must resolve under current provider policy.Client::connect_with_registry(host, socket_path, registry): the same routing with a caller-supplied registry; ambiguous names fail closed.names_this_machine(host, connect_timeout)andnames_this_machine_in(host, connect_timeout, ®istry): whether a host name is this machine’s own overlay identity, resolved withOverlayRegistry::resolve_target. A provider’sresolve_selectormatches its own identity at the same steps as a peer’s, so a name both carry isoverlay_host_ambiguous.local, an unknown name, an unavailable provider, and a@<port>route answerfalse.Client::connectitself still treats only""and"local"as local; the CLI applies this check before it connects (seedocs/cli.md, host resolution).Client::connect_local(socket_path): direct Unix socket.Client::connect_trusted_tcp_addr(host, addr): direct TCP for a route already validated by configured overlay discovery, with host context preserved for remote errors.*_with_optionsvariants acceptClientOptions.Client::attach_raw(stream_id): opens the raw attach connection on the exact endpoint selected for that client, without re-resolving the peer.
Request APIs:
Client::call::<M: protocol::Method>(params) -> M::Output: sends one typed method request, pairing the method name, params, and success payload through marker types inprotocol::method.Client::handshake() -> ProtocolVersion: callsdaemon.healthand returns the version selected by the first valid response on that connection.Client::selected_version() -> Option<ProtocolVersion>: returns the fixed per-connection selection after the first response.Client::session_screen(SessionScreenParams): reads one rendered snapshot on the current connection.Client::session_detection(SessionDetectionParams): requests current active manifest-region previews from the live detector task.Client::session_read(SessionReadParams): reads bounded current-screen text on the current connection.Client::session_output(SessionOutputParams): uses the current connection for an immediate read and automatically opens a dedicated connection whenwait_msis present.Client::session_input(SessionInputParams)and generic typedClient::call::<SessionInput>: use a dedicated connection whenwaitis present, budgets the wire timeout as the daemon’s overall delivery-and-wait deadline plus fixed response headroom, and rejects successful responses that omit epoch- and runtime-scoped activity evidence.Client::session_wait(SessionWaitParams): automatically opens a dedicated connection for the bounded long poll.Client::session_wait_turn(SessionWaitTurnParams): opens a dedicated connection whose response deadline istimeout_msplus transport headroom, orMAX_SESSION_WAIT_TURN_MSplus headroom when the request leaves the duration to the daemon default, so the client deadline never cuts a valid wait short.Client::host_governance_inspect(): callshost.governance.inspectwith exact null parameters and returns the safeHostGovernanceStatusprojection. It has no local cache or mutation behavior.Client::session_resume,session_resize, andsession_set_metadata: typed lifecycle helpers used by automation clients.Client::integration_status(IntegrationStatusParams): reads the complete daemon-managed Codex/Claude install contract without mutation.Client::request(&Request) -> serde_json::Value: sends one request and returns theokpayload in the current shape for low-level callers and framing tests; against a daemon of the previous protocol version it translates both ways, and fails withClientError::DaemonProtocolTooOldbefore sending a method that version never defined.Client::subscribe(&Request) -> Subscription: consumes the client connection after a subscribe ack.Subscription::next_line() -> Option<String>: returns event JSON lines in the current shape; a connection that selected the previous protocol version translates each event first.Subscription::next_event() -> Option<Event>: decodes one event JSON line into the protocol event envelope.Client::create_notification(NotificationCreateParams): callsnotification.create.Client::list_notifications(NotificationListParams): callsnotification.list.Client::update_notification(NotificationUpdateParams): callsnotification.update.Client::delete_notification(NotificationDeleteParams): callsnotification.delete.Client::get_notification_policy(): callsnotification.policy.get.Client::set_notification_policy(NotificationPolicyParams): callsnotification.policy.set.Client::prune_notifications(NotificationRetentionParams): callsnotification.retention.prune.
SDK error mapping preserves daemon protocol errors and adds host/transport
context for local and remote failures. Use ClientError::to_protocol_error() to
render SDK failures in the same envelope taxonomy as daemon errors.
TypeScript SDK Surface
Section titled “TypeScript SDK Surface”The @pohunek/sdk package mirrors the Rust SDK surface for TypeScript clients.
Its browser-safe @pohunek/sdk/browser entry contains no node:net imports and
exports only the shared runtime and WebSocket path. Domain types, method maps,
event unions, constants, and generated protocol types come from
@pohunek/protocol; the SDK owns envelopes, framing, transports,
request/subscription orchestration, attach helpers, and structured client
errors.
Distribution: the TypeScript packages are not published to an npm registry.
Each release attaches three npm-pack tarballs, pohunek-ts-protocol-X.Y.Z.tgz,
pohunek-ts-sdk-X.Y.Z.tgz (the @pohunek/sdk package) and
pohunek-ts-testkit-X.Y.Z.tgz, each with a .sha256 file in the same format as
the other release checksums. A consumer pins the release asset URL in its
package.json, for example
"@pohunek/sdk": "https://github.com/zajca/pohunek/releases/download/vX.Y.Z/pohunek-ts-sdk-X.Y.Z.tgz",
and the lockfile records the tarball integrity. Inside each tarball every
@pohunek/* dependency is rewritten to the exact release-asset URL of the
sibling package for the same tag, so the closure resolves from one release and
the @pohunek scope is never looked up on a registry. devDependencies,
scripts and private are dropped from the packed manifests. Each tarball
ships compiled ES modules under dist/, matching declarations under types/,
and (for @pohunek/protocol) the fixtures/* data files; no TypeScript source
is shipped. The packed exports map points every entry at ./dist/*.js with
a types condition at ./types/*.d.ts, so the packages load under Node without
a loader or bundler and type-check under both moduleResolution: "bundler" and
"nodenext". The packages have no third-party runtime dependencies. Within the
repository workspace the manifests keep pointing at src/*.ts for the Bun dev
loop; only the packed artifact is compiled, by
sdk/ts/scripts/build-package.ts. @pohunek/testkit is shipped compiled the
same way. Its root entry uses no Bun globals and runs on Node >= 20 and Bun; the
explicit @pohunek/testkit/bun-relay subpath needs Bun.serve and is Bun-only
(under Node it loads but startTestRelay fails fast, naming the subpath). The sdk/ts/scripts/test/pack-contract.test.ts contract test
installs the packed tarballs by URL against an unreachable registry, imports
every entry point under Bun and under the Node binary named by
POHUNEK_TEST_NODE_BIN, type-checks a consumer with both resolvers, and checks
that the browser entry reaches no node: module. It also starts the installed
@pohunek/testkit/bun-relay under Bun and tunnels a real WebSocket to a real
Unix-socket server, and asserts the root testkit entry does not expose the
relay. CI and the release SDK gate run
it under Node 20 and Node 22.
Public exports:
Client: framed request/response and subscription client.nextRequestId(method): shared correlation-id generator used by SDK-backed TypeScript clients.SocketTransport: direct Unix/TCPnode:nettransport for Bun/Node; exported only by the root@pohunek/sdkentry.WsTransport: transparent WebSocket relay transport using the WHATWGWebSocketglobal.Transport: pluggable transport interface withcontrol()for framed control channels andraw()for unframed attach channels.ControlChannel:send(line), asynclines, andclose()for one framed control connection.RawDuplex:ReadableStream<Uint8Array>,WritableStream<Uint8Array>, andclose()for one raw attach connection.ConnectOptions: TypeScript counterpart to RustClientOptions; the package does not export a separateClientOptionsalias. It carriesconnectTimeoutMsandrequestTimeoutMs, both defaulting to 5000 ms, plus an optional validatedorigin: {sessionId, daemonId}pair. A typedsession.newuses the same dedicated 60-minute response budget and 1-second transport headroom unlessrequestTimeoutMsis explicitly supplied; an explicit value replaces the dedicated budget. A timed-out creation may still have completed on the daemon, so inspect sessions before any retry. The typed call opts into the configured host reader grace when it sends initial input.RequestOriginandresolveRequestOrigin: explicit browser-safe origin configuration and atomic identifier validation. Browser and Bun/Node defaults are absent; the SDK never readsprocess.env.ResolvedConnectOptions,DEFAULT_CONNECT_TIMEOUT_MS,DEFAULT_REQUEST_TIMEOUT_MS, andresolveConnectOptions.Subscription: event-line stream after a successfulsubscribe.decodeProtocolEvent: decodes one event envelope into the generated typed event union when the event name is known.CatchAllEvent: forward-compatible shape for unknown event names.RawStream:ReadableStream<Uint8Array>plusWritableStream<Uint8Array>attach duplex.ClientError: structured SDK error withtoProtocolError().CompatError,downgradeRequestParams,upgradeResult, andupgradeEventPayload: the shape-only adapter between the current protocol and the previous version thatClientapplies on a connection that negotiated the previous version; also exported for low-level callers.ClientErrorClass,ClientErrorCode, andClientErrorKind: the SDK error taxonomy used byClientError.Request,Response,OkResponse,ErrResponse, andEvent: hand-written control envelopes used by the runtime SDK layer.decodeResponse,isRequest,isOkResponse,isErrResponse, andisEvent: envelope guards and decoders for low-level callers and tests.- Raw and attach helpers: both entries export
connectRawWs,attachRawWs,connectRawTransport, andattachRawTransport; the root entry additionally exportsconnectRawLocal,connectRawTcp,attachRaw,attachRawLocal, andattachRawTcp. - Re-export of every symbol from
@pohunek/protocol, including generated domain types,Methods,ProtocolEvent,EventName,AttachPrelude,PROTOCOL_VERSION,MAX_CONTROL_LINE_BYTES,EVENT_NAMES, and individual event-name constants. Generated files undersdk/ts/protocol/src/generated/**are refreshed only bycargo xtask ts generate, never hand-edited.
Supported runtimes:
- Bun: supports the direct socket transport and the WebSocket relay transport.
- Node >= 20: supports the direct Unix/TCP socket transport through
node:net. Node 18 is not supported: the SDK reads theglobalThis.cryptoglobal, which Node exposes without a flag only from Node 19. - Node >= 22: supports the WebSocket relay transport through the
built-in WHATWG
WebSocketglobal. - Browser: import
@pohunek/sdk/browser; it supports only the WebSocket relay transport because browsers cannot dial daemon Unix sockets or NetBird TCP directly.
Connection APIs:
Client.defaultOptions(): returns resolved default timeouts.Client.connectWs(baseUrl, host, opts?): WebSocket relay transport.baseUrlmay usehttp,https,ws, orwss; the SDK connects to/daemon/<host>/controlunder that base URL.Client.connectTransport(transport, opts?, remoteHost?): injection point for tests and custom transports that implementTransport.connectLocal(socketPath, opts?)andconnectTcp(host, {host, port}, opts?): root-entry Bun/Node helpers for a direct Unix socket or daemon TCP address.SocketTransport.unix(socketPath, opts?)andSocketTransport.tcp(host, {host, port}, opts?): construct direct socket transports.WsTransport.relay(baseUrl, host, opts?): constructs the WebSocket relay transport for/daemon/<host>/controland/daemon/<host>/attach. The method name predates the accepted team relay.
Request APIs:
client.call(method, params): typed call keyed by the generatedMethodsmap. A configured origin is added to the wire request.client.call("host.governance.inspect", null): the generated method map returnsHostGovernanceStatusonly after runtime validation of the exact six required fields, canonical opaque IDs and revisions, known lifecycle and quarantine values, and the enrollment/owner/quarantine invariants. A missing, unknown, malformed, or inconsistent result fails closed as the redacted, SDK-originateddaemon/host_governance_inspect_contract_mismatcherror; it never includes the raw response payload. The TypeScript SDK deliberately has no governance mutation convenience API and exports only the public-safe result types.client.sessionInput(params): uses a dedicated connection whenwaitis present, budgets the wire timeout as the daemon’s overall delivery-and-wait deadline plus fixed response headroom, and rejects successful responses that omit epoch- and runtime-scoped activity evidence.client.sessionWaitTurn(params)and generic typedclient.call("session.wait_turn", params): open a dedicated connection whose response deadline istimeout_msplus fixed headroom, orMAX_SESSION_WAIT_TURN_MSplus headroom whentimeout_msis omitted, sorequestTimeoutMsnever cuts a valid turn wait short. Atimeout_msthat is not a nonzerou32fails withdaemon/bad_requestbefore sending, because JSON would serializeNaNorInfinityasnull, which the daemon reads as “use the default”.client.handshake(): callsdaemon.healthand enforces strict protocol version equality.client.request(request): validates the optional atomic wire origin, applies the client origin when configured, sends one raw request envelope, and returns theokpayload.client.subscribe(request): applies the same configured origin, consumes the control connection after the subscribe ack, and returnsSubscription.client.close(): closes the control channel.subscription.nextLine(): returns raw event JSON text ornullon close.subscription.nextEvent(): returnsProtocolEvent | CatchAllEvent | null. Known event names decode to the generated typed union; unknown event names are preserved asCatchAllEventrather than rejected, so older clients tolerate additive daemon events.
Attach APIs:
- Root-entry
connectRawLocalandconnectRawTcp, plus sharedconnectRawWs, open unframed raw byte channels without writing the attach prelude. - Root-entry
attachRaw(host, socketPath, streamId, opts?)mirrors the Rust convenience helper for local hosts (""or"local"). The TypeScript SDK core does not perform NetBird host resolution; remote callers pass an explicit address toattachRawTcpor use a WebSocket relay withattachRawWs. - Root-entry
attachRawLocalandattachRawTcp, plus sharedattachRawWs, open a raw channel, write exactly one attach prelude, parse a failed redemption response asClientError, and otherwise return the raw attach stream.
SDK error mapping:
- Daemon protocol errors are preserved as
ClientError.kind === "protocol"or"remoteProtocol"and retain the daemon’s originalclass,code,msg, andrecoverfields, except for the local TypeScript decoder’sdaemon/host_governance_inspect_contract_mismatchresult. That redacted contract-mismatch error is SDK-originated even though it uses the normal structured protocol shape, as are the pre-send parameter refusals of the waiting helpers (runtime/session_input_invalid_waitandruntime/session_wait_limit_exceededfromsessionInput,daemon/bad_requestfromsessionWaitTurn). - Most SDK-originated errors map into the public protocol taxonomy through
ClientErrorClassandClientErrorCode:daemon_unreachable,framing,host_unreachable,remote_daemon_unavailable,request_timeout,io_error,json_error,session_input_wait_contract_mismatch,version_mismatch,daemon_protocol_too_oldandversion_translation_failed. The last two are the window errors:ClientError.kinddaemonProtocolTooOldcarriestooOld.{host, method, daemonVersion, requiredVersion}and is raised before a method new in the current protocol is sent to a daemon that negotiated the previous one.host_governance_inspect_contract_mismatchis the separate fixed, redacted SDK-originated governance-response validation error. ClientError.toProtocolError()returns the structuredProtocolErrorfor CLI/API rendering, andrecoverHint()returns the optional recovery text.
The Bun-only @pohunek/testkit/bun-relay subpath (not the root entry, which
also runs on Node) exports the loopback-only test relay used by the SDK
transport tests:
startTestRelay({bindHost, port, targets}) returns a TestRelayHandle
(url, port, close()), with the DaemonTarget, DaemonTargetSource, and
StartTestRelayOptions types. bindHost must be a loopback address; any other
bind fails. The helper implements the WebSocket framing documented above as a
transparent 1:1 tunnel and is not a deployable relay or pohunek-relayd.