0072: Derive session state from canonical events
Status: accepted (2026-08-12) · Scope: product tier (coordination, hooks, canonical events)
Context
Section titled “Context”An agent can be waiting for permission while its task is still active. It can also be idle after a turn while the same task remains open. One status field cannot describe both facts without making readers guess what a value means.
Heartbeat files are useful for live views, but they are temporary. They can be swept, deleted at session end, or rebuilt. Session state therefore needs a durable source that survives the heartbeat.
Adapter payloads are uneven. Some adapters report a permission request directly; others expose tool and turn boundaries but no trustworthy input-wait signal. Inferring a wait from elapsed time or a nearby hook would report false states.
Alternatives considered
Section titled “Alternatives considered”- Use one status enum. Rejected because values such as
blocked,idle, anddonemix current activity with task lifecycle. - Treat a missing heartbeat as completion. Rejected because a process can stop without finishing its declared task.
- Infer
needs_inputfrom time or nearby hooks. Rejected because silence and pre-execution events do not prove that an operator prompt is open. - Store state only in the heartbeat. Rejected because the state would disappear with the file and could not be audited or rebuilt.
- Implement separate live and replay rules. Rejected because the two views would eventually disagree.
Decision
Section titled “Decision”Harnery keeps two independent axes:
activity:unknown,working,needs_input, oridletask_state:active,blocked, ordone
One pure transition reducer consumes canonical events. The heartbeat projector
uses that reducer for live state, and foldSessionState() uses the same reducer
for replay. Heartbeats store the current values plus their evidence timestamp
and source. The event ledger remains authoritative.
Activity changes only on mapped evidence. A prompt or tool start means
working; a direct interaction.input_requested event means needs_input;
a turn or session stop means idle. Tool output, narration, unrelated
coordination events, and elapsed time do not clear an input wait. A command
start counts as progress only while the session already has an evidenced open
turn.
Task lifecycle changes only through state.task_state. Activity events never
finish, block, or reopen a task. Readers treat a missing lifecycle value as
active for compatibility. Missing or unsupported activity evidence resolves
to unknown.
Codex PermissionRequest is mapped to interaction.input_requested. Harnery
does not map the superficially similar Claude Code or Cursor hooks until their
payload contracts provide direct evidence of an operator wait.
Consequences
Section titled “Consequences”Live dashboards and ledger replay now share the same semantics. Removing a heartbeat does not remove the evidence needed to reconstruct either axis.
Some sessions will show unknown instead of a more appealing guess. That is
intentional. Adapter support can expand when a verified payload fixture proves
the signal.
Process exit still never means done. Lifecycle declarations use the durable
event and projection contract defined here.
Result
Section titled “Result”Phase 1 shipped the shared reducer, heartbeat projection, replay helper, and
adapter normalization. A native Codex permission request then produced a real
interaction.input_requested event and projected activity: needs_input; the
approved HEAD request completed successfully.
Phase 2 added harn agents lifecycle <active|blocked|done>. Successful changes
emit one state.task_state event, while exact repeats emit nothing. blocked
requires a reason, leaving it deletes the old reason, and done reuses the Git
finalization gate before any mutation. Normal set-task calls stay
lifecycle-neutral.
Lifecycle changes also re-mint the suggested session name. Prompt reminders and the Claude Code Stop rule now enforce the new name until it is observed. Human-facing guards keep subagents, transient processes, and workflow children from declaring operator task lifecycle.
Phase 3 carried both axes through the operator surfaces. Local CLI records, prompt peer context, cross-machine presence, and web views now use explicit activity and lifecycle labels. State-only changes participate in prompt and presence hashes, and lifecycle-only heartbeat writes refresh the web view.
The web identity index also folds session-state evidence for ended sessions. Live boards still remove stale or ended heartbeats, while detail and history readers reconstruct their last activity and lifecycle from canonical events. Timestamp guards prevent an older replayed event from rolling that projection backward.
Phase 4 added the first lifecycle consumer outside the live coordination
surfaces. harn claude-desktop tidy streams the active and rotated event
ledgers, maps desktop entries by session identity, and archives only entries
whose latest declaration is done. It dry-runs by default and uses atomic
entry replacement when --yes is supplied.
Historical [DONE] titles remain available behind
--include-legacy-prefix. They are not part of normal selection, and neither a
legacy title nor a desktop edit can override matching active or blocked event
evidence. This keeps the projection direction established by this decision.
See Session state for the shareable feature overview and Manage Claude Desktop sessions for the operator workflow.