Skip to content

Session layout — where a run lives on disk

A single Tilth run has artifacts on disk in two distinct places: the harness side (under ~/.tilth/sessions/<id>/) and the target repo side (a branch and a worktree admin entry inside the repo's .git/). This split is deliberate.

Filesystem trees for one Tilth run: HARNESS SIDE under ~/.tilth/sessions/<id>/ holds workspace/, events.jsonl, summary.json, checkpoint.json; TARGET REPO SIDE under ~/projects/tilth-demo/.git/ holds refs/heads/session/<id> and worktrees/workspace/. A sage-green arrow labeled 'git worktree binds these' connects the workspace/ on the left to the worktrees/workspace/ admin entry on the right.

One session, two locations. The sage-green link is the git worktree registration that binds them.

The agent's working directory sits inside ~/.tilth/sessions/, but every git operation that worktree performs reads and writes the target repo's .git/. That's how git worktree add works — the worktree directory can live anywhere on disk; its git database is the repo it was created from. workspace.py:create_worktree runs git worktree add <target> -b session/<id> with cwd=source (via ensure_worktree, which reuses an existing worktree on a re-entered run), which registers the worktree under the target repo's .git/worktrees/ and creates the branch in its refs.

Why a worktree, not just a branch

A branch is only a pointer; checking one out happens in place, in the repo's single working tree. If Tilth ran the agent on a plain session/<id> branch in your repo, a 10–60-minute unattended run would commandeer your HEAD and overwrite your working files as it edits — clobbering any uncommitted work and locking you out of your own checkout until it finished. A worktree instead gives the agent its own working directory (with its own index and HEAD) on that branch while sharing the repo's object store, so your checkout stays pristine and usable throughout, and commits still land on session/<id> in the shared .git/ for you to review and merge like any other branch. (This is workflow isolation, not a security boundary — the agent still has bash and can reach back up the filesystem; real process isolation is tracked in #13. See Safety guards → Worktree isolation.)

Why the working tree lives in Tilth's data dir, not in the target repo

A session has more artifacts than just the worktree — the rest of sessions/<id>/ (events log, summary, checkpoint, plus the run's durable state — task-status.json, progress.txt, and the per-task ledger/<task_id>.jsonl files) all belong to one run. Co-locating them under one directory means one logical container per run, and tilth reset only has to walk one tree on the harness side.

The flip side: the target repo stays pristine. Tilth never asks you to add anything to your .gitignore, and never drops a .worktrees/ directory at the root of your project. The only thing it writes into the target repo is the branch and the worktree admin entry — both reversible with one git worktree remove --force + one git branch -D. If you delete your Tilth clone entirely, no harness directories are left behind in your project. tilth reset handles both halves cleanly in one command; see Resuming & resetting.

Implications worth knowing

  • ls in the target repo won't show the worktree. If you're looking for "where is the agent editing right now," look under ~/.tilth/sessions/<id>/workspace/, not in the target repo.
  • Branches accumulate in the target repo, not in Tilth. Every run leaves a session/<id> branch in the target repo's .git/refs/heads/. If you delete your Tilth clone without resetting first, those branches stay behind in your project. Clean them up the same way you would any feature branch (git branch -D session/<id> or tilth reset before you blow Tilth away).
  • Multiple concurrent sessions against the same target repo are fine. Each gets its own sessions/<id>/workspace/ directory on the left and its own branch + admin entry on the right. Git is happy to host many worktrees off one repo.
  • The admin entry is the link. If the working tree directory under Tilth gets removed manually (e.g., rm -rf), the .git/worktrees/workspace/ admin entry becomes stale; git worktree prune cleans it up. tilth reset does this correctly.

Event types

events.jsonl is the append-only audit trail — one JSON object per line, {ts, type, payload, ...}. The canonical list lives in tilth/session.py's module docstring; this table is the reader's-eye summary. The Visualizer column notes whether tilth visualize renders a dedicated card for the type or falls through to a generic block.

Event Emitted when Key payload Visualizer
session_start A session begins (worktree created) source, phase: "run", worktree, branch, worker_model, evaluator_model, base_url card
session_resume tilth resume woke a session last_stop, retried, pending, unwound_commit card
archived tilth cleanse retired the session (worktree + branch removed, dir kept) branch, worktree
context_reset A new task starts; messages rebuilt from disk task_id card
prompt_assembled A user message is assembled, pre-send role (worker | evaluator), iter, content (capped)
memory_load Memory channels loaded into a prompt per-channel present/chars/truncated/sha256_8
model_call Any model call returns (one event per attempt) prompt_tokens, eval_tokens, cached_tokens (⊆ prompt), reasoning_tokens (⊆ eval), cost (USD), tokens_used_total, phase (evaluator; worker omits it), attempt (evaluator), finish_reason, reasoning, health (ok | provider_error | empty), call_attempt, and when present model / provider / response_id / health_detail / retry_backoff_seconds card
nudge The harness injected a corrective user message iter, kind (no_case), streak, content card
tool_call The model invoked a tool (incl. submit_case) tool, args card
tool_result The harness answered a tool call tool, result card
pre_tool_block pre_tool vetoed a tool call tool, reason card (special)
hook_run A lifecycle hook ran hook, outcome, tool, reason?
case_parse_error A submit_case couldn't be parsed iter, error, raw_tool_calls
evaluator_verdict The evaluator returned a verdict verdict, rejection_category, concern, evidence, next_step, parse_failed? card
evaluator_parse_error A submit_verdict couldn't be parsed (per attempt) attempt, error, raw_tool_calls
ledger_appended An entry was appended to a task's ledger task_id, iter, verdict_summary
commit A task's work was committed to the branch task_id, sha card
task_done A task was accepted (the evaluator accepted the case + diff) task_id card
task_failed A task could not be completed reason ∈ {iter_cap, evaluator_cap, provider_failure, no_case} card
stop The run terminated reason ∈ {all_done, wall_clock, token_cap, iter_cap, evaluator_cap, provider_failure, no_case, interrupted, error} card

The full per-entry payload (including the OTel-shape trace_id / span_id fields every task event carries) is documented in tilth/session.py. Per-task ledger entries live in ledger/<task_id>.jsonl, not in events.jsonlledger_appended is only a pointer; see The worker↔evaluator dialogue.

See also