Sessions

Session persistence format and CLI behavior.

Sessions

Conversations persist as append-only JSONL under ${XDG_STATE_HOME:-~/.local/state}/fen/sessions/<cwd-slug>/<ISO>_<id>.jsonl. Line 1 is a {:type :session :version 2 :id :timestamp :cwd} header; subsequent lines are session entries. Message entries are {:type :message :id :parent-id :timestamp :message <canonical-msg>}. Compaction entries are {:type :compaction :id :parent-id :timestamp :summary :first-kept-entry-id :tokens-before :tokens-after :guidance :trigger}. Extension-owned state entries are {:type :extension-state :extension :version :state :id :parent-id :timestamp}. Their :state is a JSON-friendly table, entries are append-only, and the latest structurally valid entry for one extension wins. Entry IDs are in-tree UUIDv7-shaped strings generated by fen.util.id; :parent-id points to the previously appended entry when available. The cwd-slug mirrors pi-mono's --<encoded-cwd>-- shape (slashes → -, sandwiched in --).

Flags:

Headless outcome reporting

fen --print exits 0 only after a successful final assistant reply and exits 1 for provider, HTTP, tool-safety-cap, or runtime failures. fen goal exits 0 when done, 2 when blocked or at its iteration cap, and 1 for provider, tool, runtime, or internal failures. Its objective may be passed inline, supplied as literal text with --prompt TEXT, read from --prompt-file PATH, or read from stdin with --prompt -. Choose exactly one source; prompt, file, and stdin behavior matches fen session send. A stopped goal (the explicit terminal status set when the user stops or cancels the run) also exits 2 and is folded into the JSON blocked status. Plain-text goal runs always end with GOAL_STATUS: done, GOAL_STATUS: blocked, or GOAL_STATUS: error, with the final line being authoritative when partial model output contained an earlier marker. Set FEN_JSON_OUTPUT_PATH=out.json for fen goal to write the existing JSON-presenter result blob to that path instead of plain output. When FEN_JSON_OUTPUT_PATH is set to a non-empty path the plain GOAL_STATUS trailer is suppressed, since the JSON blob carries the authoritative outcome. A JSON write failure forces exit 1 even when the goal itself succeeded, so a missing or truncated blob is never mistaken for success. Its goal object has named fields status (done, blocked, iteration-cap, or failure), reason, iterations-used, and wall-clock-ms.

The fen session subcommands provide a blocking subprocess interface for durable conversations:

fen session new --json
fen session list --json
fen session show <session-id> --json
fen session show <session-id> --tail 10 --json
fen session send <session-id> --json -- "Inspect the failing tests"
fen session send <session-id> --json --prompt -
fen session send <session-id> --json --prompt-file request.md
fen session doctor /path/to/session.jsonl --json
fen session doctor /path/to/session.jsonl --json --repair

Every command writes exactly one JSON result document to stdout and sends logs and diagnostics to stderr. Success documents have ok: true; failures have ok: false and a stable error.code plus a human-readable error.message. Exit status 0 means success, 1 means a provider, tool, backend, or runtime failure, and 2 means a caller error such as invalid arguments, an unknown session, a cwd mismatch, or concurrent mutation.

Sessions are scoped to the current cwd and mutation requires the complete session ID. The machine interface never falls back to the latest session and never uses prefix matching for send. send loads the existing canonical transcript, builds the normal agent with the current provider, model, thinking, web-search, and tool-policy flags, persists one ordinary turn, and returns only messages produced by that turn. show is the operation for reading the complete transcript or a bounded suffix. Prompts can follow --, be supplied as literal text with --prompt TEXT, come from stdin with --prompt -, or be read from a file with --prompt-file PATH.

A per-session backend lock prevents overlapping sends from loading and appending the same history concurrently. A busy session fails with session_busy and exit status 2; it is never silently forked or interleaved. The JSONL backend records the lock owner's pid and reclaims a lock whose owner process no longer exists, so a crashed send does not leave the session permanently busy. --continue remains the human-oriented shortcut for the latest session and is intentionally distinct from this exact-ID interface.

Session doctor

After repeated provider 400 errors while continuing a session, run fen session doctor PATH --json to inspect the local JSONL file without contacting a provider. The dry-run report contains only line numbers, stable issue codes, and short descriptions, so it does not print full tool output. It identifies malformed or truncated JSON lines, missing or misplaced headers, assistant error turns, broken tool-call/result pairing, and oversized tool results. Use fen session doctor PATH --json --repair only after reviewing the report. Repair is non-destructive: it writes PATH.repaired.jsonl and an adjacent PATH.repaired.jsonl.doctor.json audit record, while leaving the original untouched; repaired siblings and lock artifacts are inert and never considered session transcripts by discovery. The repair removes malformed entries, replaces oversized tool output with a marker, and adds a marker result when a tool call is missing one. Assistant-error turns without tool calls are dropped; error turns carrying tool calls are retained with their stop-reason so provider tool-call pairing stays valid. A pre-existing PATH.repaired.jsonl is renamed to PATH.repaired.jsonl.bak before a new repair is written.

The JSONL backend treats transcript files as the source of truth. Listing, finding, opening existing sessions, and replay may receive an optional cooperative yield callback from the runtime. The backend yields while scanning directories and large JSONL files, and cancellation must close open file handles before unwinding. Malformed extension-state entries are warned about and ignored, while unknown entry types remain forward-compatible. An in-process metadata cache records file size, mtime, id, title, message count, and last entry id for unchanged files. The cache is only an optimization for list/find/latest/open-existing paths; load still reads the JSONL transcript when replaying a session.

The extension API exposes session-scoped append-state! and latest-state helpers bound to the calling extension's owner identity. They operate only on the active session handle; switching with /new or /resume changes that scope, and --no-session makes appends/restores no-ops. Sessions containing durable extension state but no messages are still discoverable so an interrupted workflow can be continued.

What we deliberately don't have (vs pi-mono): branching/tree navigation, fork, model_change / thinking_level_change entries. Forward-compatible: readers should ignore unknown :type values.

Compaction

/compact [guidance] summarizes older messages in the current session and keeps recent messages verbatim. It appends a :compaction entry rather than rewriting prior message entries. On --continue, the JSONL backend finds the latest valid compaction entry and reconstructs context as a synthetic summary user message followed by messages from :first-kept-entry-id onward. If the compaction entry is malformed or points at a missing message entry, it is ignored and the full message history is replayed.

The synthetic summary is in-memory replay context and is not written as a :message entry. After compaction, future turns append normally after the :compaction entry.

Saves are wired in packages/fen/src/fen/session_lifecycle.fnl as a flush closure over agent.messages and the active session handle. The lifecycle bridge listens for :message-appended, flushes only after the first assistant message exists, and keeps a final turn-completion flush as a safety net.

Provider errors may attach sanitized machine-readable error-details to the assistant message persisted in the transcript. For Codex exhausted subscription quota, fen session send --json exposes those details as error.details while retaining the stable error.code: turn_failed envelope. The print JSON presenter and goal JSON result file expose them as top-level error-details on failure, keeping their existing human-readable error/outcome fields. code: usage_limit_reached and retryable: false distinguish exhausted quota from transient rate limits; valid reset-at (Unix seconds), resets-in-seconds, and length-seconds let callers schedule a later resume without parsing prose. Reset details are an observation, not a guarantee of future allowance, and no automatic resume is scheduled.