Sessions
A session is a durable logical record running an agent in a worker-owned PTY.
pohunekd owns the public API, metadata, and logical lifecycle; one isolated
pohunek-sessiond worker owns the live PTY generation and child process. The
CLI controls sessions through the daemon: start with pohunek session new,
inspect with pohunek session inspect, list with pohunek session list, send
input with pohunek session input, stop with pohunek session stop, and attach
with pohunek attach.
Automation can observe a managed terminal without attaching. Use
pohunek session screen <target> --json for one rendered snapshot,
pohunek session detection <target> --json for the active detector’s region
previews and complete supported region-kind set,
pohunek session read <target> --source recent --lines 100 --json for the newest
bounded current-screen text. Current workers safely report source_used: "visible" for recent, unwrapped, and detection requests because they do not
expose scrollback or soft-wrap metadata; alternate_screen remains truthful.
pohunek session output <target> --json for a bounded newest retained tail,
and pohunek session wait <target> --state ... --timeout-ms <1..8000> --json
(any legacy predicate flag selects this mode) for one bounded
state/activity/terminal/output change of one session. Continue output with the exact
worker_instance_id, decimal-string runtime_generation, and next_offset returned by
the previous result. A retained-history gap means older requested bytes were
evicted; a runtime change means discard old cursors and restart from a fresh
screen or tail. Waiting calls use dedicated connections. Closing the
connection releases the waiter slots of an in-flight session.wait, waited
session.output, or session.wait_turn at once; the timeout stays the
guaranteed release bound for a client that vanishes without closing it.
To follow agents across turns, a client runs pohunek session wait <target>... [--until <kinds>] [--after <cursor>] [--timeout <duration>] --json (turn mode,
also the mode with no flags) or calls the protocol method session.wait_turn
(Rust SDK Client::session_wait_turn, TypeScript SDK client.sessionWaitTurn).
It waits for the
next turn event of one or more sessions, turn_completed, agent_blocked,
approval_required, or exited, newer than an opaque cursor (or newer than
the call when no cursor is given), bounded by an agent-chosen timeout_ms
(daemon default 5 minutes, ceiling 30 minutes, both set by service.toml
[waits]; a longer request fails with session_wait_limit_exceeded). Every result carries the next cursor and, for
every target, state, activity, last_output_at, output_advanced (output
since the cursor), and turn_started_at, so a timeout still tells “still
working” from “stuck”. Chain waits with the returned cursor; an event recorded
between two waits is returned by the next one. cursor_reset means the cursor
came from another daemon instance or lies outside what the in-memory journal retains
(also while waiting); take the fresh cursor and inspect the sessions.
targets_ended means every target is terminal and nothing newer is pending.
Turn events come only from admitted provider hook reports for sessions the
daemon holds and from the daemon’s own projector, and are independent of the
notification policy: a turn_completed the inbox drops (the default) still
completes the wait. An event is recorded when the attention debounce window
(5 seconds by default) has passed since the last report, so it arrives no
earlier than that after the agent stops. A turn_completed whose window ends
while the session is working is a stale stop and is dropped; a short turn
that starts and stops inside one window is still recorded once.
The CLI turn mode takes up to 32 targets that must all be on one host (targets
on different hosts are a usage error before any request; run one wait per
host). --until is a comma list or repeated flag and defaults to all four
kinds; --timeout needs a unit (90s, 5m) and defaults to the daemon
default. --json prints the daemon result as one document; the human form
prints the event (<session> <kind> <at>) or the reason, one progress: line
per target, and cursor: <cursor> last. Every reason exits 0, a timeout
included, as the legacy wait does. Mixing --until, --after, or --timeout
with a legacy flag is a usage error, and the legacy mode takes one target.
The cursor orders when the daemon records events, not when the agent acted.
turn_completed, agent_blocked, and approval_required are recorded when the
debounce window ends, and a pending turn_completed is dropped when the
daemon sees the session enter working inside the window or finds it working
when the window ends; exited is recorded
at once. The stop of the previous turn can therefore be recorded after a
cursor taken shortly after it, and a wait --after that cursor would return
the old stop as the end of a new prompt. Before sending a prompt, use the
cursor of the result that returned the previous turn event, or take a cursor
(session wait <target> --timeout 1ms --json) only once at least
attention_debounce_secs have passed since the previous turn ended; then send
the input and wait with --after <cursor>. A short wait returns the current
cursor, usually with reason: timeout, but it can also return an event or
targets_ended.
Detection manifests can read osc_title, osc_progress, whole_recent,
bottom_lines(N), bottom_non_empty_lines(N), top_non_empty_lines(N),
last_non_empty_above_prompt_box, after_last_prompt_marker,
prompt_box_body, and after_last_horizontal_rule. Parameterized regions keep
their count in each diagnostic preview. Top and prompt-adjacent regions use the
same visible-grid, wide-glyph, and soft-wrap semantics as live matching. An
unknown region fails manifest parsing instead of falling back to a broader
region. A preview waits for an accepted active-agent configuration change before
it renders. If all active previews cannot fit the public response budget,
session detection returns session_detection_response_too_large instead of
closing the control connection.
Use --input-stdin (alias --stdin) with session new, or --stdin with
session input, when prompt text should not appear in argv. Stdin and inline
input are mutually exclusive and bounded. Hermes programmatic input rejects
terminal controls other than intentional LF and tab, and it is disabled while
Hermes is visibly blocked on owner approval. In JSON mode, stdout contains
exactly one versioned document with either ok or err; diagnostics remain on
stderr.
pohunek session input s-01J00000000000000000000000 'Continue.' --until idle --timeout 1000
can confirm delivery for an
agent profile whose submit framing has no delay. The daemon first validates the
whole wait contract, so zero or over-limit timeouts cannot deliver text;
duplicate --until values are deduplicated in first-occurrence order; omitted
targets default to idle and blocked; the timeout range is 1..8000 ms
(default 8000). The timeout is one overall deadline measured before the
per-session input gate, so gate contention, the two-fragment worker-plan
acknowledgement, and activity waiting all consume it. Every input plan preserves
the body fragment and separate submit fragment; fire-and-forget keeps the
provider delay on the body fragment. A waited request rejects blocked activity
as session_agent_blocked regardless of provider policy. Because the daemon
cannot revalidate activity during a worker-owned delay or safely retract text
already consumed by an arbitrary TUI, waited input rejects delayed framing with
session_input_wait_unsupported before any bytes are written. Zero-delay waited
input reserves the exclusive worker write first, then captures its causal
boundary immediately before the prepared atomic two-fragment plan starts. A
timeout or shutdown while reserving the worker cancels the unsent plan without
PTY bytes. After send starts, the exchange continues consuming its late
acknowledgement and holds the per-session gate to keep the shared control stream
synchronized; after the plan is sent, delivery
outcome may be unknown, so callers inspect the session and do not retry blindly. Waiting
acquires one observation waiter slot and returns exact post-submit evidence as
activity, activity_source, runtime, activity_epoch, and decimal-string
activity_revision. Clients deduplicate by (activity_epoch, runtime, activity_revision) because a daemon reconnect changes the epoch while retaining
the worker runtime. Rapid matching transitions remain valid even when the latest
activity changes again, arrives before submit ACK, or the event receiver lags. The wait returns
session_not_running if that runtime exits, session_runtime_changed if it is
replaced, session_input_wait_unsupported for delayed provider framing, and
session_input_timeout when delivery acknowledgement or a target does not
arrive before the deadline. Rust and TypeScript
SDK helpers validate the timeout locally and fail closed with
session_input_wait_contract_mismatch when a daemon ignores wait or omits
runtime-scoped evidence. Its recovery guidance says to inspect the session rather
than blindly resending input because delivery may already have happened.
Generic typed Rust and TypeScript Client.call paths route waited input through
the same helper, so they cannot bypass this validation. SIGINT or SIGTERM during
a waited CLI input returns JSON code session_input_interrupted without a retry
hint because delivery outcome is unknown.
Every session.input write, waited or not, and the initial input of
session.new are bounded by the worker’s input stall bound
(input.write_stall_timeout_ms in service.toml). When an agent stops reading
its terminal and the PTY accepts no byte for that long, the write fails with
session_input_stalled and the rest of that input is not written; the part
already accepted may have reached the agent, so inspect the session before
resending. For session.new the failed delivery rolls the new session back. A
worker started by a daemon older than worker protocol 7 keeps unbounded writes. Input typed or
pasted through an attached terminal is not bounded and waits like a real
terminal; while it waits, a session.input to the same session fails with
session_input_stalled after the stall bound instead of queueing behind it.
pohunek session diff <target> [--base <ref>] [--json] prints a unified diff
of a session’s worktree against a base ref: raw diff text on stdout by
default, or the structured SessionDiffResult (diff, base, truncated)
with --json. --base overrides the base ref; omitted, the daemon falls back
to the worktree binding’s recorded base branch, then the repository’s default
branch, and always echoes whichever ref it actually used in the result’s
base field. A session without a bound worktree fails with a typed
session_no_worktree error — there is nothing to diff for a plain-cwd
session. The diff covers tracked changes plus untracked files (rendered as
added-file diffs) and is truncated at a file boundary when it exceeds the
daemon’s size cap, reported via truncated: true.
Session targets are host-aware. A bare session id targets the local host; a
<host>/<session-id> target names a specific host. Remote session creation keeps
the existing confirmation behavior: non-local starts require explicit approval,
and JSON/non-interactive remote starts require --yes.
Daemon-issued session IDs use the s-<ULID> form. They are time-sortable opaque
identifiers, not sequence numbers; clients must preserve and display them
verbatim rather than deriving ordering or lifecycle meaning from their values.
Worker runtime paths, journals, and service-manager job names are derived only
from an s-<ULID> ID or the numeric s-<digits> form of older records, with 1
to 20 digits; any other ID is rejected there.
A session can carry an optional owner-set display name. Set it at creation with
pohunek session new --name <NAME>, and change or clear it later with
pohunek session rename <target> <NAME> (or --clear). The name is cosmetic:
it shows in pohunek session list, session inspect, and other clients, but never
affects targeting or recovery — a session is still addressed by its id. The
daemon trims the name and rejects a control character or an over-long one. The
name is stored in the logical session record, so it survives daemon and worker
loss.
The assistant feature reuses this session lifecycle. Its opening prompt is just initial input to a normal session, so session warnings and applied-input status remain the source of truth for whether the agent received that prompt.
For profile-backed agents, session.new writes initial input to the PTY after
the agent shows an editable prompt or enables bracketed paste. An upper-bound
startup grace permits delivery when the agent stays silent. The daemon writes
the prompt body and submit key as separate fragments using the runtime’s input
rules. Native hosts record the startup grace and the host submit delay in
service.toml under [input]; fresh installs use 5000 ms and 150 ms. The
submit delay applies to the runtimes whose descriptor marks their submit delay
configurable; a per-runtime value the host set is not imposed on the others.
A successful write confirms PTY delivery, not agent consumption.
The current SDK and CLI opt into the host’s full configured grace for initial
input. An older client that does not send that option keeps a 500 ms silent
startup bound so its five-second response deadline remains usable.
A runtime whose descriptor declares [input] ready = "screen_idle" (Claude
Code, Pi and Hermes) is ready only when its rendered prompt shows. This applies
to initial input written into the terminal; a launch without a host profile of
a runtime that takes its first prompt as an argument (Claude, Pi) passes it on
the command line and does not wait. Claude
enables bracketed paste before its prompt box accepts input and drops earlier
pastes, Pi enables it before it draws any screen, and Hermes discards input
queued during its start, so neither the enable signal nor the end of the grace
starts the write. Such a runtime gets the full configured grace even from an
older client. When the box does not render in time, session.new
fails with session_input_not_ready, the initial input is not written, and the
session is removed through the same rollback as any failed initial-input create.
A session can also carry owner metadata, set atomically at creation with
repeatable pohunek session new --meta key=value flags (split on the first
=, so a value may itself contain =; a missing =, an empty key, or a key
repeated across separate --meta flags fails before any connection is
dialed). The daemon enforces size limits on the values. The link.* key
family (link.provider, link.kind, link.id, link.url, link.branch) is
the cross-surface convention for tying a session to a work item: every client
and the launch scripts write exactly these five keys through the shared
pohunek_prompt::link implementation, so a link is byte-identical regardless
of which surface created the session. The daemon treats all metadata as
opaque owner-controlled strings.
The session.list protocol request can filter this metadata with exact-value
(metadata_eq) and key-presence (metadata_has) predicates. Equality compares
the complete value; presence also matches an empty value. Metadata filters
combine with each other and existing session filters using AND semantics. Keys
and values remain opaque strings and use the same validation as
session.set_metadata. When a current Rust or TypeScript client targets a
protocol-3 daemon, a metadata filter fails with
daemon/daemon_protocol_too_old before the request is sent.
The CLI exposes these predicates as repeatable pohunek session list --meta-eq key=value and --meta-has key options. Equality splits on the first
= so values can contain =; empty keys and values are valid. Repeated
predicates are all required to match, including contradictory values for the
same key, which produce no sessions. These options affect selection only and do
not add metadata to the human table or quiet output.
Sessions created by older review flows may retain review.source and
review.dispatched_at metadata. Those keys remain opaque session metadata.
Notifications can be linked to a session through session_id. Provider hook
adapters attach the id when POHUNEK_SESSION_ID is present and shape-valid;
invalid values are dropped so the notification is still created without session
linkage. The daemon also enriches notification.create with current session
context when the referenced session still exists, but a notification may outlive
the session it references.
Session attention notifications use the source-independent dedupe key
attention:<session_id>. That lets a daemon projector agent_blocked record
and a provider-hook approval record refer to the same waiting-for-input moment
without sharing a producer-specific source id. Within the policy’s attention
dedupe window, provider hook records outrank daemon projector records for the
same session attention key, and while reports are held pending in the debounce
window a provider hook record outranks a user or external record. A provider
ranks as a hook provider exactly when a registered hook schema lists it as an
identity provider (today shell, codex, claude, and hermes).
Session notifications self-resolve. When the daemon observes a session enter
working, or the session reaches a terminal lifecycle state, the projector
acknowledges any unread or read
agent_blocked and approval_required records for that session’s
attention:<session_id> key and any turn_completed records for
turn:<session_id>. This keeps transient waiting-for-input and completed-turn
signals from lingering as unread after the agent has resumed; other kinds such
as error and session_finished are never auto-resolved and wait for explicit
owner action. An idle observation alone does not resolve attention because an
approval prompt can be technically idle while still requiring owner input.
Session notifications are also debounced before they ever become visible. An
agent_blocked, approval_required, or session-scoped turn_completed create
carrying attention:<session_id> or turn:<session_id> is held pending in
memory by the daemon for the policy’s attention_debounce_secs window (5
seconds by default) instead of being persisted immediately; notification.create
still reports created: true with a minted id, but the record does not appear
in notification.list and no notification_created event fires while it is
pending. If the session enters working or reaches a terminal lifecycle state
inside that window, the pending record is dropped entirely and nothing is ever
created — the same self-resolve edge described above, applied before the record
surfaces rather than after. Only a genuinely outstanding session signal, still unresolved once
the window elapses, is committed and broadcast. This is distinct from
attention_dedupe_window_secs, which merges duplicate attention reports across
producers rather than delaying when a session notification surfaces. When the
window elapses the daemon also records the session turn event that
session.wait_turn returns, whether or not the policy keeps an inbox record for
the kind, when the report came from the projector or from an admitted provider
hook for a session the daemon holds; a create whose kind the policy disables
still answers notification_kind_disabled. The turn event does not use the
inbox’s stale-stop suppression after resumed work: a turn_completed is
dropped instead when the session is working as its window ends.
Unread turn_completed rows are bounded per session. A newer
turn:<session_id> record acknowledges any older unread turn for that key with
superseded_by pointing at the newer record, and a visible attention record for
the same session supersedes the unread turn twin because waiting-for-owner
attention includes the fact that the turn completed.
Notification policy is provider-keyed. enabled is the complete base per-kind
policy, while the deterministically ordered providers object holds complete
overrides by open provider wire name. A missing provider key falls back to
enabled. The old fixed codex and claude policy fields are not accepted.
The notification policy also owns automatic retention. Informational/success, warning, acknowledged attention, acknowledged error, and archived records have separate TTLs. Unresolved action-required and error records have no automatic TTL. After a sweep appends deletion events, the daemon atomically compacts the JSONL action log once its configured action threshold is reached.
Every session has an immutable launch identity: agent is the selected profile
name and agent_base is the RuntimeRef of the runtime backing the session (for
example shell, codex, claude, or hermes). A shell session can temporarily host a nested Codex or Claude Code
process. The daemon
now treats active nested-agent state as an evidence tripod: process facts from
procwatch are authoritative for start/stop, hooks are the fast path for rich
claims and clean release, and PTY output remains an activity signal rather than
lifecycle authority. SessionStart hooks report the nested agent’s PID when the
provider exposes it as the hook parent, so procwatch can bind the claim exactly.
Claude SessionEnd sends an explicit release for prompt clean-exit clearing;
Codex has no installed session-end release because its Stop event is
turn-level, so procwatch remains the release backstop. Hook claims must be
backed by a live process and age out when unbound. active_agent,
active_agent_base, and active_agent_pid are runtime metadata for display,
filtering, and detector behavior; they do not change the launch agent /
agent_base.
Which hook reports a session accepts is decided by the hook schema of its
runtime: the descriptor names an integration handler and a hook schema, and
core keeps the schemas as a closed compiled set. The daemon passes the schema
id to the worker at initialization, the worker journals it, and both the
worker (when a hook reports) and the daemon (when it imports worker state)
check the provider, the action, the native-reference kind, the nested-agent
rule, the ancestry matcher and the subagent fields against it. The worker alone
applies the schema’s launch-process rule, which decides which process may set
the launch identity (the daemon holds an imported launch identity to the core
ancestry matcher only), and the schema’s subagent sequence rule (a stop must be
ordered after its start, a newer start reopens a terminal record, a record’s
phase follows the highest-sequence claim admitted for it, and the
providers the schema lists, claude in identity-subagent-v1, have a stop
admitted only after a recorded start; a schema without the rule admits no
subagent record), and the daemon refuses
imported subagents whose fields or outcome the schema does not admit. Peer
binding and expiry checks are shared by every schema. The schema the worker
journaled outranks the one the pinned runtime resolves to, and reports of a
newer asset set are refused by a session on an older schema. Codex, Claude, and the shell
use the schema with subagents, Hermes the identity-only schema, and a runtime
without an integration, such as Pi, has none and accepts no hook report. The
daemon applies the same admission on the public socket, which the managed
hooks fall back to when the worker refuses a report, so a refused report cannot
be replayed there. A worker built before schema delivery journals no schema, so
the daemon validates its state with the schema of the session’s pinned runtime.
Current Claude and Codex integrations separately observe provider-managed
subagents. Their SubagentStart and SubagentStop hooks report only lifecycle
metadata to the PTY-owning worker: provider, child id, optional parent id, and
optional agent type. The worker journals multiple concurrent children, assigns
monotonic decimal-string revisions, and retains bounded completed history across
daemon and client reconnects. A running child is marked lost when its owning
runtime terminates.
For every provider, a child’s phase is the phase implied by the
highest-sequence claim the worker has admitted for that id, and the journal
keeps that sequence with the record. A SubagentStart newer than the recorded
claim of a terminal child reopens the record: it is running again with a new
revision, its terminal time is cleared, started_at_ms keeps the time of the
first run, and the next newer SubagentStop completes it. Claude sends such a
start when a finished background agent is resumed with SendMessage or wakes
itself. A newer start for a child that is already running, or a newer stop for
a terminal child, is ignored: the phase and revision stay, but the worker
persists the claim’s sequence in the journal, so a delayed older claim cannot
reopen or complete the record. A claim not newer than the recorded sequence is
ignored and changes nothing. The recorded sequence, and the sequence a
remembered orphan stop keeps, is capped at the worker’s own CLOCK_MONOTONIC
reading when the claim arrives, as the background mark is: a claim taken from
the host’s monotonic clock is never ahead of that reading, so honest claims
keep their sequence, while a far-future claim leaves a mark no later than its
arrival and the subagent’s next genuine claim is still newer. Comparisons use
the claim’s own sequence.
A Claude SubagentStop without a recorded start creates no row and leaves the
running and recent counts and the retained history unchanged; Claude sends
such a stop for its prompt-suggestion helper after every main turn, which is
not a subagent. The worker remembers the sequence of such a stop in memory
only, in a set bounded to 64 entries that drops its oldest entry beyond that,
and drops an entry once a start for that id applies. A delayed start whose
sequence is not newer than the remembered stop is ignored, so it creates no
row. A Codex SubagentStop without a recorded start records a completed
child, because Codex reports resumed children with stops and no start
(#916).
At the end of every main-agent turn, Claude’s Stop hook input lists the
backgrounded tasks that are still running or pending (background_tasks). The
Claude state hook forwards each task’s id, type and status, its sanitized
label (from description), the program name of a shell task and the
agent_type of a subagent task, as one subagent_snapshot claim, identified
by the reporting process like a start and stamped with a sequence taken before
the hook reads its input. The command of a shell and its arguments are never
forwarded.
It sends no snapshot when the input exceeds 1 MiB or is not a JSON object, when
the Stop carries a non-empty agent_id (it is not the main agent’s), when
background_tasks is missing or not a list, when the list has more than 256
entries, or when any entry lacks a non-empty string id, type or status
(an optional field that fails its own check is dropped alone, and the claim
still goes out);
the hook never truncates the list, and a missing list never means that nothing
runs. A snapshot is evidence, not a claim on a row’s phase: it settles only
rows whose end it proves. Claude builds the listing before the hook reads its
clock, so the snapshot’s sequence is only an upper bound on when the listing
was taken; the lower bound is the same reporting process’s previous snapshot,
because Claude’s main turns run one after another and each listing is built
after the previous Stop hook started. The worker considers only the rows the
same Claude process started or reopened whose recorded sequence is older than
the snapshot’s, so a later start or stop always wins over the snapshot. Of the
reporter’s earlier snapshots the worker keeps only the newest evidence: the
newest snapshot that listed each row and the reporter’s newest snapshot. A
snapshot older than that evidence, delivered out of order, settles nothing
from it, even when a still older snapshot listed the row or preceded it:
- a running row whose id is listed stays running; the worker remembers the
listing in memory and changes neither its sequence nor its journal record,
so the agent’s own
SubagentStopstill completes it; - a running row that this snapshot omits and whose newest listing by the
reporter falls within its current run and is older than this snapshot is a
background agent that finished, and becomes
completed; - any other running row this snapshot omits becomes
completedonly when the snapshot lists no task that can host an unlisted agent (any task type exceptshell,monitorandMCP task) and the reporter’s newest snapshot is older than this one and newer than the row, which proves the agent started before this listing was built; otherwise it is deferred and stays running, because Claude does not list teammates, synchronous children of a background agent, or workflow agents by their agent id, and an agent that started while the listing was being built is missing from it; - a terminal row whose id is listed is left as it is: Claude can still list an
agent whose own
SubagentStopalready arrived, and a self-woken agent reopens its row through its ownSubagentStart, which is newer than the recorded stop.
A row the snapshot completes keeps its own recorded sequence, so a resume
SubagentStart ordered between that sequence and the snapshot’s still reopens
it with a new revision. Each main turn therefore settles the Claude rows it can
prove: a background agent that a main Stop listed and whose own stop never
arrived holds a running-child slot only until the next main-turn end after it
finished. A reconciled row looks like any other completed row; the reason is
recorded only in the worker log. Two limits remain. A row that no snapshot
listed settles at the earliest at the second main-turn end after its start,
because the reporter’s previous snapshot must be newer than the row.
A row whose reporter never ends another main turn stays running until its
own stop arrives or the runtime ends, when it becomes lost. Rows of another
reporting process, such as a second Claude in a shell session, are outside a
snapshot’s scope. The reporting process and last listing of each row, and the
last snapshot sequence of each reporting process, are kept in worker memory
only. The worker remembers the last snapshot of at most
SNAPSHOT_REPORTER_CAPACITY (16) reporting processes and forgets the least
recently reporting one first; a forgotten process’s never-listed rows stay
deferred through the reporter’s next snapshot, which only records itself, and
settle at the earliest on the one after it. Codex Stop carries no task list, so Codex
rows are never reconciled this way (#916).
Every admitted subagent claim the worker ignores or refuses is logged in the
session’s worker log with a reason code: duplicate_start,
stop_without_start, stale_sequence and not_running at INFO, and
capacity (the running-child limit) at WARN; an inadmissible claim, including
a snapshot whose task list breaks a bound, is logged as the
worker.identity.rejected warning. A snapshot logs one
worker.subagent.reconciled at INFO for each row it completes (reason
snapshot_absent), and one worker.subagent.snapshot.applied at INFO with the
number of tasks in the snapshot, the hosts flag, and the row counts
listed (running rows the snapshot listed), terminalized and deferred.
The background events are listed in the
debug-session-runtime runbook. The worker
answers the hook with success when it admitted the claim and either applied it
or ignored it for one of the INFO reasons, and an admitted, valid snapshot
whether or not it changed a row; it answers with failure when the claim was
refused, was inadmissible, or its journal write failed. In every case the hook exits quietly, because subagent
claims have no daemon fallback. Inside a Pohunek session a state hook records
each reason it fails to deliver a report (input over the bound or invalid, no
snapshot to send, worker or daemon socket missing, unreachable, timed out,
closed or refusing) as one line with ts, session, provider, action,
reason and an optional fixed detail code, and never payload content, input
ids, paths or environment values, in the owner-private pohunek-hooks.jsonl
of the pohunek log directory (path from POHUNEK_HOOK_TRACE_PATH, rotated
once into .1 at POHUNEK_HOOK_TRACE_MAX_BYTES, so at most twice that in
total), and still exits 0; the reason codes are in the
debug-session-runtime runbook.
This collection does not change the parent session’s
activity, launch identity, or recovery binding. The hook validates its action
and Pohunek handshake before reading a bounded payload directly from stdin (at
most 64 KiB, or 1 MiB for the main Stop, whose input also carries the final
assistant message and each task’s description and command); it does not create a payload
file. Subagent lifecycle rows keep lifecycle fields only: a snapshot’s task
type and status decide the reconciliation and are not stored in the rows.
Task text is kept only in the sinks that hold the background snapshot. Each of
them holds a task’s sanitized label and a shell’s program, and nothing else
of the task: the worker journal (the snapshot below), metadata.jsonl (the
info.background of the session record), the daemon event log under events/
(the session_updated and background_state payloads), and the protocol
surfaces clients read, which reach remote clients through the relay. No command
or argument of any task is kept in any of them, and no prompt, result, final
message, transcript path or raw hook payload is kept anywhere. The worker and
daemon logs record the number of items and their kinds, never a label or a
program.
The current privacy rule for background tasks is the operator decision of 2026-10-12 in #890. It exposes a task’s sanitized label and a shell’s program name to clients, including over the relay, and never its command or arguments. That rule revises the rule that only lifecycle fields are retained, which the subagent collection above still follows for its rows.
The worker keeps the latest background snapshot of the session’s main agent
(background in the worker inspect snapshot), replaced as a whole by each
applied turn-end snapshot. Each item holds id; a closed kind (subagent,
workflow, shell, monitor, mcp_task, teammate, dream,
auto_mode_scan, memory_import, cloud_session, or other with the
provider’s own type label in provider_kind); a closed status (running,
pending, or other); the agent_type of a subagent; the label; the
program of a shell; first_seen_ms, the first snapshot that listed the item,
which is not a start time because Claude reports none; and last_seen_ms. The
label is the task description with every control, separator and format
character removed, whitespace runs collapsed to one space, trimmed, and cut to
BACKGROUND_LABEL_MAX_BYTES (160) bytes on a character boundary. The program
is the basename of the first command word, after leading NAME=value
assignments, an env prefix and cd <dir> && or ; prefixes; it is kept only
when it matches [A-Za-z0-9][A-Za-z0-9._+-]* within
BACKGROUND_PROGRAM_MAX_BYTES (64) bytes, and otherwise dropped. The command
and its arguments are never stored, journaled, served or logged.
Snapshots are ordered by sequence, not by identity. A snapshot from any
admitted, process-valid report replaces the stored one when its sequence is
newer than the sequence mark, which is the highest sequence of an applied
background snapshot or of a clear (see the clearing rules below); an older one
is ignored with reason stale_sequence. The reporting process becomes the snapshot’s owner, a private
journal field (pid and start identity) that never reaches the inspect snapshot
or the logs. Every applied snapshot takes a new revision, even when its items
are unchanged, so a client sees the refreshed observed_at_ms. An item keeps
its first_seen_ms only when the same process reported it before. A snapshot
item of kind subagent changes no subagent row. The subagent reconciliation
above applies to every snapshot unchanged, whichever process reported it.
Who may report is the same trust boundary as subagent claims: any process in the managed PTY tree whose ancestry the schema admits, such as the agent or a Claude it started. The active identity is not consulted, so the background does not depend on which agent last reported an identity.
Nested Claude sessions have one trade-off. While two Claude processes are live in one session, the latest turn end wins, so the outer agent’s background can be replaced by the nested one’s. When the nested owner releases, its snapshot is cleared and the background is empty until the outer agent’s next turn end restores it. A background can therefore stay empty for as long as the outer agent runs no turn, which is the price of not scoping by identity.
A snapshot listing more than BACKGROUND_MAX_ITEMS (64) tasks does not replace
the background; it is ignored with reason too_many_items, while the
reconciliation still applies it under its own 256-task bound. A hook input over
1 MiB sends no snapshot at all, so the previous background stays, and its
observed_at_ms shows its age.
The worker checks each optional field again. A label or program that fails its
check, an optional field of the wrong JSON type, and an agent_type on a
non-subagent or a program on a non-shell (a kind mismatch) are dropped alone,
counted by reason, and never logged with their values; the rest of the snapshot
applies. The limits are configuration in pohunek-worker-protocol. The worker
injects the two byte limits the hook needs into the agent environment as
POHUNEK_BACKGROUND_LABEL_MAX_BYTES and POHUNEK_BACKGROUND_PROGRAM_MAX_BYTES,
and the hook forwards no label and no program when either is missing or
invalid.
A snapshot belongs to the agent process that reported it, so the background is cleared in these cases:
- the runtime ends (
runtime_end); - the owner releases its session (
release), unless the hook forwards the release reasonclear: a/clearends the session and starts it again in the same process, so the snapshot stays. The release reasons the hook forwards areclear,resume,logout,prompt_input_exitandother, and every one of them exceptclear, as well as an absent reason clears. The release is matched to the owner whether or not it also ends the active identity, so a later release of the owner still clears after aclearrelease whose session start never arrived. A release from another process changes nothing; - an admitted, process-valid identity report comes from a process other than
the owner (
agent_changed), whatever its session source: when the report is accepted as the active identity, whatever its sequence, because sequences of different processes are never compared, and then the stored sequence is raised to the worker’s ownCLOCK_MONOTONICreading, so an owner snapshot taken before the report but delivered after it stays stale; when the identity rules refuse it, only if the stored sequence is older than the report’s; - an admitted, process-valid identity report from the owner with source
startuporresume, ordered after the stored sequence (session_start); - the owner process is not running (
owner_exited): it has exited and been reaped, it is a zombie that was not reaped, or its pid now names a process with another start identity. The worker checks this with the platform’sis_running, which rejects zombies and mismatched start identities. The inspect path runs the check on every inspect, which the daemon polls every 100 ms, and the clear is persisted before the inspect answers. A process that cannot be read keeps the snapshot, since the owner may still be alive. A killed agent sends no release, so this check is what ends its snapshot.
A compact session of the owner, or a clear session after a clear release
of the owner, keeps the background.
A release clear and a session-start clear raise the sequence mark to the sequence of the release or report that caused them. A claim ordered before that point is stale, even when it arrives late and was begun before the clear. This is what keeps a hook that began before a release from restoring the snapshot the release ended. An owner-exit clear does not raise the mark: a late claim from the dead owner is already refused, because its process must still be alive with its start identity, and raising the mark would refuse a new owner’s Stop whose hook read its clock just before that inspect. A runtime end needs no mark, because the runtime’s claims end with it.
Claim sequences are not checked against the worker’s clock: every claim kind is
admitted and ordered by its own sequence. The background mark is the one
exception that uses the clock. Every write of the mark is clamped to the
worker’s own CLOCK_MONOTONIC reading at receipt, so the mark never exceeds
the clock. A runtime package may keep a clock of its own whose readings run
ahead of the worker’s. Without the clamp, one such reading would make every
later genuine snapshot stale until the runtime ends. With it, a far-future
snapshot still applies, and the next normal snapshot, ordered after it, replaces
it. The comparisons keep the unclamped sequence. A genuine snapshot whose hook
started before a forged claim arrived can be refused as stale; the next turn end
replaces the forged snapshot.
Bounds the daemon re-checks for stored items are public constants in
pohunek-worker-protocol: BACKGROUND_TASK_ID_MAX_BYTES (256),
BACKGROUND_TASK_TYPE_MAX_BYTES (64), BACKGROUND_TASK_STATUS_MAX_BYTES (32),
and BACKGROUND_AGENT_TYPE_MAX_BYTES (128). The worker refuses a snapshot whose
task id, type or status exceeds its bound, and drops an agent type that exceeds
its bound from that item alone.
Clients read the snapshot as SessionInfo.background: the decimal-string
revision, observed_at_ms and the items, with id, kind, status,
agent_type, label, program, first_seen_ms and last_seen_ms. The daemon
imports it from the worker’s inspect poll, not from a worker event, and only
under a hook schema that declares a subagent sequence rule, the gate the
subagent rows use. A snapshot whose items exceed the worker’s bounds is
rejected. When the imported snapshot differs from the held one by value (the
revision included), the daemon stores it with the session record, publishes
session_updated, and then one background_state event. The event carries the
complete new snapshot, or background: null when it was cleared, and the
runtime (worker instance id and generation) that holds it. Clients accept an
event only when that runtime matches the current session snapshot, the rule
subagent_state follows; a session.list or session.inspect snapshot seeds
the state after a reconnect. A subagent item shares its id with its
subagents row, and the snapshot never changes that row.
The daemon clears the snapshot wherever it ends or loses a runtime: an exit or a
stop, a worker that is lost, reconnecting, in conflict, or incompatible. Each of
those paths publishes background_state with null after its session event,
naming the runtime that held the snapshot, when it held one. Startup reconciliation runs before
the API is exposed, so it clears without an event; a live worker found there
re-seeds the snapshot from its own inspect, and a dead or unreachable one leaves
none. A new runtime, including a recovered or restarted session, starts with no
snapshot until its own worker reports one. A worker that becomes reachable again
after an unreachable spell has its snapshot applied again, so the snapshot it
still holds is shown again.
Protocol 3 connections never receive background_state, and their session
objects carry no background key. The field and the event are current-version
additions; see the protocol window in docs/public-api.md.
Human-readable pohunek session list shows the running/recent child count in
SUBAGENTS and the background snapshot’s per-kind counts in BACKGROUND (for
example 2a 2s 1m: agents, shells, monitors; w workflows, t teammates, o
every other kind). pohunek session inspect includes the corresponding
lifecycle rows, each subagent with the label of the snapshot item of the same
id and its age, then every other background item with its kind, status,
program, label and the age since pohunek first saw it (>=, because Claude
reports no start time). --json carries the full background model. Streaming
clients accept subagent_state only when its runtime id and generation match
the current session snapshot; a full list or inspect snapshot seeds state after
reconnect or native recovery.
Foreground reconciliation selects the recognized process-group leader first; when the leader is an unidentified wrapper, it selects a recognized member of the same foreground PGID. It never selects a nested agent by kind from another process group. Replacing an active agent clears stale native identity metadata and switches the detector configuration with the new agent. An active agent that a managed hook reports through the session’s worker switches it the same way once the daemon projects the worker’s journal, and its release restores the session’s default configuration.
The durable session record keeps the active agent the worker journaled, with whether it was reported or observed, so a daemon restart restores it even after the worker’s claim expired. An agent reported only to the daemon socket (the hooks’ fallback when the worker cannot be reached) or observed by procwatch is held in memory and reaches the record only with the session’s next record write for another reason (a native reference report, a stop, a worker becoming unavailable or being adopted again). Whether such an agent restores as reported after a restart therefore depends on that timing; without such a write, procwatch observes it again, as observed. A worker snapshot projected after such a report replaces it with the worker’s journaled identity, also when the snapshot was read before the report arrived (#1101). A record written before schema 10 carries no origin, and its agent counts as observed when its name equals the agent’s own runtime: a reported agent under a profile named like its runtime, nested or not, is then restored as observed until it reports again.
A known shell foreground group suppresses descendant fallback after clearing a process-bound nested claim, which prevents claim/clear flapping while the agent remains in the process tree. A recent unbound hook claim remains valid until its claim TTL because the foreground group alone does not identify that process. Direct-launch agent sessions preserve a matching PTY-root claim. Transient foreground probe failures retain the last-known PGID and claim. PID reuse is distinguished by kernel process-start identity, including delayed exit notifications from the replaced process.
The same runtime model keeps cwd current. A session starts with its launch
directory, then procwatch reads the cwd of the focus process on each tick: the
active nested-agent PID when one is bound, otherwise the root PTY child. OSC 7
terminal output is accepted as an immediate cwd hint, but procwatch remains
authoritative and overwrites a hint that a later read of the process cwd
contradicts. Cwd evidence is ordered by when it was observed: a procwatch read
taken before a hint arrived never replaces that hint, and a hint for the
current cwd keeps the source that set it. Each cwd
change emits session_updated and re-resolves project and worktree context. If
the new cwd is inside another registered active worktree, worktree_path,
branch, and project metadata move to that worktree; if it is outside every
known worktree, worktree_path is cleared while git repo/branch metadata is
kept when detection still finds a repository.
Every managed SessionInfo has a runtime object distinct from its agent
state and activity. Runtime state is one of starting, live,
reconnecting, terminal, lost, conflict, or incompatible. worker_id
identifies the PTY owner and worker_instance_id identifies one PTY generation. A
daemon restart preserves both ids. Explicit native recovery preserves the
logical session id but changes the worker and runtime ids.
SessionInfo.capabilities.resume and .fork are frozen flags derived from the
session’s native-session launch spec; fork is only offered together with
resume. Clients must use them instead of guessing from the provider name. Long-lived
wire counters (runtime_generation, output offsets, terminal watermarks, hook
sequences, and subagent revisions) are canonical unsigned decimal strings so JavaScript clients do
not lose precision.
A stop whose PTY output cannot drain still ends the session. When a process
outside the session’s process group (for example a setsid helper) keeps the
terminal open past the stop deadline, the worker force-closes the output,
reaps the root, and records the root’s exit. The session reaches stopped
with that exit and its session info has output_force_closed set to true,
because output the helper wrote after the close is not retained. Before the
session ends, the worker terminates every process still carrying the runtime’s
POHUNEK_WORKER_INSTANCE_ID marker and waits until none is left (SIGTERM,
then SIGKILL after the stop grace), so a helper cannot keep writing once the
session is resumable or its worktree is released. A same-user process whose
environment cannot be read (a non-dumpable or setgid program, or one caught
between images) and that started after the worker could belong to the runtime:
the worker re-reads the process table for up to the stop grace, and if such a
process is still there it is never signalled and the cleanup stays unconfirmed.
Linux has no fork-lineage proof, so a long-lived foreign process of that kind
also keeps the cleanup open. A process that scrubs its environment carries no
marker and cannot be found. If the root’s exit cannot be observed, nothing is
invented and the stop fails; if the runtime’s processes cannot be proven gone,
the runtime is faulted instead of ended, the stop fails with
worker_operation_failed, and the daemon handles it as a lost runtime
(runtime_lost_cleanup_unconfirmed when its own sweep cannot confirm either).
The worker log names each process that kept the cleanup open.
The same applies when the root exits on its own and such a helper keeps the
terminal open past the drain window: the worker force-closes the output, sweeps
the runtime’s marked processes, and the session ends done or failed with
the root’s exit and output_force_closed set, or its runtime becomes lost
when the cleanup cannot be confirmed. Either way, a helper that carries the
runtime’s marker does not outlive the runtime.
lost means the worker or host runtime is gone and the PTY cannot be
reattached. conflict means discovery found ambiguous or mismatched live
identity; Pohunek quarantines it and does not kill a worker automatically. A
conflict whose record names a worker generation is re-checked in the
background (after 1 s, doubling to at most 60 s) while a worker still answers
or its job is supervised: a pass never kills, the session stays conflict
while the evidence holds, a worker that becomes adoptable is adopted live,
and once the worker is gone and its job ended the session becomes lost with
runtime_lost (an orderly exit with a terminal journal is imported as
terminal). Resume-binding conflicts, journal-only conflicts, and records
naming no worker generation are not re-checked. session stop <id> on a
conflict stops the supervised job by the identity the record names, but only
after proving that the recorded generation’s journal names the recorded worker
and that the service manager’s job under that generation is that worker’s;
otherwise it refuses (session_runtime_conflict, runtime_identity_mismatch,
runtime_supervision_ambiguous, or runtime_supervision_unavailable) and
leaves the record untouched. A stop never accepts unconfirmed cleanup, and
session stop of a lost, reconnecting, or incompatible session is
refused with session_runtime_lost, session_runtime_reconnecting, or
worker_protocol_incompatible. Each classification as conflict, lost,
reconnecting, or incompatible logs one WARN, session runtime is {runtime.state}: {reason}, to pohunekd.jsonl.
incompatible means the worker is alive but the daemon does not manage it, so
it leaves the worker running: worker_protocol_incompatible when the worker
has no compatible private protocol version, worker_marker_legacy when its
journal names a worker release before 0.33.1 (or none), whose children carry
no POHUNEK_WORKER_INSTANCE_ID marker any sweep could attribute. Attach, input, and resize are not
available in these degraded states, but list and inspect retain the logical
record and diagnostic loss_reason. After preserving diagnostic evidence, the
operator can remove a degraded logical record with session rm. A lost runtime
has ended, so removal leaves its worker job alone; a terminal worker only
retains its final output, so removal retires its job. A reconnecting or
incompatible worker, or a conflict whose reason is
runtime_supervision_ambiguous, cannot be reached yet may still own a live PTY,
so removal first retires that worker’s job through the service manager by the
exact generation the record names, which stops the worker and its child, then
requires every worker the session’s journals record for that generation to be
gone, and only then deletes the record. For another conflict reason, removal
first applies the same journal, job, and process identity proof as session stop.
A proven generation is stopped, then its logical record is removed. Removal is
refused, and the record kept so it can be retried, when the record names no
worker generation (session_runtime_conflict), a conflicted worker cannot be
proven to be the recorded generation (runtime_identity_mismatch or
runtime_supervision_ambiguous), the service manager cannot complete retirement
(runtime_supervision_unavailable), a worker journaled under another generation
still runs (runtime_identity_mismatch), or the session’s journals cannot be
read or a journaled worker still runs or cannot be inspected after retirement
(runtime_supervision_ambiguous). Every removal, whatever the runtime state,
then sweeps processes carrying a journal-proven runtime marker. A recorded
runtime absent from its worker journal is swept only when its process also
carries the removed session’s ID. A process with that runtime marker but
another session ID is left alone; one with no session ID is not signalled and
keeps cleanup unconfirmed. A process whose environment the kernel withholds
(on macOS 27, Apple platform binaries such as /bin/sh and /bin/zsh) is
decided by fork lineage when the sweep knows the lost worker’s creation number,
which the worker journals as the optional worker_spawn_id. The kernel records
the creation number of each process’s creator; it keeps naming that creator after
the creator exits, until the process re-executes after being reparented, when it
is rewritten to launchd’s number. A creator number equal to launchd’s is
therefore ambiguous and never excludes a process. A descendant of the worker is
reaped like a marked process. A process created before the worker, or whose
chain reaches a non-launchd creator older than the worker, is ignored and not
reported. Every other hidden process stays an unreadable candidate and keeps
cleanup unconfirmed: an orphan that re-executed after its creator died, and one
whose creator exited after the worker started. A process created after the worker
by launchd is decided by coalition instead: the kernel puts every process
created from the worker, through any number of forks, execs, setsid calls and
reparentings, in the worker’s resource and jetsam coalitions, and an
unprivileged process cannot move into another job’s coalition. The worker
journals its own pair as the optional worker_coalitions (<resource>:<jetsam>
decimal, no schema bump, used only in the boot it was recorded in). A hidden
process whose lineage is open and whose both coalitions differ from the
worker’s is ignored and not reported; equal or unreadable coalitions prove
nothing and keep it a candidate, and a lineage descendant whose coalitions
differ is skipped, never selected. Those processes are not reported at once: while only such
unreadable processes remain, the sweep re-reads the process table for up to the
sweep grace (sweep.grace_ms), so one that is caught between images or about to
exit does not leave the cleanup unconfirmed; a candidate that outlives the grace
is listed. That wait is shared by every runtime one operation sweeps: the startup
reconciliation (which runs before the daemon reports readiness), one background
re-check of a session, and one removal or stop cleanup each wait at most one
sweep grace in total, however many runtimes a persistent candidate blocks; once
it has passed, a further runtime left with only such candidates is decided
without waiting and stays unconfirmed. The daemon uses the journaled number only when the journal was
written in the boot the host reports now, because creation numbers restart at
reboot, and several journals of one runtime bound it only when they agree. Every
journal-backed sweep passes it; the sweep of a runtime absent from its journal
passes none. A journal without it (a worker started by the previous release)
gives no lineage proof. Under the session-ID requirement a lineage-proven
process is not signalled either, since its session marker cannot be read. Linux
has no lineage proof and keeps its start-time ordering. A descendant that left the worker’s process group
(macOS kills only the group) can outlive the stop and job retirement. A sweep
that cannot confirm every marked process exited fails
the removal with runtime_supervision_ambiguous and keeps the session listed
with its removal intent; session rm again, or the next daemon start,
finishes it once the leftover process is gone. When the only obstacle is
same-user processes whose environment cannot be read (so they cannot be proven
foreign to the runtime), the refusal message lists them as pid N (start S, command name), at most eight and then and N more, and its recover hint
says to inspect and end the ones that belong to the session before retrying.
A refusal for any other reason lists no processes.
session rm <id> --accept-unconfirmed-cleanup (the session.remove_accepting_unconfirmed
method; plain session rm uses session.remove, which always refuses) is the operator’s way out when
those unreadable processes are the only obstacle. After a lost runtime on macOS
27 the runtime’s own platform-binary processes are reaped without it, but it may
still be needed for a re-executed orphan or a process whose creator exited after
the worker started, which the refusal lists by pid and command name for you to
judge, and for every hidden process when the worker journaled neither a creation
number nor coalitions (worker_coalitions). The flag is per call: nothing
stores it, a retried removal needs it again, and neither the reconciliation that
finishes an interrupted removal nor the retention sweep ever has it. With it the
removal proceeds past the unreadable candidates without signalling them,
logs each at warn once the sweep lets it proceed (before any cleanup), and lists
every one in the result as accepted_unconfirmed_processes (human output prints pid N (start S, command name) lines after the removed= line, with the process-chosen command name
escaped; --json carries the array). A removal with more than 64 candidates is
refused before anything is deleted, so the result always fits one response. It changes
nothing else: a signalled process that is still running, a sweep error, or a
missing supervision configuration still refuses. The trade-off is that an
accepted process that does carry the runtime marker keeps running unsupervised
after the worktree, logs, and record are deleted, so inspect the listed
processes first. Web removal only calls session.remove and so never consent. Against
a daemon without the method the CLI reports method_not_found with an upgrade
hint.
Reconciliation joins the service manager’s jobs with worker sockets and
journals for each worker generation. It reports runtime_lost when a worker’s
job ended while its journal still said live, after sweeping that generation’s
leftover processes (runtime_lost_cleanup_unconfirmed when that cleanup could
not be confirmed). A stale socket that refuses connections supplies no worker
identity: an absent job and a confirmed marker sweep yield lost, while a
present but silent job remains conflict. It reports
runtime_supervision_ambiguous (conflict) for
a present job whose worker does not answer, runtime_identity_mismatch
(conflict) for a job whose definition or process does not match the record,
and runtime_supervision_unavailable (reconnecting) while the service manager
cannot be inspected. The last three kill nothing. A session is reported lost
only after the service manager retired its ended job; while that retirement
fails it stays reconnecting with runtime_supervision_unavailable and is
retried. A removal interrupted by a daemon restart is finished by
reconciliation through the same steps as session rm (retire the recorded
generation and prove its workers gone, sweep its runtimes’ marked processes,
then delete worktrees, logs, the resume binding, and the record); until those
succeed the session stays listed with runtime_supervision_unavailable or
runtime_supervision_ambiguous and is retried. A stop interrupted the same way
is replayed through its answering worker and committed stopped only once the
worker returns the terminal outcome; until then it stays reconnecting with
runtime_supervision_unavailable and the retry replays it. A create
interrupted before it committed is compensated from its durable create record
once its worker is proven ended (never launched, or its runtime ended): the
generation is retired first, then the worktree and its binding are removed,
then the record is deleted, and a step that fails keeps the rest for a retry. A
committed create whose --input prompt was not yet delivered is removed like
session rm, since the prompt lived only in the stopped daemon’s memory.
A compensation that cannot finish while the daemon runs (a checkout that
cannot be removed, or a binding or record the store cannot drop) keeps the
session listed as reconnecting with create_compensation_pending, and the
supervision retry repeats it until the session is removed (session_removed).
A job of a
generation no record names is retired only once its journaled worker is proven
gone as well, even when the job itself already ended; while that worker runs or
the session’s journals cannot be read, the job stays orphaned in the runtime
inventory (stale_worker_generation).
Attach terminal behavior
Section titled “Attach terminal behavior”pohunek attach uses raw terminal passthrough, preserving the terminal’s native
scrollback. Ctrl-\ temporarily freezes the visible agent screen and opens a
session menu dialog whose header shows the session name, host, project,
branch (when the session has one), and live agent state. The menu owns kill
confirmation (k then y), terminate and delete (t then y, which stops the
session, removes it from the registry, and deletes its pohunek-owned worktree
checkout including uncommitted changes while keeping the branch), detach (d),
new session in the same worktree (n), fork (f), and rename (r). Agent output received while the menu is
open is buffered; closing the menu restores the frozen screen, replays that raw
output, and resumes passthrough without losing terminal modes or scroll margins.
Whenever an attach attempt ends (detach, session stop, typed failure, unexpected EOF, or reconnect) the CLI restores normal terminal output modes after replaying any buffered menu output. This disables mouse and focus reporting, bracketed paste, alternate-screen state, and TUI cursor/scroll modes before returning control to the parent shell.
Attach retries automatically after an unexpected daemon stream close. The
settings live in <config_dir>/attach.conf (key=value lines, # comments;
pohunek setup config installs a template with every key commented at its
default):
attach_reconnect_secondsis the retry window (default 20;0disables retry).attach_reconnect_interval_secondsis the minimum delay between attempts (default 0.5).attach_reconnect_max_attemptscaps consecutive attempts within the window (default 3), including failures where inspect still reports a running session.
The replacement daemon reconciles with the existing per-session worker, so
Codex, Claude, Hermes, and plain shell sessions retain the same PTY, child PID,
and runtime id. A typed worker-stream failure is surfaced once and is not
retried. A lost worker cannot be reconstructed by retrying attach; inspect
runtime.state and use explicit native recovery only when supported.
Temporary directory
Section titled “Temporary directory”Each session’s agent runs with TMPDIR set to the session’s own directory,
<cache_dir>/session-tmp/<store key>/<session id> below Pohunek’s cache
directory ($XDG_CACHE_HOME/pohunek or the platform cache path), so agents
running side by side never overwrite each other’s temporary files. The store
key comes from the metadata store’s path, so two daemons with different stores
never share a directory. The directory is private
(mode 0700) and replaces a TMPDIR forwarded from the daemon’s environment. A
profile that sets TMPDIR in its [env] keeps that value instead. Stopping a
session keeps the directory and resuming it reuses it; removing the session,
explicitly or through retention, deletes it with its content. When the daemon
starts, it deletes the directories of its own store that nothing it knows at
startup names, which a crash or a failed delete can leave behind. A directory
is kept when a session record, a resume binding, a store line the daemon
rejected that names the session, a worker or its claimed session, a
service-manager job or a worker journal names it. When it cannot tell, because
worker discovery, the job listing or the journal scan failed, or a rejected
store line names no session, it deletes nothing. A launch whose directory is not a private directory of the daemon’s user fails
with session_tmpdir_unsafe before the agent starts.
Retention
Section titled “Retention”A host that runs agents for weeks accumulates logical records for sessions that can never be attached again, and those records keep owning their worktrees. The daemon therefore runs a retention sweep that ages unavailable sessions out.
The policy lives at <data_dir>/session-policy.json and is read with
pohunek session policy get (add --json for the machine-readable shape) and
changed with pohunek session policy set, which accepts --enabled /
--disabled, --sweep-interval-secs, --terminal-ttl-secs,
--lost-ttl-secs and --max-removals-per-sweep. Unspecified fields keep their
current value, and the running sweep task picks the new policy up on its next
cycle, so no daemon restart is needed.
The shipped default is deliberately conservative: sweeps are disabled, and
once enabled they run every 6 hours, keep a terminal (stopped/done/failed)
session for 30 days, keep a session whose runtime is lost for 90 days, and
remove at most 25 sessions per sweep. The lost grace period is the longer one
because such a session may still be recoverable with session resume.
pohunek session retention sweep --dry-run reports exactly what the current
policy selects without touching anything; --apply removes the selection, and
--limit lowers the cap for that one sweep. A manual sweep works whether or not
automatic sweeps are enabled and never exceeds the policy’s own cap.
A sweep removes through the same path as session rm, so it stops a runtime
that is still live, cleans pohunek-owned worktrees, and deletes the session’s
logs. It never selects an external session or one in conflict or
incompatible runtime state, and it never selects a session that is still live
or inside its TTL. Anything the sweep cannot classify is kept: an ambiguous
record is never a removal candidate.
Because the sweep is unattended and worktree removal is forced, a session whose
age matched the policy is still held when its pohunek-owned worktree holds
work: an uncommitted change to a tracked file, an untracked file, commits
contained in no other branch, remote branch or tag, or a checkout whose state git
cannot report at all. A held session is reported with a hold reason
(worktree_uncommitted, worktree_untracked, worktree_unpushed,
worktree_unknown), counted in held rather than eligible, and logged — its
worktree stays on disk. Ignored files are not a hold, since they are ignored on
purpose and regenerated. One sweep inspects at most 64 checkouts; a matched
session past that bound is held as worktree_unknown and inspected by the next
sweep, because an uninspected checkout is an unproven one. session rm is unaffected: an explicit operator removal
still removes a dirty worktree, which is how a held session is cleaned up once
the operator has looked at it.
The sweep’s counters are exact. worktrees_cleaned counts only checkouts
confirmed gone from disk; when git worktree remove fails the session record is
still evicted, and the leftover directory is reported as worktrees_failed and
logged instead of counted as cleaned. pohunek session retention sweep exits
non-zero when the sweep reports a failed removal or a leftover worktree, so a
cron job or health check sees a partial failure without parsing the output.
External observer mode is opt-in with POHUNEK_OBSERVE_EXTERNAL_AGENTS=1 (or
SessionRegistryConfig.observe_external_agents = true) and defaults off because
it watches provider transcript trees under the operator’s Claude/Codex homes.
When enabled, the daemon combines same-user process facts with transcript JSONL
candidates to show agents that were started outside pohunek. These entries use
synthetic ids such as ext-12345, carry external: true, and appear in
session.list, session.inspect, and other clients as read-only sessions. They have
no pohunek-owned PTY: attach, input, resize, stop, remove, rename, metadata, and
resume operations are rejected with session_external_read_only. The observer
removes the entry when the external process exits, including kill -9 via the
event-driven native exit watch: a pidfd on Linux and a kqueue NOTE_EXIT
registration on macOS. Both re-verify the exact process identity after arming
the watch, so a reused process id never completes a watch for the process it
replaced, and a registration failure is reported as a failure rather than as an
exit.
The observer also indexes provider transcripts so a newly started external agent
is enriched without waiting for the next process sweep. It watches only the
transcript trees runtime descriptors declare in [transcripts] (Claude
projects, Codex sessions) below the config homes the host launches those
agents with (each runtime’s own home and the home of every host profile,
re-read on every pass), never their parents or $HOME, and reads each with the
parser the descriptor names. A runtime without the declaration contributes no
root, and a runtime that is not installed contributes nothing: a host without
the official Codex or Claude Code package observes no transcript tree of that
agent and does not recognize a codex or claude process outside pohunek or
in the foreground of a shell session; a hermes process is likewise
recognized only through the Hermes package’s detection manifest. 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.
The transcript index is converged by a bounded reconciliation pass that runs
every 30 seconds, on a lost-event or directory-change hint, and after a
watcher restart, whether or not the live watcher (inotify on Linux, FSEvents on
macOS) is healthy. Each pass re-resolves the configured roots (symlinked or
aliased roots are scanned once, a nested root of the same runtime owns its own
transcripts), registers
them with the watcher, and scans them, dropping indexed transcripts it no longer
sees. A pass that could not finish drops nothing. The watcher only reduces
latency: a file notification is a hint that schedules one debounced, bounded
parse of that transcript, its failure is only logged because the next pass
repairs it, and process-backed identity checks are unchanged. A stream that
silently stopped delivering therefore delays enrichment by at most one pass.
Every pass is bounded to 100000 directory entries, 8192 directories and 20000
transcript parses per root, and unchanged transcripts (same size and modification
time) are not parsed again. A provider tree beyond these bounds is visibly
degraded with scan_incomplete, never silently partial. The bounds apply to each
root, so a host with several account homes spends them once per distinct home.
A transcript below a root that left the set (a removed profile) leaves the index
on the next pass. Watcher health is logged
with stable machine-readable causes: external_transcript_watcher_unavailable
(inotify_open_failed, fsevents_open_failed, unsupported_target,
watcher_backend_failed) when no live watcher runs, and
external_transcript_watcher_degraded (roots_missing, registration_failed,
scan_incomplete) when the watcher runs but a root is missing, could not be
registered (denied directory, exhausted OS watch limit), or could not be scanned
completely (unreadable directory or a tree beyond the bounds). The registration
diagnostic names the transcript root, never the transcript contents. A failed
watcher is restarted with backoff, and the reconciliation passes and the process
sweep keep running in every state.
Workers live for the operating-system login session. Closing a terminal,
detaching, or locking the screen does not stop a session. Logging out or
rebooting ends every worker; after the next login the daemon reports those
sessions lost (runtime_lost) and never restarts or resurrects them.
Explicit session.resume remains the recovery path.
Detach and client restarts do not stop a session because its worker owns the
PTY. A daemon restart, daemon SIGKILL, or daemon binary upgrade closes client
and controller sockets, but the worker keeps the same PTY and process group,
continues draining bounded output, and accepts the replacement daemon after
reconciliation. pohunek attach reconnects to the same runtime id. Reconnection
emits session_runtime_reconnected; it does not emit session_created, report
child exit, or invoke native resume. Detector reconnection can replay retained
raw output from its last processed offset. A fresh interactive attach instead
applies the client’s initial dimensions when known and starts from one complete
current terminal repaint, followed atomically by live output. It never rebuilds
the screen from raw bytes emitted at historical terminal sizes.
Workers negotiated below private protocol v3 cannot guarantee that ordering;
the daemon returns attach_snapshot_unsupported, and the session must be
restarted on an upgraded worker or forked into a new session.
Interactive attach input is ordered by a stream-scoped sequence and does not
consume the worker’s bounded control-input deduplication capacity. A typed
worker stream failure is retained by the daemon for the attaching CLI, which
surfaces it instead of repeatedly treating it as an ordinary reconnect.
Native recovery metadata is accepted only from the immutable launch agent
process, so a nested different or same-provider agent cannot overwrite the
parent session’s recovery reference. Managed children inherit the stable
POHUNEK_SESSION_ID, POHUNEK_WORKER_ID,
POHUNEK_WORKER_SOCKET_PATH, and worker hook protocol version. They also
receive POHUNEK_DAEMON_ID from the daemon instance that initialized their
worker. A daemon restart leaves this launch-time value in the live PTY;
pohunek attach rejects a session attaching to itself by matching its session
and stable worker IDs, including after the restart. The daemon also denies a
managed child request to mutate its own session, recognizing the worker’s
launch-time daemon ID after a restart. Identity hooks
prefer the worker endpoint so accepted state survives daemon outage. The worker
binds every private report to the process that sends it: the connecting peer,
taken from the kernel and never from the request, must be the reported process
itself or a descendant of it. The shipped hooks already have that shape, and a
same-session sibling reporting another process is rejected. Nested
active-agent reports remain runtime evidence only: they can expose the active
agent and active native metadata while that process runs, but never populate or
replace native_session_id / native_session_path for the parent session.
Notification hooks also prefer the worker endpoint: the worker applies the same
peer binding, replaces any session id in the hook’s parameters with its own, and
forwards one public notification.create to the stable daemon socket. A worker
that refuses or does not know the request leaves the hook to dial the daemon
socket itself, so an older worker keeps working.
Subagent lifecycle hooks use the same owner-private endpoint without a public
daemon fallback, so their durable worker state continues to advance while the
daemon or a client is disconnected.
Startup reconciliation merges the worker’s immutable launch identity into the
persisted session and recovery binding; it does not replace an already captured
native reference with an empty worker field.
If the owner-private worker identity claim cannot be delivered, the shipped
hook falls back to the local public daemon with the exact runtime id, PID and
kernel start identity, a monotonic sequence, and a short expiry. That public
path admits only the session’s PTY root itself, attested by the kernel peer of
its local connection, and refuses every report for an identity-v1 session
(native_report_unattested); a hook, a descendant of the agent, is therefore
refused there. Stale runtime,
PID reuse, wrong provider/session, expiry, and duplicate or reordered reports
are rejected. The public path is fallback; the private worker claim remains
preferred because it survives daemon outage.
Procwatch can auto-report a matching nested agent when hooks are missing, and
auto-release clears stale active fields when the backing process exits or an
unbound claim exceeds the active-agent claim TTL.
A terminal or runtime.state=lost session that still carries captured native
recovery metadata can be explicitly recovered with session.resume. The daemon
reuses the logical pohunek session id and frozen launch profile but creates a
new worker, runtime id, PTY, and child PID. Clients receive
session_native_recovered (including the previous and new runtime IDs when
known) and must show that generation change rather than
present it as reconnection. Recovery is rejected for live, reconnecting,
conflicting, or incompatible runtimes and is never automatic. A removed session
is gone and cannot be recovered.
Hermes, served only by its official package, uses the same durable worker
model, but only for the local interactive terminal backend in the releases its
package supports ([0.20.0, 1.0.0), verified on 0.20.0). Pohunek launches it as
hermes chat; when a valid native reference is already present, recovery is
exactly hermes chat --resume <reference>. It never continues ambient Hermes
state and never reads state.db. Before either launch, and before material
side effects, the daemon fails with agent_binary_missing when the hermes
program does not resolve to an executable, then requires an isolated, bounded
version probe to confirm a release in the package’s supported range and fails with
agent_runtime_unsupported when the runtime is incompatible; the refusal names
the parsed release and the supported range, never the probe output. The Hermes operator plugin reports a native
reference through its bounded lifecycle hooks for a managed Hermes session; it
does not infer or read one from Hermes state. Its resume capability is
independent from a temporarily unavailable report; fork is always unsupported
and rejects before any child/worktree side effect. See
Hermes operator for the typed tool and hook
boundaries.
Where the native reference comes from is a per-runtime strategy declared in the
runtime definition’s [native_reference] table: hook (a validated integration
report, the behavior of the official Codex, Claude Code and Hermes
packages), assigned (core
generates the reference and passes it at launch) or none (no native recovery,
the shell runtime). A runtime package without an integration handler is
resumable only with assigned, which needs an agent CLI that accepts a
caller-chosen session id; with hook or none it gets launch and detection
only, because session.report_native_id accepts a report only from the launch
process itself. An assigned reference is recorded with provenance assigned,
is never identity evidence for ancestry, sequence or pid validation, and is
persisted before the agent starts, so the session is resumable at once. It goes
stale when the user switches conversation inside the agent (/clear, in-session
resume) or when the agent never wrote the conversation, so session.resume and
session.fork first run the existence check the runtime declared (a bounded,
shell-free, symlink-free file listing below a declared config home) and fail
closed with agent_native_reference_missing instead of launching an agent into
an empty conversation; a runtime that declares check = "none" is relaunched
unchecked. Recovery never falls back to another runtime, the shell or
“continue latest”. A fork holds no reference of its own until its launch
process reports one, regardless of the source reference’s provenance; it
cannot resume its parent’s conversation. A runtime whose assigned strategy
declares assign_fork = true (the official Pi package) instead launches each
fork under a reference core generates for it (--fork <source> --session-id <fork> for Pi), so the fork holds its own assigned reference from creation
and resumes into its own conversation after a stop or a daemon restart. A later validated report always supersedes an
assigned reference: an assigned runtime that declares an integration handler and
hook schema may report, and the report replaces the stored value and its
provenance (reported) in the session, its durable record and its resume
binding, so /clear and in-session resume are followed and session.resume and
session.fork launch with the reported conversation. The declared existence
check runs for a still-assigned reference. A hook runtime may declare the same
kind of check under [native_reference.existence] for its id-kind reference;
the official claude package declares one, so its recovery additionally requires a
regular transcript file under its effective declared config home; either
recovery action refuses with agent_native_reference_missing before
launch when the file is absent or cannot be verified. A check may also declare
how the agent moves its store: a variable that names the store directly (the
check follows it; Pi’s PI_CODING_AGENT_SESSION_DIR), and launch arguments or
settings-file keys it cannot follow (Pi’s --session-dir and sessionDir).
session.new, session.resume and session.fork refuse a launch that moves
the store out of reach with agent_native_store_relocated before anything is
spawned; its hint names the variable to set instead. A tested worker journal
that carries a newer conversation switch the daemon cannot verify makes both
recovery actions refuse with native_identity_uncertain instead of resuming
the older verified target. A report must pass the usual checks (the
process-identity, sequence and expiry checks for session.report_native_id, the
launch process and sequence for the worker’s active identity), a stale or
refused one leaves the stored reference alone, and an assigned value never
replaces a reported one, also across a daemon restart. Sequences are compared per
transport (worker claim or public report) and per runtime generation, so one
clock never makes the other stale. Inside the worker, identity claims and
releases are ordered per reported process (pid and kernel start identity): a
report must be newer than every claim and release accepted for that same
process, and reports of different processes are never compared, so a nested
agent’s claim, even at u64::MAX, never makes the launch agent’s later claim
stale. Across processes the latest accepted claim is the active identity. A
release answered ok: false (its process is not active) still orders that
process’s later claims. The worker keeps every live reporter’s sequence, never
evicting it, so a released process cannot become active again through its own
older claim delivered late. Marks of exited processes, unreaped ones
included, are pruned when room is needed; when live reporters other than the
PTY root and the launch process fill the bound (16), a further process’s report
is refused with reason identity_reporters_exhausted until one exits. Before
the launch identity exists, a launch agent’s report with a native reference is
still verified as a launch claim at the bound and kept only when it
establishes the launch identity; a deferred verification keeps only its
pending launch claim, so the retry can still establish it. Each
hook event runs a fresh interpreter for the same agent process, so the shipped
Codex and Claude hooks stamp their worker claims from the system-wide
monotonic clock in nanoseconds (CLOCK_MONOTONIC). A recovered
generation’s first claim supersedes the reference it was relaunched with. The worker journals the latest
reference of the verified launch process apart from the active claim’s lease, so
a switch made while the daemon was down survives the lease, and every
replacement carries an ordering key that decides record-versus-projection
reconciliation: the newer key wins and an unkeyed side loses to a keyed one. A
session whose runtime ended while the daemon was down imports that reference too,
when it belongs to the record’s own worker instance and the verified launch
process; a report that arrives while the worker is still verifying the launch
process is promoted with the claim. The public API reference
has the field-level contract.
Which process may report the conversation id
Section titled “Which process may report the conversation id”A hook reports the conversation id through the worker, and the worker accepts it
as the launch claim only from the launch process the session’s hook schema
declares. Under NamedForProvider (identity-subagent-v1; it is also the core
default for a session without a schema, which is never reached because such a
session admits no hook report) that is the process named for the provider that
started first below the PTY root, never an interpreter such as node. Under
FrozenRootEntrypoint (identity-v1, used by Hermes, whose hermes entrypoint
is a Python script) it is the PTY root alone, reporting in process: the worker
requires the kernel peer of the report to be the root’s PID and start time, so
a report naming the root from any other process, a descendant included, is
refused, and it accepts the root’s report only while the root still runs the
exact entrypoint and shebang interpreter the worker froze before spawning it.
Every later conversation id of the root is verified the same way before it is
journaled. On the worker path only this rule binds the root’s report to the
root’s own kernel peer. Under NamedForProvider a hook reports as a descendant of the launch
process, and a descendant’s report naming the launch process counts as that
process’s report; every process in the agent’s PTY subtree runs as the owner,
so this rule attributes reports and does not authenticate them. Under every
schema the worker refuses a report from a process that is neither the process
it names nor a descendant of it (peer_outside_subject). The rule requires
the entrypoint’s shebang interpreter to run the script itself. An interpreter that re-executes another image, such as a macOS
framework Python stub or an xcrun shim like /usr/bin/python3, leaves the
session without a launch identity: every claim is refused as
image_mismatch or cmdline_mismatch (a worker.launch_identity.rejected
warning in the worker log). The runtime still runs, but native resume is
unavailable for that session. Under NamedForProvider, a process named for the
provider that the launch process started directly is accepted as well only in the provider’s hook-helper role, which is
a command line whose second argument is app-server for Codex (it runs its
hooks from a codex app-server child of the launched codex); a provider
without a declared helper role gets no exemption. An independent same-provider
child, a deeper process, a sibling, a process of another name, and a process
whose PID or start time no longer matches are refused, so an agent a tool command starts never speaks for the session. The
first accepted claim is the initial reference. A later switch replaces it only
when the launch agent reports a newer verified conversation. Only the
SessionStart identity report carries a conversation
id; a subagent’s SubagentStart and SubagentStop reports and Claude’s
main-Stop subagent snapshot are separate claim types that never touch the
reference. The daemon’s public session.report_native_id fallback is attested
against the kernel peer of its local connection: only the session’s PTY root
itself is admitted, so a descendant of the root reporting the root’s pid and
start identity is refused with native_report_unattested and records nothing.
A session on the identity-v1 schema refuses that method for every report, the
root included, so its native reference comes only from the worker. Its runtime,
process-identity, ordering and expiry checks still apply to an admitted report.
The official Pi package is the worked example: see the Pi runtime package.
session.fork creates a new pohunek session id and PTY from the source session’s
native agent conversation. The source may still be live; fork does not require a
terminal state. With cwd_mode: "same", the new session starts in the source
cwd/worktree. The child begins without its own native recovery reference;
only its own verified launch-agent report can set one, except for a runtime
whose assigned reference covers forks (assign_fork), where the child starts
with the reference core assigned to it. The fork argv comes
from the session’s frozen native-session
launch spec: Claude forks as claude --resume <native_session_id> --fork-session, and a host profile that declares fork_args forks with exactly
those arguments. Codex fork is intentionally not enabled in this daemon contract;
Codex-backed sessions return the typed agent_fork_unsupported error instead
of fabricating an unsupported branch. Hermes-backed sessions return the same
typed unsupported error.
Recovery records written by earlier releases are completed when the daemon
starts, so every such session stays listed with its recovery record. The
metadata store migration maps the flat resume_mode/ref_kind/resumable/fork_*
fields of v0.33.0 onto the session’s native-session launch spec with the argv
they already produced. A v0.33.1 record that kept its native reference but lost
its launch spec is restored with the spec v0.33.1 compiled in for Claude, Codex
and Hermes, which the migration keeps frozen and never reads from a current
descriptor (a restored Claude record carries its fork arguments again); for a
profile or package runtime the spec is resolved by agent name when the record
is loaded. The migration never launches anything: a restored session stays
lost until an explicit session.resume. A record of the built-in Codex,
Claude Code or Hermes, or of a host profile over one of them, is restored and
listed, but this daemon compiles none of them in: its resume and fork answer
runtime_builtin_removed while no package serves the alias. Once the official
package serves it, the package adopts the record (see
official aliases) and resume
and fork run through it when the recovery contract is honored; otherwise they
answer runtime_served_by_package, and the migration is a new session. A record that
cannot be completed (the profile was deleted, no longer runs on the same
runtime, or declares no native resume) still loads and keeps its place in
session list. The daemon logs reconcile.native_recovery.unrepairable at
WARN, the session carries a native_recovery warning, and session list prints
the hint under the table: start a new session and resume the native conversation
in it, using the reference in the warning’s detail. Never resume the old
record when the native conversation already continues under another session,
because that would start a second agent on the same native id.
A launch (new, resume or fork) whose agent’s installed Claude or Codex hook
assets are outdated (an older version marker, or content that differs from the
daemon’s embedded script) carries a hook warning on the session: its hooks may
report nothing, so message names the pohunek integration install --agent <agent> command (with --profile <name> for a host profile’s home). The
session starts normally and an agent without installed hooks carries no warning.
For project-aware work, prefer a registered project or repository target over an ad hoc directory. See projects and worktrees.
Delegated task errors
Section titled “Delegated task errors”The protocol reserves a typed error contract for the delegated task layer
(task.* methods). The daemon does not serve those methods yet, so they answer
method_not_found and no task error is raised today. Every task error has a
stable code, a fixed msg and, for most, a fixed recover hint in the
daemon’s response; none echoes a prompt, answer, path, task id or secret. The
Rust SDK’s remote path prepends host '<host>': to msg. Branch on class
and code and show msg and recover verbatim. The full code table is in the Delegated Task
Errors section of docs/public-api.md. Sessions that belong to tasks use
worktree_busy (only observation is admitted while a task occupies the
worktree), task_session_ended, task_fork_unsupported, worktree_in_use,
worktree_users_changed and task_snapshot_retired.