This is the full operator/agent reference for fable-session: every command, option, guarantee, and edge case. If you are new here, start with the README and the Quickstart instead — and keep their two notices in mind: fable-session is unofficial (not affiliated with Anthropic) and does not bypass safeguards.
One console command, three subcommands:
fable-session run— resolve a registered project, build a bounded brief, construct the exact Claude CLI command, and (only on explicit request) launch it in a new tmux session.fable-session audit— post-run model-purity audit: stream one completed session's JSONL transcript and report which models actually served it.fable-session watch— a JSONL-first monitor for exactly ONE runner-created lane (one manifest). It stays silent when nothing changes, emits one compact JSON object per newly confirmed structured lifecycle event, and in--followmode exits by itself at the lane's terminal state. It never scans hosts, projects, panes, or PIDs, and never acts on the session.
Runtime dependencies: Python 3.12 standard library only. Installation is covered in the README.
fable-session run --project example-api --task /absolute/path/task.md --dry-run--dry-run is the default; omitting all launch flags behaves identically.
The dry run:
- resolves the project from
~/.config/fable-session/projects.toml(override with--registry); - loads the repo-owned profile (
agent-context/profiles/*.toml); - builds the bounded brief from the packaged
bounded-worker.mdtemplate (importable package data — no dependency on the source checkout); - writes a run manifest under
~/.local/state/fable-session/runs/<run-id>/manifest.json(override with--state-dir); - prints project, model/effort/fallback, repo, branch, task, brief mode, session id, the future structured-stream path and native JSONL transcript path (described only — a dry run creates no transcript), manifest path, and the redacted launch command.
Dry runs are deterministic: identical registry/profile/task inputs produce the identical run id, session id, and manifest.
fable-session run --project example-api --task /absolute/path/task.md \
--launch --tmux api-example--launchis required to start anything;--tmuxnames the new session.- The full session name is strictly validated (
[A-Za-z0-9_-], max 64 chars —:and.are tmux target syntax and are rejected) and must start with the project'stmux_prefix(same character set). - The tool only ever creates a new tmux session (exact-name match); if
the name already exists it fails and starts nothing. It never uses
--continueor--resume. - Model, effort, and permission mode are pinned from the registry;
--fallback-modeland--dangerously-skip-permissionsare never emitted (config accepts onlyfallback = "stop"and an allowlistedpermission_mode;bypassPermissionsis rejected at parse time). fallback = "stop"is enforced, not just documented: every command carries--settings '{"switchModelsOnFlag": false}', so the pinned model either serves or the session stops — it never switches silently.- Only the verified prompt flags
--append-system-prompt(profilecontext_mode = "append") and--system-prompt("replace") are used; no file-based prompt flags. Since 0.3.0b1 the prompt payloads never ride the tmux command line: the runner writes the brief and task as run-scoped mode-0600 payload files (brief.payload,task.payloadinside the run directory) from its single scanned read, and the capture wrapper — after verifying each payload's sha256 against the hash recorded in the manifest — substitutes the brief for a single placeholder token in the Claude argv and feeds the task to Claude on stdin (-pwith no inline prompt). This also repairs the verified launch defect where a 7,745-byte task plus an 8,072-byte brief failed with tmux'scommand too long: the tmux command stays short no matter how large the (bounded) payloads are. - If configured, the registry's
max_budget_usdis passed as exactly one--max-budget-usdargument, shown in the launch summary, and recorded in the manifest; with no budget configured, no budget flag is emitted and the manifest recordsmax_budget_usd: null. The Claude CLI's own budget enforcement is the mechanism — fable-session never kills or stops a session itself. - Terminal evidence is captured, not inferred. Claude runs with
--output-format stream-json --verboseunder a shell-free capture wrapper (capture.py, executed by absolute path — nobash -c, no pipeline, no redirection strings). The wrapper creates the run-scoped stream file<run-dir>/stream.jsonlexclusively (mode 0600; an existing/symlinked destination fails closed before Claude is invoked), hands it to Claude as stdout so the structured stream — including the top-level terminalresultevent — lands byte-for-byte, and propagates Claude's exit status. The manifest'sexpected_transcript_pathnames this stream (whatfable-session watchandfable-session auditconsume);native_transcript_pathseparately preserves Claude's native session JSONL, which never contains the terminalresultand is for diagnosis only. The stream is private transcript data: it may contain prompt and result text and is never echoed to runner output or the manifest.
- Each launch gets a high-entropy run id
(
<UTC-timestamp>-<project>-<random-token>) and an independent random Claude session id; identical launches in the same second can never share a run directory, manifest, or JSONL transcript identity. - The run directory is reserved atomically (
mkdirwithexist_ok=False, retried with fresh entropy on collision) and apendingmanifest is written before tmux starts. If reservation or the pending write fails, nothing is launched. - After
tmux new-sessionsucceeds the manifest is atomically updated tolaunched; if tmux fails it recordsfailed. If a post-launch update fails before its publish rename, thependingrecord remains on the reservation-bound run-directory inode and already identifies the actor, model, session id, and tmux session. - Manifest paths in output and manifests are reservation-time display paths. All writes go through the run-directory FD held since reservation, so they always land in the reserved inode — but a same-UID process can rename that directory despite the open FD, so the display path may later stop resolving to it (and may even name a planted replacement). Do not treat the display path as the manifest's current location without re-proving its identity.
- Post-publish durability failure (
StateCommitUncertain): if the run-directory fsync after a successful publish rename fails, the new complete manifest was already atomically published to the bound run-directory inode and the previous one is gone from it — only durability across a crash or power loss is unconfirmed. Errors report the bound(dev, ino)taken from the held FD (never re-read from the path) and label the reservation-time path as display-only, so recovery tooling is never pointed at a possibly planted replacement. - Run ids are re-validated defensively in the state layer: path separators,
./.., control characters, and symlinked run directories can never escape the canonicalruns/root. - The state directory path is interpreted lexically and trusted only via
directory file descriptors: every component is created/opened
component-by-component with
O_DIRECTORY | O_NOFOLLOWfrom the filesystem root. A symlink anywhere in the state path — including the state directory itself and itsrunschild — is rejected as aStateError; symlinked state directories are unsupported by contract (point--state-dirat the real path). No write ever re-traverses a path after trust is established. - Manifest updates are atomic and writer-private: each writer stages its
payload in its own unpredictable
.manifest-<random>.tmp(O_CREAT | O_EXCL | O_NOFOLLOW), fully written, fsynced, and closed before anos.replacepublish relative to the trusted run-dir FD. Concurrent writers are last-writer-wins; a reader can never observe a partial or mutable-after-publishmanifest.json. A crashed writer may leave a stale.manifest-*.tmp, which other writers never adopt or delete.
The runner pins the model and disables model switching, but its manifest
proves only intent. fable-session audit checks the evidence: it
streams one completed Claude Code JSONL transcript and reports observed
model purity. It is generic — any registered project — with no
project-specific logic.
# Explicit source
fable-session audit \
--transcript /absolute/path/session.jsonl \
--requested-model claude-fable-5
# Runner manifest source (reads `model` and `expected_transcript_path`;
# never mutates the manifest)
fable-session audit --manifest /absolute/path/manifest.json
# Machine-readable
fable-session audit --manifest /absolute/path/manifest.json --format jsonThe two input modes are mutually exclusive. Every input path must be an absolute, existing regular file; symlinks are rejected, not followed.
What it reports:
- observed message models — non-synthetic
message.modelvalues from assistant entries, deduplicated by uniquemessage.id(streamed/chunked entries count once), as unique-message-id counts per model; - final response model — the model of the last unique assistant message
with non-empty text content (
—/nullwhen unprovable, which forfeits PURE); - synthetic messages —
<synthetic>entries are counted separately and never prove a serving model; - auxiliary models — result
modelUsagekeys beyond the observed message models. Helper-model use (e.g. a smaller model doing summarization) is reported honestly but does not by itself make a run MIXED; - fallback/refusal events — structured system events such as subtype
model_refusal_fallback, with any serving model they identify; - result metadata — safe fields only (subtype,
is_error, stop reason); - usage (0.3.0b1) — a privacy-safe aggregate of the single terminal
result's
modelUsageacross all models:input_tokens,output_tokens,cache_read_input_tokens,cache_creation_input_tokens,web_search_requests, andtotal_cost_usd— bounded numbers only, never prompt, result, or tool text. WhenmodelUsageis missing, or there is no single trustworthy terminal result,usageis an explicitnull(text output saysusage: unavailable) — zeroes are never invented. A structurally malformedmodelUsage(wrong types, negative or non-finite numbers) additionally sets the reason codemalformed_model_usageand forfeits PURE: corrupt optional usage never falsely proves model purity; - terminal-result completeness — a completed transcript carries exactly
one top-level
type="result"event withsubtype: "success"and booleanis_error: false. A missing result (interrupted stream), more than one result (concatenated runs — none is silently chosen;resultisnull), or an unsuccessful/ambiguous result is an evidence-integrity failure (missing_terminal_result,multiple_terminal_results,unsuccessful_terminal_result). Completion is never inferred from the last assistant text, status events, or anything outside the structured result;stop_reasonis reported but not required; - reason codes — bounded machine-safe diagnostics (e.g.
malformed_json_line,conflicting_duplicate_message_id,missing_terminal_result).
Verdicts, for requested model R. PURE and MIXED both require exactly one
successful terminal result; a missing, multiple, or unsuccessful result is
UNKNOWN:
PURE(exit 0) — parsing complete and valid, exactly one successful terminal result, at least one non-synthetic message observed, every observed message model and the proven final response model are exactlyR, and no fallback/refusal event occurred.MIXED(exit 1) — complete evidence (including exactly one successful terminal result) proves an observed non-synthetic message model other thanR, or a fallback/refusal event identifies a different serving model. A later return toRstays MIXED.UNKNOWN(exit 2) — purity cannot be proven: missing, malformed, partial, or conflicting evidence (duplicate id with conflicting models, truncated trailing JSON, model-bearing message without an id), a missing, multiple, or unsuccessful terminal result, synthetic-only transcripts, no provable final response model, or a fallback/refusal without enough serving-model evidence to classify MIXED. Evidence-integrity failures always win over a tempting PURE/MIXED conclusion — an interrupted or concatenated stream is UNKNOWN even when all observed messages matchRor a different model would otherwise prove MIXED.
Exit code 2 is also used for usage, file, and manifest errors. Neither
output format ever contains prompts, result free text, tool payloads, or
any other transcript content; the JSON object (stable keys,
schema_version: 1) carries no filesystem paths.
fable-session watch monitors exactly one runner-created lane, identified
by the absolute path of its run manifest. It is read-only with respect to
the lane: it never presses keys, pastes text, retries, switches models,
creates prompts, kills sessions, or touches product files — it reads,
classifies, and reports.
# One scan, then return
fable-session watch --manifest /abs/runs/<run-id>/manifest.json --once
# Poll only this lane's transcript; exits by itself at the terminal state
fable-session watch --manifest /abs/runs/<run-id>/manifest.json \
--follow --poll-interval 2 --settle-seconds 10Inputs fail closed (exit 2): the manifest and the transcript it names via
expected_transcript_path must each be an absolute, existing, regular,
non-symlinked file (the transcript is re-verified on every scan). The lane
identity is the manifest's session_id (required) plus run_id and
tmux_session (optional); a transcript carrying any other session id is a
lane mismatch and is refused. There is no global or multi-lane mode — no
scanning of other projects, tmux panes, PIDs, home directories, or hosts.
Events. Evidence rules are shared with the model audit (one truth
model): only structured top-level JSONL entries are events; quoted warning
text inside ordinary assistant/user message content (a message that merely
says "Session paused") never is. Each newly confirmed event is one
compact JSON object on stdout (schema_version: 1, session/run identity,
bounded structural fields only); when there is nothing new, stdout is
empty.
refusal_pause— structured system event whose subtype names a refusal or pause;model_fallback— structured system fallback event (a subtype naming both, e.g.model_refusal_fallback, counts as the fallback), with any serving model it identifies;permission_block— structured system permission-block event;malformed_evidence— lifecycle-relevant integrity failure (malformed JSON line, non-object line, conflicting session-id fields) before any terminal result; diagnostic, not terminal;completed— exactly one successful terminal result (subtype: "success", booleanis_error: false); terminal. When the result carries a structurally validmodelUsage, the event includes the same boundedusageaggregate as the audit (tokens, web-search requests,total_cost_usd) — and makes no usage claim otherwise;completed_error— exactly one unsuccessful (or ambiguous-fields) terminal result; terminal; carries the boundedusageaggregate under the same rule;completed_ambiguous— more than one terminal result, or a terminal result on malformed evidence (integrity failures win over a tempting completion verdict, exactly as in the audit); terminal;interrupted—--followonly, see below; terminal.
A missing terminal result is never called complete. An unterminated final JSONL line is an in-progress write: tolerated until it completes, never malformed evidence.
Silence and dedupe. Dedupe state persists per lane (default
watchdog.state.json next to the manifest; override with an absolute
--state-file), keyed by session id plus event identity, so repeated scans
of an unchanged transcript emit nothing. Writes are atomic (exclusive
temp + fsync + rename) and contain only the schema version, lane identity,
and emitted event keys — never prompts, tool payloads, credentials, or
transcript content. A state file belonging to a different session fails
closed; separate sessions can never collide. The repo's documented
same-UID limitation applies to this state exactly as to run manifests.
Auto-exit and interruption. --follow polls only this manifest's
transcript and exits after emitting/deduping the lane's terminal event —
it never stays resident after completion. An interruption is claimed only
when ALL of the following hold: the manifest names a tmux session, an
exact-name probe (tmux has-session -t =NAME, never a scan) says it is
gone, the transcript stayed byte-stable across consecutive polls for at
least the settle window (--settle-seconds, default 10.0), and no
terminal result exists. Then exactly one interrupted event is emitted
and the watchdog exits 1. A failed probe proves nothing and never supports
an interruption claim; with no tmux_session in the manifest,
interruption detection is disabled and only a terminal result ends the
follow. Start --follow after the transcript file exists — a missing
transcript is a rejected input, not a wait state.
Exit codes (stable).
0— clean:--oncescanned with the lane in progress or successfully completed;--followexited on the successful terminal result.1— the lane reached a confirmed non-success terminal state:completed_error,completed_ambiguous, orinterrupted.2— usage, input-validation, or state error (bad flags; missing, relative, malformed, non-regular, or symlinked manifest / transcript / state inputs; lane/session mismatch; state write failure). Nothing was concluded about the lane.
Host registry (~/.config/fable-session/projects.toml, see
examples/projects.toml):
[project.example-api]
repo = "/srv/example-project"
profile = "agent-context/profiles/fable-5.toml"
model = "claude-fable-5"
effort = "high"
fallback = "stop"
permission_mode = "auto"
tmux_prefix = "api-"
# Optional (0.3.0b1): enforceable per-session budget, passed to Claude as
# exactly one `--max-budget-usd 12.5`. Must be a finite positive TOML
# number with a plain-decimal form; booleans, zero, negatives, NaN,
# infinities, strings, and exponent-only representations are rejected
# before any command is built. Omit the key for no budget flag at all.
max_budget_usd = 12.5permission_mode is required and allowlisted (acceptEdits, auto,
manual, dontAsk, plan — the installed Claude CLI's choices minus
bypassPermissions); it is passed explicitly as --permission-mode so the
session never inherits an ambient permission policy. bypassPermissions and
dangerous-skip flags are rejected outright, as is the unsupported default.
Repo-owned profile (<repo>/agent-context/profiles/fable-5.toml):
version = 1
product = "Internal data dashboard built with Vue and FastAPI."
context_mode = "append"
max_brief_bytes = 2048
allowed_roots = ["backend", "frontend", "tests", "docs"]Parsing is strict: unknown or secret-like keys, secret-shaped values,
relative repo paths, unsupported profile versions, and allowed_roots
escaping the repo all fail before any command is built. Project names are
strict identifiers ([A-Za-z0-9][A-Za-z0-9_-]{0,31}) because they flow
into run ids and state paths. The profile path is resolved and must stay
inside the repo even through symlinks. Project/model differences live
entirely in these TOML files — the runner has no project-specific behavior.
Task and profile files are read exactly once per run: the same immutable text is parsed, secret-scanned, hashed, rendered into the brief, and passed to Claude, so a file changing on disk mid-run cannot smuggle unscanned content into the launched command.
# Task: short title
## Goal
One short paragraph.
## Checks
- one bullet per required check
## Boundaries
- one bullet per boundary
## Report
- one bullet per report element
## Docs (optional)
- path/to/canonical-doc.mdGoal, Checks, Boundaries, and Report are required. Docs entries must be bare
paths — canonical docs are referenced, never copied into the brief. The
rendered brief must fit in the profile's max_brief_bytes. Secret-shaped
content anywhere in the task file or brief aborts the run.
Run manifests and user-facing output contain hashes, byte sizes, and paths —
never brief/prompt contents, credentials, or terminal history. The manifest
also records the effective permission mode and no-fallback settings, plus
the expected Claude Code JSONL transcript path
(~/.claude/projects/<munged-repo>/<session-id>.jsonl; the session id is
passed to Claude via --session-id).
Prompt payload handling (0.3.0b1): the tmux command line never carries
prompt text — the brief and task travel as run-scoped mode-0600 payload
files (brief.payload, task.payload in the run directory), written from
the single scanned read and verified by sha256 before delivery. The task
reaches Claude on stdin and appears on no process command line at all.
The brief is substituted into the Claude argv at exec time, so it
remains visible to same-host process inspection (e.g. ps,
/proc/<pid>/cmdline) of the Claude process for its lifetime, and the
payload files themselves are readable by the same UID. Do not put anything
in a task file that other users of the same host must not see;
secret-shaped content is rejected before launch. A dry run writes only the
manifest — it leaves no payload material behind, and its manifest records
payload_files_created: false (payload paths are descriptive only). A
launch manifest carries payload_files_created: true only in records
written after both run-scoped payload files were actually created;
records written before that point (the pending record, or a failed
record after a payload-write failure) truthfully say false. Manifests
from earlier releases lack the key and stay fully readable.
Version 0.2.0 renamed the distribution, import package, CLI, manifests, and default paths. Nothing migrates implicitly: fable-session never reads, adopts, or mutates the old directories.
| 0.1.x | 0.2.0 |
|---|---|
distribution claude-context-tools |
fable-session |
import package claude_context_tools |
fable_session |
claude-context-run |
fable-session run |
claude-context-audit-models |
fable-session audit |
claude-session-watchdog |
fable-session watch |
~/.config/claude-context/ |
~/.config/fable-session/ |
~/.local/state/claude-context/ |
~/.local/state/fable-session/ |
manifest tool: "claude-context-run <ver>" |
tool: "fable-session <ver>" |
Explicit migration steps:
- Copy your registry yourself:
cp ~/.config/claude-context/projects.toml ~/.config/fable-session/projects.toml(or pass--registryexplicitly). The file format is unchanged. - Old run state stays where it is and stays readable:
fable-session audit --manifest <old path>andfable-session watch --manifest <old path>accept 0.1.x manifests when you point at them explicitly — nothing keys off the manifest'stoolstring. New runs write only under the new state directory (or your--state-dir). - Deterministic dry-run ids changed with the rename (the id namespace is
now
fable-session), so a 0.2.0 dry run of identical inputs produces a different run/session id than 0.1.x did. Launch ids were always random.
Rollback: uninstall 0.2.0 and reinstall 0.1.x. Because 0.2.0 never touches
the old config/state directories, a rollback finds them exactly as 0.1.x
left them; delete ~/.config/fable-session/ and
~/.local/state/fable-session/ if you want no trace of the trial.
The three old console names (claude-context-run,
claude-context-audit-models, claude-session-watchdog) were kept for
exactly one migration release (0.2.x) as deprecated compatibility aliases
and are removed in 0.3.0b1: they are no longer installed and no longer
exist as entry points. Use fable-session run|audit|watch. Reading old
manifests is unaffected — fable-session audit --manifest and
fable-session watch --manifest still accept 0.1.x manifests when pointed
at them explicitly. There is deliberately no short alias — nothing named
fs is ever installed.
python3 -m compileall -q src tests
python3 -m unittest discover -s tests -vUnit tests never call the Claude API or start real tmux sessions; subprocess
boundaries are mocked. --registry, --state-dir, and --claude-bin exist
so tests and fixtures never touch real host state. All test fixtures are
synthetic.
The brief template ships inside the package
(src/fable_session/templates/bounded-worker.md, declared as setuptools
package data) and is loaded via importlib.resources, so an installed
distribution renders briefs without the source checkout. The offline
install smoke proves it end to end — disposable venv under /tmp,
pip install --no-build-isolation --no-deps --no-index, installed console
commands, and one sanitized dry-run from outside the checkout:
python3 tests/offline_install_smoke.pyIn restricted CI environments the smoke accepts an explicitly pre-seeded
wheelhouse for its offline build backend: point FABLE_SESSION_WHEELHOUSE
at a directory containing a setuptools>=77 wheel downloaded in an
earlier, clearly network-allowed step; the install phase itself still runs
with --no-index --no-build-isolation --no-deps.
The public-readiness check scans the tracked tree (naming, notices, no internal identifiers, no credential-shaped literals), and its full-history sibling applies the same policy to every tracked blob of every commit reachable from HEAD; both run locally and in CI:
python3 tests/public_readiness_check.py
python3 tests/full_history_readiness_check.py0.3.0b1 (public beta 1; the matching GitHub prerelease tag is
v0.3.0-beta.1):
- optional registry key
max_budget_usd: an enforceable Claude budget passed as exactly one--max-budget-usdargument, printed in the summary and recorded as structural manifest metadata; - command-length/privacy repair: prompt payloads moved off the tmux
command line into hash-verified run-scoped 0600 payload files (brief via
placeholder substitution, task via stdin), fixing the
command too longlaunch failure for large task/brief pairs; - privacy-safe usage reporting: bounded
modelUsageaggregates (tokens, web-search requests,total_cost_usd) inaudit --format json, audit text output, and terminalwatchevents — explicitnullinstead of invented zeroes, and malformed usage never falsely proves purity; - the three deprecated 0.1 console aliases are removed after their one migration release;
- public packaging metadata (SPDX MIT, README long description, honest classifiers), pinned CI actions, a full-history readiness scan, and a tag-triggered attested prerelease workflow.
Small, focused contributions are welcome — the repository is public:
open an issue or PR at the official repository. Keep changes test-first
(python3 -m unittest discover -s tests), offline, stdlib-only, and
within the documented guarantees — anything that weakens the no-fallback,
permission, secret-scanning, or evidence-integrity contracts (or tries to
bypass safeguards) will not be accepted. Security reports:
SECURITY.md, never a public issue.
Installation into ~/.local/bin on real hosts, systemd units/timers or any
other scheduling, cron jobs, multi-host/fleet distribution, retry or
notification automation, host-wide or multi-lane watching, and any form of
safeguard circumvention (which is out of scope permanently, not just for
now).