Skip to content

0072: Derive session state from canonical events

Status: accepted (2026-08-12) · Scope: product tier (coordination, hooks, canonical events)

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.

  • Use one status enum. Rejected because values such as blocked, idle, and done mix current activity with task lifecycle.
  • Treat a missing heartbeat as completion. Rejected because a process can stop without finishing its declared task.
  • Infer needs_input from 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.

Harnery keeps two independent axes:

  • activity: unknown, working, needs_input, or idle
  • task_state: active, blocked, or done

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.

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.

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.