Skip to content

Latest commit

 

History

History
575 lines (496 loc) · 28.4 KB

File metadata and controls

575 lines (496 loc) · 28.4 KB

fable-session reference

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.

Commands

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 --follow mode 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.

Dry run (default — starts nothing)

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.md template (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.

Explicit launch

fable-session run --project example-api --task /absolute/path/task.md \
  --launch --tmux api-example
  • --launch is required to start anything; --tmux names 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's tmux_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 --continue or --resume.
  • Model, effort, and permission mode are pinned from the registry; --fallback-model and --dangerously-skip-permissions are never emitted (config accepts only fallback = "stop" and an allowlisted permission_mode; bypassPermissions is 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 (profile context_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.payload inside 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 (-p with 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's command too long: the tmux command stays short no matter how large the (bounded) payloads are.
  • If configured, the registry's max_budget_usd is passed as exactly one --max-budget-usd argument, shown in the launch summary, and recorded in the manifest; with no budget configured, no budget flag is emitted and the manifest records max_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 --verbose under a shell-free capture wrapper (capture.py, executed by absolute path — no bash -c, no pipeline, no redirection strings). The wrapper creates the run-scoped stream file <run-dir>/stream.jsonl exclusively (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 terminal result event — lands byte-for-byte, and propagates Claude's exit status. The manifest's expected_transcript_path names this stream (what fable-session watch and fable-session audit consume); native_transcript_path separately preserves Claude's native session JSONL, which never contains the terminal result and 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.

Launch identity and run state

  • 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 (mkdir with exist_ok=False, retried with fresh entropy on collision) and a pending manifest is written before tmux starts. If reservation or the pending write fails, nothing is launched.
  • After tmux new-session succeeds the manifest is atomically updated to launched; if tmux fails it records failed. If a post-launch update fails before its publish rename, the pending record 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 canonical runs/ 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_NOFOLLOW from the filesystem root. A symlink anywhere in the state path — including the state directory itself and its runs child — is rejected as a StateError; symlinked state directories are unsupported by contract (point --state-dir at 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 an os.replace publish relative to the trusted run-dir FD. Concurrent writers are last-writer-wins; a reader can never observe a partial or mutable-after-publish manifest.json. A crashed writer may leave a stale .manifest-*.tmp, which other writers never adopt or delete.

Post-run model audit

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 json

The 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.model values from assistant entries, deduplicated by unique message.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 (—/null when unprovable, which forfeits PURE);
  • synthetic messages — <synthetic> entries are counted separately and never prove a serving model;
  • auxiliary models — result modelUsage keys 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 modelUsage across all models: input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, web_search_requests, and total_cost_usd — bounded numbers only, never prompt, result, or tool text. When modelUsage is missing, or there is no single trustworthy terminal result, usage is an explicit null (text output says usage: unavailable) — zeroes are never invented. A structurally malformed modelUsage (wrong types, negative or non-finite numbers) additionally sets the reason code malformed_model_usage and forfeits PURE: corrupt optional usage never falsely proves model purity;
  • terminal-result completeness — a completed transcript carries exactly one top-level type="result" event with subtype: "success" and boolean is_error: false. A missing result (interrupted stream), more than one result (concatenated runs — none is silently chosen; result is null), 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_reason is 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 exactly R, 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 than R, or a fallback/refusal event identifies a different serving model. A later return to R stays 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 match R or 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.

Exact-lane session watchdog

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 10

Inputs 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", boolean is_error: false); terminal. When the result carries a structurally valid modelUsage, the event includes the same bounded usage aggregate 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 bounded usage aggregate 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 — --follow only, 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: --once scanned with the lane in progress or successfully completed; --follow exited on the successful terminal result.
  • 1 — the lane reached a confirmed non-success terminal state: completed_error, completed_ambiguous, or interrupted.
  • 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.

Configuration

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.5

permission_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 file format

# 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.md

Goal, 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.

Privacy and metadata

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.

Migrating from claude-context-tools 0.1.x

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:

  1. Copy your registry yourself: cp ~/.config/claude-context/projects.toml ~/.config/fable-session/projects.toml (or pass --registry explicitly). The file format is unchanged.
  2. Old run state stays where it is and stays readable: fable-session audit --manifest <old path> and fable-session watch --manifest <old path> accept 0.1.x manifests when you point at them explicitly — nothing keys off the manifest's tool string. New runs write only under the new state directory (or your --state-dir).
  3. 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.

Development

python3 -m compileall -q src tests
python3 -m unittest discover -s tests -v

Unit 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.py

In 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.py

Release notes

0.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-usd argument, 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 long launch failure for large task/brief pairs;
  • privacy-safe usage reporting: bounded modelUsage aggregates (tokens, web-search requests, total_cost_usd) in audit --format json, audit text output, and terminal watch events — explicit null instead 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.

Contributing

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.

Out of scope

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).