Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Hephaestus

Hephaestus, heph at the prompt, is a coding agent that runs in your terminal and never touches your files without showing you first.

You describe a change. A model reads your project, edits a private copy of it, runs your project's own checks, and hands the result back as a set of hunks, each one labeled with the reason the model gave for making it. You accept the hunks you want, reject the ones you do not, say why, and the model tries again with your words in front of it. What you accept remains private to that conversation until /commit; what you reject never publishes.

That is the whole idea, and everything else in this book is a consequence of it:

  • The model works in a shadow worktree based on committed upstream plus that conversation's accepted private state. Dirty checkout files are not imported, and reviewed changes publish only with /commit.

  • Reads are silent. Edits are visible as they happen but need no approval, because review stands between them and your files. Only commands that reach outside the worktree, a shell command or a fetch from the network, stop to ask, and a prompt says exactly what it is asking and what saying yes will remember.

  • Acceptance gates, commands you configure such as cargo test, run on what the model changed before you are asked to look at it. A failing gate goes back to the model, not to you.

  • Every turn is recorded: the prompt, the model's words, each tool call with its parameters and result, what it cost. A turn you interrupt, or leave unreviewed, is there when you come back.

Heph talks to Claude models, directly through the Anthropic API or through AWS Bedrock, and to anything that speaks the same protocol.

How to read this book

Getting Started takes you from nothing to a reviewed, applied change in three short chapters. Read it in order.

Using Heph explains each part of a session as you will meet it: the turn, the screen, prompts and permissions, review, gates, models, and how the same engine runs without a screen from a script or a pipe. Read it once through, then come back to chapters as you need them.

Configuration is one chapter per section of the config file, with every key and its default.

Reference is the terse version of everything: every flag, key, slash command, tool, and exit code, and what the model is told about you.

This book describes what heph does today. The design it is built toward is larger; the last page says which parts of that design have not arrived, so you are not left looking for them.

Installing

Heph is a single binary, heph, built from the workspace with Cargo.

What you need

RequirementWhy
Rust 1.98+To build. Heph uses the 2024 edition and recent standard library.
git 2.40+Heph keeps its own git repository beside your project and merges with merge-tree.
rgRipgrep runs the grep and glob tools and lists files for search_replace.
A terminalThe interactive session takes the terminal over. heph exec works without one.
CredentialsAn Anthropic API key, or AWS credentials for Bedrock. The next chapter covers both.

Linux is where heph is developed and tested.

Build and install

From the workspace root:

cargo install --path crates/heph

That puts heph in ~/.cargo/bin. Check it:

heph --help

You should see the subcommands exec, wire-check, models, and tmux, and the flags the interactive session takes.

Optional tools

None of these are required, and heph does not check for them until you ask for one:

  • lazygit, vim-fugitive, or any tool that can stage and unstage hunks, if you would rather review changes there than in heph's own review screen (Review).
  • delta, difftastic, or another diff pager, to render diffs in the built-in review.
  • wl-paste or xclip, so Ctrl-V can attach an image from the clipboard.
  • tmux, for notifications in the status line and heph tmux next (Working in tmux).

The First Run

Heph needs one thing before it can do anything: a model to talk to. It finds one in your environment, and the first time it runs it writes down what it found so you can see and change it.

Credentials

Set one of these before you start:

An Anthropic API key, for the Anthropic API directly:

export ANTHROPIC_API_KEY=sk-ant-...

AWS credentials, for Claude on Amazon Bedrock. Anything the AWS SDK's default credential chain accepts works: AWS_ACCESS_KEY_ID and friends, an AWS_PROFILE, an SSO session, aws-vault exec, a container or web identity role. Heph also needs a region: AWS_REGION or AWS_DEFAULT_REGION, or a region on the profile.

aws-vault exec --ecs-server work -- heph

If both are set, the API key wins, because it is set for this one purpose and AWS credentials serve everything on AWS.

A session outlives a set of temporary credentials, so prefer a form that renews itself: --ecs-server above, an SSO profile, or a credential_process profile. Plain aws-vault exec work -- heph exports fixed credentials that stop working when they expire, and heph tells you when that happens. Bedrock Credentials sets up each of the three, step by step.

What the first run does

Run heph in a project directory. Before the screen appears you will see one line:

no config file; wrote /home/you/.config/heph/config.toml from the environment.
Edit it to change providers or models.

Heph looked for a config file, found none, found a provider in your environment, and wrote its decision to the user config file. The file names the provider it chose and lists, as comments, the model tiers it will use:

[providers.bedrock]
# region = "us-east-1"   # default: AWS_REGION, then the profile's
# profile = "work"       # default: the chain aws-vault exec feeds

# Tiers say which model does which work, and a profile is a set of tiers. With
# one provider, its catalog defaults are the default profile, today the three
# below. To change one, uncomment it: a [profiles.default] tier changes only
# the fields it writes, and the rest keep the defaults. `heph profiles` shows
# what is in force.

# [profiles.default.tiers.frontier]
# models = ["bedrock/us.anthropic.claude-opus-5"]

# [profiles.default.tiers.workhorse]
# models = ["bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"]

# [profiles.default.tiers.fast]
# models = ["bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0"]

The file is written once and never rewritten. It is yours to edit; the Configuration chapters cover every key. You can also leave it alone: with the tiers commented out, heph keeps using the defaults the comments show.

If you set up credentials after a config file already exists, nothing is written; heph uses the file. And if no credentials are found and there is no file, heph stops at once and says what it looked for:

Error: config: no provider is configured and none was found in the environment:
set ANTHROPIC_API_KEY for Anthropic, or AWS credentials (AWS_PROFILE or
AWS_ACCESS_KEY_ID) for Bedrock, or write [providers] in
~/.config/heph/config.toml

Check the wire

heph wire-check sends one small request through the whole stack, a tool call and thinking included, stores the reply, reads it back, and confirms nothing changed on the way. It is the quickest way to know that credentials, region, and model are all right before you spend a real turn:

$ heph wire-check
wire check passed
  provider     bedrock
  model        us.anthropic.claude-opus-5
  blocks       thinking, text, tool_use
  ...
  stop_reason  ToolUse
  cost         $0.0037
  ttft         1401 ms
  duration     2072 ms

--role quick checks the fast tier instead of the coding tier.

See what models there are

heph models lists every provider heph can talk to and every model in its catalog, with rates in dollars per million tokens and the context window:

$ heph models
anthropic (Messages, anthropic)
  claude-opus-5                  in 5.00  out 25.00  cache w 6.25  r 0.500  ctx 1000000
  claude-sonnet-4-6              in 3.00  out 15.00  cache w 3.75  r 0.300  ctx  200000
  claude-haiku-4-5-20251001      in 1.00  out  5.00  cache w 1.25  r 0.100  ctx  200000
bedrock (Bedrock, bedrock)
  us.anthropic.claude-opus-5     ...

These are the names you use in tiers and with --tier and --model. --all includes providers whose protocol heph does not speak yet, for completeness.

Your First Turn

This chapter walks one change from prompt to applied file. Everything in it is what you saw on the screen when you tried it; the chapters after explain each piece.

Pick a project with a test suite. From its root:

heph

The screen has three parts: the conversation on top, an input line in the middle, a status bar at the bottom.

 message ─────────────────────────────────────────────────────────────────────
>
──────────────────────────────────────────────────────────────────────────────
main | code | 0 tok | $0.00 | opus-5

The conversation starts as <unnamed> and gets a prompt-derived name as soon as you submit your first prompt. A background title may improve it, but failure keeps the local name. Each plain heph creates a fresh UUID; heph --continue resumes the latest branch conversation. The current mode (code, plan, or design) always appears in muted gray after the conversation and branch. opus-5 is the model that will answer, from the coding tier.

Ask for something small

Type a prompt and press Enter:

> add a pow(base: i64, exp: u32) -> i64 function to src/lib.rs with one test

Your prompt appears in the conversation under You:, and the status bar changes to working. The model reads what it needs, silently: the tools that only read (view, grep, ls, and the rest) run without a word. When it edits, you see the edit happen:

Adding `pow` after `mul` with the exact signature you gave, plus one test.

ok edit src/lib.rs

The sentence above the edit is the model's rationale. Heph asks the model to state what it is changing and why before every write, and keeps that sentence with the change. You will see it again in review.

A prompt to approve

If the model wants to run a command, it has to ask, because a command reaches outside the private copy heph gave it:

┌ permission ─────────────────────────────────────────────────────────────────┐
│exec   in worktree of ref main   (mirrors /home/you/project)                  │
│                                                                              │
│cargo test --lib auth                                                         │
│                                                                              │
│y  this call only                                                             │
│a  this session:    exec(cargo) args(*)                                       │
│   = any exec of cargo, any arguments; only cargo is run, and each argument   │
│     is passed to it literally, never through a shell                         │
│A  remember:        the same, written to project config [permissions]      │
│e  edit the pattern before a or A                                             │
│n  deny                                                                       │
│N  deny with reason                                                           │
└──────────────────────────────────────────────────────────────────────────────┘

Read it top to bottom: which tool, where it runs, the exact command, then your choices. y allows this one call. a allows this call and every future cargo test ... for the rest of the session. A does that and writes the pattern to the project's config, so it holds tomorrow too. The line beginning = is the plain-English meaning of the pattern, generated from it, so you know what you are agreeing to. Press a.

Gates

If the project has acceptance gates configured, they run now, on what the model changed, before you are asked to look:

-- running acceptance gates

A gate that fails goes back to the model with its output, and the model fixes it. You only see the change once the gates pass. (A fresh project has no gates; the Gates chapter shows how to add cargo test as one.)

Review

When the model is done and the gates are green, the review opens:

┌ review ─────────────────────────────────────────────────────────────────────┐
│Review: 01a0c63 on main, round 1 -- 1 file, 2 hunks                           │
│                                                                              │
│src/lib.rs                                                                    │
│hunk 1 (18-31)   edit #8fqrN6                                                 │
│Adding `pow` after `mul` with the exact signature you gave, plus one test.    │
│hunk 2 (56-64)   edit #8fqrN6                                                 │
│Adding `pow` after `mul` with the exact signature you gave, plus one test.    │
│                                                                              │
│Enter: review each hunk    q: reject all                                      │
└──────────────────────────────────────────────────────────────────────────────┘

This is the brief: every hunk, the tool call that made it, and the reason the model gave. Press Enter to go through the hunks one at a time:

[1/2] src/lib.rs  (+11 -1)
hunk 1 (lines 18-31)
why: Adding `pow` after `mul` with the exact signature you gave, plus one test.

@@ -18,9 +18,14 @@ pub fn mul(a: i32, b: i32) -> Option<i32> {
     a.checked_mul(b)
 }

+/// Return `base` raised to the power `exp`.
+pub fn pow(base: i64, exp: u32) -> i64 {
+    base.pow(exp)
+}
+
 #[cfg(test)]
 mod tests {

(a)ccept  (r)eject  (c)omment  (e)dit  (s)kip  (q)uit

Press a to accept the first hunk. For the second, press c and type a comment:

comment for the model (Enter sends, Esc cancels):
> make the test check pow(2, 10) == 1024 only

A comment rejects the hunk and tells the model why. When the last hunk is decided, heph keeps what you accepted, sends your comment to the model, and the model takes another round. A second review opens with only the new change in it. Accept it, and the turn ends:

-- turn done, 1 file applied, $0.67

The private worktree now has pow and its test. Nothing you rejected is in it. Heph prepares the publication candidate, passes its final gates, and offers to commit the shown message to the conversation's branch in a dedicated dialog. Choose Yes (y) to publish immediately, Yes, but edit the message (e) to open your editor first, or No (n) or Esc to keep it private. The publication lock stays held while you answer and through the editor and commit. Both publication actions need no further gate pass unless upstream changed. Your checkout stays untouched unless it has that branch checked out.

What just happened

  1. Heph copied your project into a worktree of its own and pointed the model at that.

  2. The model read files silently and edited the copy; each edit was committed to heph's private history with the rationale as its message.

  3. A shell command stopped to ask you, and your a became a session-wide approval.

  4. Gates ran on the model's changes.

  5. You reviewed hunk by hunk; a comment sent the model another round inside the same turn.

  6. What you accepted became private conversation state. The commit offer lets you publish it now or later with /commit; your uncommitted checkout changes were never imported.

Press Ctrl-D to open the exit confirmation, then press it again to leave. The conversation is saved; the next heph in this directory opens it again with everything on screen.

How a Turn Works

A turn is what happens between your prompt and the moment there is nothing left for anyone to decide about it. Understanding its shape makes the rest of heph predictable, so this chapter lays it out once.

prompt ──► rounds ──► gates ──► review ──► apply ──► done
              ▲                    │
              └────── feedback ◄───┘  (a rejection or comment sends the
                                        model another round in the same turn)

Rounds

A round is one request to the model and its reply. The reply is text, tool calls, or both. Heph runs each tool call in order, through the permission engine, feeds the results back, and the next round begins. Rounds continue until the model stops with nothing more to do.

Two things end the rounds early. You can interrupt (Interrupting). Or the model can hit its output limit; heph doubles the limit and re-sends once if a tool call was cut off mid-input, and otherwise ends the turn there with the text it has.

There is a ceiling of 200 rounds per turn, so a model that never says it is done cannot spend without end. Real turns are a handful of rounds.

What a tool returns stays in the model's context for the rest of the turn and is sent again on every round after, so heph keeps reads small. view returns a file whole only up to 400 lines; a longer file comes back as its outline, the functions, types, impl blocks, classes, or headings it defines with the lines each spans, and the model reads the range or the one item it needs from there (outline is also a tool on its own). The outline is found from the text by pattern, so it is approximate; it covers Rust, Python, JavaScript and TypeScript, Go, Ruby, shell, the C-like languages, Markdown, Norg, TOML, YAML, and Makefiles, and a long file of another kind comes back as its first lines. Norg includes star headings and TODO list entries, even in a flat file like TODO.norg; view(symbol) uses the title without its marker or status extensions. Code/metadata, macro definitions, comments, and examples are skipped. The scanner is approximate and does not interpret every Norg construct. The system prompt tells the model to read this way.

The worktree

The model never works in your files. When a turn begins, heph fetches the conversation's upstream from your repository, the tip of the conversation's own branch once it has one and of its base branch until then (Branches), and replays this conversation's previously accepted private state on top of it, as a rebase would, then checks the result out in the conversation's worktree. What your checkout has checked out does not matter: switching branches there changes no conversation. Commits that appeared on the upstream since the last turn, whether you made them or another conversation published them, come in underneath the conversation's own change; they never show up as something for it to undo. Where a new commit and the conversation changed the same lines, the commit's text stands and the model is handed the displaced text to reapply, which then goes through review like any other edit. Dirty and untracked files in your checkout are not imported.

Every write the model makes is committed on a private shadow chain with its stated reason. Accepted state remains in this conversation across turns, which lets the agent build and test cumulatively without exposing unfinished changes to your checkout or to another conversation. Anything else the worktree holds when a turn begins, such as a file an interrupted turn wrote or one you edited there by hand, is carried onto the new base the same way and shown in that turn's review; nothing in the worktree is silently dropped or silently adopted.

Agent-run Git commands in the worktree use your source directory's configured user.name and user.email, including global config and conditional includes. Heph copies these into the shadow when it opens and refreshes them on reopening, so commits and rebases need no identity setup. Heph's internal snapshots keep their own heph <heph@localhost> identity. Hooks and signing settings are not copied from your repository.

Paths your repository ignores are not copied, so target/ and node_modules/ are not duplicated; the Worktree chapter says how to link them in when the model's commands need them. CARGO_TARGET_DIR points at one directory per project shared by every worktree, so a build in the worktree reuses your dependencies.

Markdown formatting

For named .md or .markdown files, the model can use markdown_format instead of running a formatter command. Heph embeds yucky and formats at 80 columns without spawning a process or generating HTML. Formatting changes enter normal review; check = true reports differences without writing. The result lists file statuses and parser warnings, not whole documents. Directories and symlinks are refused, and every named file is checked before any write.

The tool is Level 1 in both modes, so plan turns do not offer it. Design turns can format files within their configured design paths. See the tools reference for its parameters.

Gates

When the model stops, the acceptance gates run on what it changed: commands you configure, such as cargo test, run in the worktree. A gate that fails goes straight back to the model with its output; the model gets a fixed number of tries per gate before heph shows you the review with the failure attached. You never review something the gates could have caught.

Review

Then the turn's diff, everything the model changed since your snapshot, is presented for review, each hunk with the rationale the model gave for it. You accept, reject, or comment, hunk by hunk, in heph's own screen or in lazygit or another tool you configure.

What you accept is committed as the accepted state. What you reject is recorded as a constraint: the model is told, in your words, what was refused and why, and told not to propose it again in this turn.

Another round, or private acceptance

If you rejected or commented on anything, the model gets another round in the same turn, with your feedback. It edits again; the gates run again on what you kept plus what it changed; a second review shows only the new change. This continues until you accept everything or the model gives up.

If you accept everything, heph advances only the conversation's private snapshot. Your checkout is unchanged. The next turn starts from that snapshot plus any commits that have appeared upstream in the meantime.

When the turn ends with accepted changes and no pending review, heph prepares a publication candidate, passes its final gates, and offers to commit its drafted message in a dedicated dialog. y publishes immediately without an editor; e opens the message in your editor first. Neither needs another gate pass if upstream is unchanged; n or Esc leaves the work private. The publication lock is held while you answer. See Committing.

/commit is the explicit publication transaction: it integrates the private snapshot onto current committed HEAD, runs the due gates again on that exact tree, runs your hooks and signing, creates the commit, and only then updates the corresponding paths in your checkout. An overlap with a newer upstream commit stays private and must be resolved before publication.

What ends a turn

A turn ends when:

  • the model said it was done, the gates passed, and there was nothing to review or you accepted it all (turn done);

  • you interrupted it (turn interrupted); its work is still reviewable;

  • the model hit its output limit twice, declined the request, or filled its context window (turn ended: ...);

  • the model re-proposed a rejected change twice in one turn, at which point heph applies what you did accept and stops asking.

A turn that ends with changes you have not reviewed, because you were not there to review them, or because a gate kept failing, leaves them in the worktree and marks the conversation as waiting. Picking Up Where You Left Off covers what happens next.

What a turn costs

Every round is one API call, recorded with its token counts and cost at the model's frozen rates. The status bar shows day/session/thread totals; the end-of-turn line shows the turn's input/output/cache token and USD breakdown. Estimated usage is labeled; absent usage is unavailable rather than a zero bill. Every request is sent so that the provider's prompt cache covers as much of it as it can: the tool definitions, the system prompt, the whole conversation before this turn, and every round of this turn before the current one are marked for caching, so a round pays the input rate for its own new tokens and the cache-read rate (a tenth of it; heph models shows the rates side by side) for everything before them. The cache figure on the status bar is the share of prompt tokens served that way; in a long turn it climbs past 90%.

What a round carries is mostly tool results, and a result read in an earlier turn is rarely needed again. At the start of each turn heph replaces the results of the turns before it with one-line stubs, [view src/auth.rs: 412 lines, elided], so a long conversation's rounds stay about the size of the current turn. The model reads again what it needs. Context says what is kept and why the default keeps no earlier turn whole.

The Interactive Session

heph with no subcommand opens the interactive session on the project in the current directory (-C DIR for another). It takes the terminal over until you leave by pressing Ctrl-D twice.

Conversations

Plain heph creates a fresh conversation every time, initially displayed as <unnamed>. Opening it or typing a draft starts no turn and calls no model. The UUID is its identity; empty conversations and drafts survive exit and crash. You can also start with heph 'fix the tests' or heph fix the tests. The initial prompt follows the ordinary submission and base-branch choice flow. Until submission succeeds it is kept as a draft.

The first submission saves a prompt-derived name immediately, before provider setup or turn creation. It uses up to three non-filler ASCII words, capped at 24 characters each, or a short prompt hash if none remain. Once the prompt is stored, the title role may improve that name asynchronously. Input, the main request, and review do not wait for it. Failure or cancellation keeps the local name; unusable output does not become session-N. /rename NAME takes precedence over a delayed title, even when choosing the local name. Reopening retries a pending title once; legacy unnamed conversations with stored input get a local name first.

heph --continue reopens the most recently active branch conversation. heph --continue NAME or heph --continue FULL-UUID opens exactly that existing conversation, with its history, draft, routing, and pending review. Missing or locked targets error, never create replacements. A startup prompt cannot accompany --continue; restored drafts are not automatically submitted. Fresh startup uses configured routing plus flags, not the previous ref's routing.

heph --continue-branch BRANCH resumes by an exact current or recorded publication branch in this checkout. One match opens directly; several prompt with session metadata and cached /summary content so you can choose. Enter or q cancels. It excludes startup prompts and other startup selectors. See Resuming by publication branch.

Inside a session, /stats shows the active conversation's full UUID as Session ID: at the top, even while accounting loads or fails. Use that ID with heph --continue FULL-UUID or /switch FULL-UUID to reopen it.

/refs lists labels, full UUIDs, activity, tips, and branches; /switch NAME or /switch FULL-UUID opens one while idle. Names are exact and case-insensitive; UUID prefixes and <unnamed> are not selectors. /new <prompt> starts another root with the same immediate, nonblocking naming flow, inheriting the active routing and base. /fork <prompt> keeps its naming-before-creation flow (Forking and Switching Conversations).

A long history is rendered one screenful at a time. A resize or sidebar toggle keeps the source position of a scrolled view; it does not rewrap the entire conversation. Live appends keep compatible displayed prefixes while replacement layout runs, and running-tool clocks do not replace the conversation with a loading pane. A genuinely cold layout may briefly say rendering history while the input and sidebar remain responsive; unavailable regions keep their neighbors. more below indicates that newer content follows the view, without measuring all preceding rows.

Draft durability

Every edit requests an asynchronous save. The input border shows draft pending, draft saved, or draft save failed; only saved input has been acknowledged as committed. Unsent intermediate versions coalesce. A crash can lose unacknowledged edits, with no fixed time bound while SQLite is busy.

Submission waits for the original prompt's save before starting its runner. Ref changes drain old-ref saves before releasing its lease. You can keep typing while these operations wait; Enter keeps your input, and Esc cancels the action. Normal quit freezes input and flushes for up to ten seconds. On save failure, R retries and Esc cancels. Failed quit also offers X to explicitly leave without saving pending changes. Reopening restores the last committed draft, never automatically submits it.

Tool-result cost diagnostics

/stats shows estimated generating-request shares and a separate tool-result lifetime section. It reports recorded result appearances, replay input, following output, and their retrospective lifetime estimate. Full results and later one-line stubs are captured as distinct prepared-request forms.

These are not extra charges or causal measurements. Costs are allocated by result bytes relative to the request's local input estimate, with cache pricing averaged within that request. Images are particularly approximate. Generating and following-output shares overlap; never add them to the provider total. Older requests have no snapshots, and estimated requests may not have reached the provider. There is no forecast of future replay rounds.

Work summaries

/summary reads a cached memory jogger: generation asks for 3-5 short Markdown bullets, at most 100 words, covering the current goal, key progress or decision, and the next step or blocker rather than an exhaustive recap. The UI renders Markdown, including in older cached summaries. Use /summary --refresh to replace an older, longer summary with the terse format.

It shows the covered turn and age, and warns if the ref has moved since generation. The cache survives reopening and renaming; a new or forked ref gets its own summary. An empty cache says how to request one.

Automatic generation runs after the first completed turn, then after five new turns or fifteen minutes since the last summary, whichever comes first. These checks happen only when a turn ends, not while idle. Pending reviews wait. /summary --refresh regenerates from the stored transcript while idle. Only one generation runs at a time, and interactive generation never blocks input. Leaving or switching away cancels it. heph exec also checks the policy and finishes a due generation before exiting.

Summaries use the summarize model role and record usage on the covered turn. They do not rewrite history, consume a conversation turn, or change what the agent remembers. /clear does not erase them. Failed refreshes keep the old cache; a delayed result is not installed if the ref has moved. Automatic refresh failures are logged at debug level; requested failures are displayed.

Input is bounded to 80,000 transcript characters, favoring recent turns, with per-block limits of 12,000 text characters or 1,500 tool characters. Omitted content is marked. Automatic updates also carry the previous summary when it belongs to the current chain. Output uses [context] summary_max_tokens, capped by the selected tier's token limit. This is a model-generated overview, not a replacement for reviewing the actual changes or test results.

The turn, on screen

You type a prompt and press Enter. From then until the turn ends, the conversation fills in as things happen:

  • While the model is being waited on, a dim * ... with moving dots stands where its reply will go: right after Enter, and again after each tool call while the next round is on its way.

  • The model's text streams in as it is written.

  • When the model thinks before answering, the thinking is folded to one line: * thinking: <its first line> (Right to open). Right or Left with the input empty unfolds and folds the nearest thing that can be: the last tool call, or the last reply that has thinking. Thinking is the model's scratch work; heph never treats it as the model's stated reason for a change.

  • Each tool call is one line: a mark, the tool, and what it is acting on. .. means the model is still sending the call; >> means it is running, with a clock beside it; ok means it finished; !! means it failed. A finished call's ok is magenta when you approved it and green when it ran on its own.

  • A running command streams its last few lines of output under its line. When it finishes they fold away; unfolding the call shows all of its output and its full result, colored as a diff when it is one.

  • For work with several steps the model keeps a task list with the todos tool. Its line names the step in progress and the count done, and the list shows beneath it, [x] done, [>] in progress, [ ] to come, without unfolding; Left folds it. Each call replaces the list, so the newest is the one to read.

  • Lines beginning -- are heph speaking: running acceptance gates, a gate that failed, a denied call, and the last line of every turn: turn done, turn interrupted, or why it ended, with the turn's cost.

Messages stand apart with a blank line above and below. Tool calls that follow one another, and -- lines that follow one another, run together as a list.

The status bar uses one row when everything fits. Otherwise it separates session information (conversation and branch, mode, held locks, activity, edits, and queued messages) from usage information (live tokens, cost, cache-hit share, context window usage, and model). Narrower terminals add rows between complete fields, not in the middle of a label. Oversized names and lock lists end in ... rather than pushing other fields out of view.

Muted totals like $8.00 / $1.00 / $4.00 show today's global local-date spend, this active process, and this ref's persisted history. Refresh messages stay in /stats and the sidebar; missing totals show -. Activity is working 0:41, running shell 0:12, approve?, asking, review, or done. 1 queued means a message is waiting for the next turn. /stats shows the full accounting breakdown at any width. locks: ... lists the worktree lease, publication ownership when held, and execution resources, including background jobs. Long lock lists end in ...; full acquisition and release names remain in scrollback.

context N% is muted gray like cache N%. It shows the current request's size as a percentage of the running model's context window, rounded to a whole percent, not the session's cumulative tokens. While waiting for a reply, it is an estimate; after the reply, it counts the provider's input tokens (including cached tokens) plus output tokens. It is hidden if the window is unknown or no request has run since opening, switching, or clearing the conversation. If an Ollama response omits usage counts, the percentage keeps the pre-request estimate; token and cost totals are labeled incomplete instead of actual zero.

done means a turn finished and you have not touched the pane since. Your next keypress or scroll clears it. The same test decides whether a notification is sent, so "done" on the bar and a notification elsewhere never both happen.

Dialogs

Dialogs default to 120 content columns, plus four columns for borders and spacing. They are centered with at least two columns of outer margin on each side, shrinking to fit narrower terminals.

Every dialog shows the current conversation name centered in its top border, with the dialog name on the left and metadata on the right. This includes permission prompts, confirmations, questions, review, tiers, stats, and help. Permission levels stay muted gray; review's stage and help's closing hint sit on the right. Long conversation names are shortened with ... to avoid colliding with either side. On a very narrow dialog the centered name is hidden if there is no room; side labels are shortened when necessary too.

Scrolling

New output keeps the conversation scrolled to the bottom. PgUp and PgDn scroll by ten lines, Shift+Up and Shift+Down by one, the mouse wheel by three, Home and End (with the input empty) jump to the top and bottom. While you are scrolled up, a yellow marker in the corner counts the lines below you; any new output arrives without moving your view, and End brings you back.

The wheel works because heph takes the mouse, which is also why dragging does not select text as it does at the shell: hold Shift while you drag, and the terminal selects as usual. When a permission prompt, a question, or a review is up, the wheel scrolls that instead of the conversation behind it.

Leaving

Ctrl-D opens an exit confirmation. Press Ctrl-D again to leave; any other key cancels, leaving your input untouched. A half-typed prompt is saved and comes back next time. If a turn is running, the confirmation warns you; leaving anyway cancels it, and the next start closes it as interrupted with its work kept for review.

/quit and /exit leave immediately, without the confirmation. After shutdown, heph restores your terminal and prints the active session's latest saved metadata to stderr:

Session ended
  ID: 0195e123-4567-7abc-8123-456789abcdef
  Name: fix-parser
  Base: main
  Branch: heph/fix-parser

The ID is the full UUID you can pass to heph --continue. An unnamed session shows <unnamed>; a base or publication branch not yet chosen shows <unset>. This summary also appears after leaving with two presses of Ctrl-D, but not on startup failure or unexpected event-loop termination.

/clear starts the model fresh on the same conversation: when the work so far is done and the next thing is a new thread, the next turn is sent nothing of the earlier ones, which makes every round of it cheaper. Live token/cache counters restart, but daily, active-session, and historical spend do not. The worktree, uncommitted work, and the history are kept; the screen empties, and the next heph shows the earlier turns again above a line marking the clear. Use /clear <prompt> to clear and immediately send the prompt; a refused clear sends nothing. A running turn or pending review must finish first. /help and ? on an empty input show the key bindings. /commit commits what the last turns applied (Committing). /plan, /design, and /code switch plan and design mode on and off (Plan Turns, Design Turns). Those are the slash commands so far; anything else answers not a command yet.

Forking and Switching Conversations

A conversation is a UUID-identified ref with an optional name and turn tip, and its own history, draft, and worktree. /refs lists live refs with their full UUID, activity, tip, and branch (or (base main) before the first commit). Unnamed refs display as <unnamed>; select them with /switch FULL-UUID. /switch NAME resolves exact names case-insensitively before exact full UUIDs. It opens the target while idle; missing or locked targets error.

/fork <initial prompt> branches from the current turn. /new <initial prompt> starts a root conversation without inherited history. Neither takes a name: Heph sends the prompt to the title role (the default fast tier), requires a one-to-three word result (ASCII letters or digits, separated by whitespace or hyphens), lowercases and hyphenates it, and adds -2, -3, and so on if needed. Invalid output, including more than three words or a generic placeholder, falls back to a bounded prompt-derived name. The title call is recorded on the new first turn. Both start from the current conversation's base branch (Branches).

The new ref becomes active and its initial prompt starts immediately. /fork waits for naming before creation. /new saves a prompt-derived name before starting its first turn, without waiting for the title provider. A failed or cancelled title request keeps that name; improvement retries once on resume. Explicit naming wins over a late automatic result. Naming changes no UUID, lease, worktree, history, routing, or publication branch.

Forking into another terminal

Start a separate process manually with an existing name or full UUID:

heph -C /path/to/project --fork SOURCE-UUID 'try another approach'

The child starts at the source's latest completed accepted checkpoint. A running turn or pending review is excluded; its live files are never copied. No safe checkpoint means refusal, not a new root as a fallback. The child inherits context, accepted private work, base, routing, and task settings, but not the source's publication branch, draft, jobs, pending review, or session approvals. Startup flags can override inherited routing and mode.

Omit the prompt to wait for input. The first submission names the child locally from its own prompt, not inherited prompts, before background title improvement. Its title request is recorded on that child's turn. The source remains running, and tmux is not required. Interactive recovery runs only for the selected leased conversation. Automatic tmux creation and a dedicated session switcher are not yet available.

Renaming the active conversation

/rename fix-flaky-tests
/rename

The first sets the active conversation's name explicitly; the second asks the title role (the default fast tier) for a 1-4 word kebab-case slug. It sees only the most recent 3,000 characters of your prompts, in chronological order, not assistant responses, tool results, skill payloads, or shell output before the prompt. The goal is to name what you want to accomplish.

An explicit name must match [a-z0-9][a-z0-9-]* and be unused by any other conversation, including archived ones. For automatic names, a conflict goes back to the model with a request to distinguish the name. Heph makes at most three requests, then adds -2, -3, and so on as a last resort. Each completed naming request is recorded on the latest turn. Missing user prompts, unusable model output, or a provider failure leaves the old name intact; use an explicit name or retry.

An explicit rename can also name an empty ref; bare /rename needs stored user prompts. An explicitly named empty ref keeps its name on first submission.

Rename affects only the active conversation. It waits for the turn and any pending review to finish. While naming, input remains editable and the conversation scrollable, but sending prompts and other session commands wait; help, sidebar controls, and quitting remain available. History, draft, armed skills, worktree path, and publication branch stay unchanged. The status bar, /refs, and the existing worktree's .heph-ref marker reflect the new name.

Naming a turn

The commands below name turns three ways: by place, #12, counting from the conversation's first turn as #1; by id; or by the first six or more characters of an id, as long as they name only one turn. A prefix that names two is refused with both listed. Ids and prefixes can name discarded turns from this conversation; #N names only its current or forward chain.

What /commit would publish: /diff

/diff

shows the difference /commit would publish now, against the conversation's branch (or its base branch before the first commit), open in the conversation like the model's diff tool. It starts with the turns it spans:

3 turns since heph/wrap-lines was last advanced (3f2a9c1)
  #12 01a0efa6  wrap the input prompt at word boundaries
  #13 01a0f201  the pager still splits mid-word
  #14 01a0f9b3  add the tests

/diff --stat shows that and the summary only. /diff #13 shows what one turn accepted, and /diff #13 src/pager.rs that turn's change to those paths. Where the branch changed the same lines as the conversation, /diff shows the paths and the conversation's displaced text instead of refusing. Work the worktree holds that no review has decided is not in /diff; /review shows that, and /diff says when there is some.

Going back: /back and /forward

/back        # one turn
/back 3      # three

moves the conversation back and puts the worktree's files back to the accepted files of the turn it lands on. The next turn is told which turns were rolled back, and the model is sent nothing of them. Your repository and checkout do not change; nothing private had reached them. If a turn you went back over was already published with /commit, heph says so: /back changes the conversation, not the branch, and reverting that commit is yours to do with git.

/back 3 --keep-code moves only the conversation. The worktree keeps the newer files, and /review (or the next turn) offers the difference as hunks, so you can undo the conversation and keep some of the code.

/forward undoes a /back: all the way, or /forward 1 one turn at a time, with the same --keep-code. Once a new turn starts, the conversation has forked there and /forward has nowhere to go; the rolled-back turns are still there by id for /diff and /restore.

Getting files back: /restore

/restore #12
/restore #12 src/pager.rs

puts the files as they stood after turn 12, all of them or the paths named, back in the conversation's worktree without moving the conversation. Then /review shows the difference as hunks: what you accept becomes the conversation's accepted state, which the next turn and the next /commit see.

/back, /forward, and /restore wait while a turn runs or a review is pending, and save the worktree's files before replacing any of them. Nothing they do loses work.

Background multi-ref runners, arbitrary-turn forks, archive, and /tree are still to come (What Is Not Here Yet).

Typing a Prompt

The input line is where you spend the session. It behaves like a small editor with the keys a readline user expects, and a few things of its own.

Editing

A line longer than the window wraps onto the next row between words (a word too long for a row is split where it must be), and the input grows to eight rows as you type and scrolls after that. Up and Down move by line of the prompt, not by row on screen.

KeyDoes
EnterSend
Shift+Enter, Alt+Enter, Ctrl-JNew line
Left, Right, Home, EndMove; Ctrl-B, Ctrl-F, and Ctrl-A do the same as in a shell
Ctrl-Left, Ctrl-Right, Alt-B, Alt-FMove by word
Up, DownMove between lines of a multi-line prompt
Backspace, DeleteDelete a character (Ctrl-H is backspace)
Ctrl-W, Ctrl-BackspaceDelete the word before the cursor
Ctrl-KDelete to the end of the line
Ctrl-UClear the input (or withdraw the newest queued message)
Ctrl-CClear the input, when no turn is running

Whether Shift+Enter is distinguishable from Enter depends on the terminal. Heph asks the terminal to report modified keys (the kitty keyboard protocol); terminals that support it, such as kitty, foot, WezTerm, and Ghostty, send Shift+Enter as itself. In others, use Alt+Enter or Ctrl-J.

A paste arrives whole, newlines included, as one edit. The first newline does not send.

Editing in your editor

Ctrl-E opens what you have typed in $VISUAL, or $EDITOR, or vi. Heph suspends its screen while the editor runs; save and quit, and the file's contents become the input. Quit without saving and the input is unchanged. A variable that is set but empty counts as unset.

Attaching files: @path

Type @ followed by a path anywhere in a prompt and the file goes to the model with your message, saving a tool call and a round:

> the parser in @src/parse.rs rejects @tests/fixtures/odd.toml; why?

Tab completes the path. The first Tab lists files under the worktree that match what you have typed, fuzzily and case-insensitively, like fzf: every character in order, closer matches and matches on the file name first. It fills in the best match; each further Tab cycles to the next. Hidden directories, target, and node_modules are skipped.

What the model receives depends on the file:

AttachedBecomes
A text fileA document block, titled with the path
A PNG, JPEG, GIF, or WebPAn image block the model can see
A directoryA listing of its entries
A file over 64 KBA note with the path and size; the model reads the part it needs with view

The size limit is [input] max_attach. The path may be anywhere, not only in the project: @~/notes/design.md works. Attaching a file gives the model that file and nothing else; it still cannot read a second file outside the worktree.

An @word that names nothing on disk is left as text, since @ is also how people write handles. In the conversation, attachments show as chips under your message: [src/parse.rs (2.1 KB)].

Images from the clipboard

Ctrl-V with an image on the clipboard saves it to a temporary PNG and puts @that-path in the input, so it goes with the next message. Heph reads the clipboard with wl-paste or xclip, whichever is on PATH, or the command in [input] clipboard_image. With text on the clipboard, Ctrl-V does nothing; paste text with your terminal's paste key as usual.

History

Up on an empty input recalls your previous prompt on this conversation, and each further Up goes one further back; Down comes forward again, ending at whatever you had typed before you started. The history is the conversation's own record of your prompts, so it is there across sessions too.

Ctrl-R, prompt search, is not here yet; it arrives with /search.

Drafts

Whatever is in the input is saved as you type, and comes back when you next open the conversation: after Ctrl-D, after a crash, after a reboot. Sending clears it.

Sending while the model works

Enter while a turn is running does not interrupt it. Each message joins a first-in, first-out queue instead of replacing earlier feedback. Messages drain one at a time, each starting its own turn after the previous turn and any review or publication pause finish. You can keep submitting small pieces of feedback while reading a document.

The status bar shows the number waiting, such as 3 queued. A Queued messages preview above the input shows the count and submitted text in queue order, including while you scroll the conversation or view only user prompts. It wraps at the terminal width and uses at most four content rows; overflow shows .... Up recalls the newest queued message into the input to edit; Ctrl-U withdraws only the newest message. Earlier messages stay queued. Resubmitting recalled text appends it at the back. The queue is session-local; draft recovery does not restore the entire queue after quitting or restarting.

The title's color says what the session is waiting for at a glance: green message when it is ready for you, blue while a turn runs and a message would queue, yellow commit pending while publication is busy and new prompts cannot start, and red when a prompt above needs you (approve or deny the call above, answer the question above, review the changes above).

This is deliberate: a message injected into a running turn would land between two of the model's steps with no way to know which. If you want the model to stop and do something else, interrupt first.

Slash commands

A prompt beginning / is a command to heph, not a message. /help, /clear, /quit, /exit, /plan, /design, /code, and /commit exist today; see Slash Commands.

Running Commands Yourself

A prompt that begins with ! is not sent to the model. Heph runs it as a shell command, shows its output in the conversation, and attaches that output to your next message, so you can say "look at what this prints" without asking the model to guess the command.

> !cargo test 2>&1 | tail -5
$ cargo test 2>&1 | tail -5
  test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured
  ...
> the last test above is flaky on my machine; find out why

The model receives, ahead of your prompt, the command framed as $ cmd, its output, and its exit code, with ANSI formatting removed. Standard error is shown in yellow unless the command supplies its own colors, and goes to the model too. A non-zero exit shows as [exit N]; a command killed by a signal as [killed].

Waiting and cancellation

Both ! and !! select execution resources as shell(<command>), in the original project's scope. Authorization is still yours; there is no agent permission prompt. Contention shows immediately without blocking input or scrolling. Esc or Ctrl-C asks before aborting a waiting or running command. Cancellation completes after supervised cleanup.

Only one bang command runs at a time in the session. A second stays in the input until the first finishes or is cancelled.

Color and terminal behavior

Heph renders ANSI colors (including 256-color and RGB) and text attributes in command output. Unless NO_COLOR is present in heph's environment, it requests color with FORCE_COLOR=1, CLICOLOR_FORCE=1, CARGO_TERM_COLOR=always, and a Git color.ui=always environment config entry. Existing Git environment config entries are preserved. Explicit command options such as git --color=never (for commands that accept it) can still disable color; programs that only check for a terminal may need their own --color=always option.

Commands still run with stdout and stderr captured through pipes and no stdin, not in an interactive terminal. Git normally skips its pager in this mode; use heph's conversation scrollback for long output. An explicitly invoked pager or another interactive program is not supported yet. Cursor movement, screen clearing, titles, hyperlinks, and other terminal controls are discarded, not executed. This is styled line output, not a terminal emulator.

It is you, not the model

A ! command is yours. It goes through no permission prompt, records no tool call, and runs outside any sandbox heph would put a model's command in. The output is your content, in your message, exactly as if you had pasted it.

Because nothing is recorded but the output, ! is also the way to do things the model is not allowed to: commit, push, or run something the permission patterns deny.

Where it runs

!cmd runs in heph's worktree for the conversation, the copy the model edits, because the conversation is about what the model built there and you usually want to show the model something about it. !!cmd runs in your own tree instead.

[input] bang_runs_in = "tree" swaps the two: ! in your tree, !! in the worktree.

You can run !ls immediately after opening a new session, before sending a prompt. If the selected worktree does not exist yet, heph prepares it in the background before running the command. Existing worktrees are left untouched. This starts neither a model turn nor a naming request, and never falls back to running the command in your checkout.

For a repository, a fresh worktree contains committed files from the conversation's branch or base. If no base has been selected yet, heph previews [git] base_branch, or the first offered branch (current branch first, otherwise the most recently committed local branch before remote-tracking branches). This does not settle the base; the first model turn still uses normal base selection. Uncommitted checkout files are not imported. Projects without a repository or without commits use the usual project observation instead. A resumed conversation's missing worktree is restored from its recorded snapshot, or its base if the turn never ended.

Setup errors appear in command output with exit code 125, without spawning the shell. Cancellation during setup waits for preparation to finish and prevents the command from starting. Commands targeting your own tree need no private worktree setup. Edits you make in the private tree before the first model turn are carried into that turn for review.

What it is good for

  • Running the tests yourself before asking the model to look at a failure: !cargo test -p heph-shadow 2>&1 | tail -30

  • Showing the model the actual state of something it would otherwise have to ask permission to inspect: !git log --oneline -5, !ls -la target/

  • Checking the model's work in the worktree before review opens, since the worktree is where it runs by default.

Output accumulates: several ! commands before one prompt all ride along with it, in order. Sending the prompt clears them.

Interrupting

Nothing in heph stops by accident. The two keys that stop things both ask first, and anything other than y dismisses the question at no cost while the work continues underneath.

Esc: interrupt the turn

Press Esc while a turn is running:

┌ confirm ──────────────────────────────────────────────────┐
│Interrupt this turn? (y/N)                                 │
└───────────────────────────────────────────────────────────┘

y interrupts. Heph drops the model's stream, or kills the running command's whole process group, and the turn ends with turn interrupted. What is kept:

  • The text the model had streamed so far.

  • Every edit already made in the worktree, on the private chain.

  • A killed command's abort, recorded as that call's result.

What is dropped: a tool call the model was still sending, since a call without a result cannot be sent back to the model.

An interrupted turn still gets a review if it changed anything. Gates are skipped, because the work is by definition unfinished, but the review opens, and what you keep from it is gated before it lands in your tree. If you would rather look later, Esc out of the review; the change waits in the worktree and the conversation is marked as waiting (Picking Up Where You Left Off).

A message sent while a turn runs is queued, never injected; if you want the model to change course, interrupt, then send.

Ctrl-C: abort the tool, keep the turn

Ctrl-C while a command is running asks a narrower question:

Abort the running tool? (y/N)

y kills that command and only it. The model sees aborted by the user as the command's result and carries on with the turn, which is what you want when a test run is hanging or a build is going to take longer than you are willing to wait for. The turn's other work is untouched.

Ctrl-C when no tool is running but a turn is asks to interrupt the turn, like Esc. When nothing is running, it clears the input.

Ctrl-D: leave

Ctrl-D opens a confirmation, even when nothing is running. Press Ctrl-D again to leave; any other key cancels. Holding the key does not confirm the exit when the terminal reports key-repeat events.

With a turn running, the confirmation also warns:

A turn is running. Leave anyway? It is closed as interrupted on the next
start. Press Ctrl-D again to leave; any other key cancels.

The second Ctrl-D cancels the turn and leaves. The next time heph opens this project, it finds the turn left in flight, closes it as interrupted, and, if the turn had changed anything, offers its work for review as soon as the conversation opens.

In a script

heph exec has no one to ask. Ctrl-C (SIGINT) interrupts the turn outright, and the exit code says so.

Permissions

Every tool call the model makes has a level, and the level decides whether it runs silently, runs where you can see it, or stops to ask. The levels are fixed by what a tool does; configuration can only make heph ask less about calls that would have asked, or refuse calls outright. Nothing can make a tool quieter than its level.

Authorization and execution locks are independent. Every matching lock applies after authorization, including auto-approved, session-approved, remembered, and --yes calls. Waiting does not count as a second tool attempt, and denial acquires nothing.

The levels

LevelNameWhat happensTools
0TransparentRuns silentlyEverything that reads: view, outline, ls, glob, grep, stat, log, show, diff, status, branches, blame, search_history, read_turn, which, man, job_status, config_view; todos; and ask_user
1NotifyRuns; you see it happenThe writers: edit, create, search_replace; web_fetch to a host on the allowlist
2ApproveStops and asksshell, background, job_kill; web_fetch to any other host
3EscalateAsks, and --yes alone is not enoughconfig_edit: live configuration, always once-only approval

Why edits do not ask: they land in the worktree, not in your files, and review stands between the two. Every edit will be read before it reaches you, so asking about each one would only add prompts without adding safety.

Why shell asks, and background and job_kill with it: a command can do anything, including reach outside the worktree, and a process the model started is one it can also stop (Background Processes). Why web_fetch asks about a host it does not know: a GET carries its whole URL to whoever runs that host, and only you can say whether that host is one you would hand a URL to.

Persistent denial guidance

Put a reason beside a deny rule to explain the constraint and the next step:

[permissions]
deny = [
    { pattern = "exec(npm) args(*)", reason = "This project uses pnpm. Use exec(pnpm) instead." },
]

An npm call is refused without a prompt or execution. Its error tool result returns the pattern and reason to the model, and the same explanation appears in developer status. Every matching deny entry is reported, user entries before project entries, so broad rules do not hide narrower guidance. Reasons reach the selected model only on denial; keep them concise and avoid secrets. They are ordinary feedback, not higher-priority instructions. The suggested pnpm call still needs its own permission check. This is separate from interactive N feedback for one call. See Permissions config for validation, layering, and multiline reasons.

The prompt

A Level 2 call that nothing has approved opens this. The dialog's border also shows a muted Level 2 (approve) label that stays visible while scrolling. Plain-text prompts show the level above the tool and call. The label gives the tool's own level, before any repeated-call escalation; the explanation of why it is asking remains separate:

┌ permission ─────────────────────────────────────────────────────────────────┐
│exec   in worktree of ref main   (mirrors /home/you/project)                  │
│                                                                              │
│cargo test --lib auth                                                         │
│                                                                              │
│  why: Run the suite to confirm the fix compiles and passes.                  │
│                                                                              │
│y  this call only                                                             │
│a  this session:    exec(cargo) args(*)                                       │
│   = any exec of cargo, any arguments; only cargo is run, and each argument   │
│     is passed to it literally, never through a shell                         │
│A  remember:        the same, written to project config [permissions]      │
│e  edit the pattern before a or A                                             │
│n  deny                                                                       │
│N  deny with reason                                                           │
└──────────────────────────────────────────────────────────────────────────────┘

This is an exec call -- one program run directly. Environment variables go in its optional env map, not through the env program or a shell prefix:

{"program": "cargo", "args": ["test"], "env": {"RUST_BACKTRACE": "1"}}

The overrides are shown before the command in the prompt. They apply only to that child, and their values are passed literally, without shell expansion. Heph's configured command environment and markers take precedence, and git redirection remains disabled. A saved exec(cargo) args(*) approval still covers the call: patterns match the actual program and arguments, not env.

A shell command (one that needs a pipe, a redirect, globbing, or chaining) looks the same, but the pattern it offers is the exact command, because no wildcard is safe over a string a shell parses.

It says exactly what it is asking, in this order:

  1. The tool and where it runs. For exec and shell, the worktree and which of your directories it mirrors. For web_fetch, that it is an in-process GET with no body and no headers of the model's choosing. If the call selects execution locks, a Locks if approved: build-output, test-db line follows, before the call. It lists every selected name in sorted order, without duplicates; no line is shown when no locks match. These are the locks for this call, not the broader pattern you might approve. Showing the prompt acquires nothing; approval allows the call to wait for and take this same set.

  2. The call, verbatim. The whole command or URL, not a summary.

  3. The model's context. why: shows the latest nonempty assistant text before the call in the same response. If none exists, Model thinking: shows the latest available reasoning text or provider summary before the call instead. With neither, the prompt says No model context available for this call. Encrypted or withheld reasoning is not shown. Thinking is display-only, not a stated rationale for write or commit attribution; no extra model request is made.

  4. Why it is asking, when the level alone would not have: today, the repeated-call anomaly below.

  5. The answers, each with the pattern it would remember and, on the = line, what that pattern covers in plain words. The sentence is generated from the pattern, so it is always literally true.

A long call (a commit message, a script) scrolls: Up, Down, PgUp, PgDn, or the mouse wheel move through it, a marker counts the lines out of view, and compact action keys stay at the bottom of the box whatever is scrolled. Long scope explanations scroll with the call. After e and Enter, both the shown scope and its expansion update; editing does not remove A from an otherwise eligible prompt.

Long thinking initially shows a preview of at most 400 characters and four source lines. t expands it; t again collapses it. Both switches return to the top of the dialog without approving the call. The dialog stays the same size, and compact approval keys remain visible while expanded text scrolls. Plain-text prompts show the full available context.

The status bar shows approve? while the prompt is up, and a notification goes out if you are looking elsewhere.

The answers

KeyMeaning
yThis one call.
n (or Esc)Deny. The model is told the call was denied and continues.
NOpen a reason input. Heph waits for Enter, then augments the denial returned to the model with your feedback.
aThis call, and every call matching the pattern, until you leave the session.
AThe same, and the pattern is written to the effective original project config under [permissions] promoted, so it holds in future sessions.
eEdit the pattern first. Type a new pattern and press Enter; the = line updates. Then a or A remembers the edited pattern.

The pattern heph offers is a generalization of the call: for an exec call, the program with args(*) (exec(cargo) args(*)); for a path, its directory and * (edit(src/*)). It is a starting point. e lets you narrow it (exec(cargo) args(test *)). A shell command is offered exactly, with no wildcard -- e can change the command, but the saved pattern is still one exact command.

A is offered only on ordinary Level 2 prompts with a project destination. An anomaly prompt (below) is about this turn and cannot persist approvals. config_edit has no rememberable scope: it always asks for this one change.

Promotion writes immediately to the original project's effective config file, not a shadow copy, and honors --project-config. Its heading names that path. A failed save is reported as session-only. Permission and network policy are reread before subsequent tool decisions, including in other running sessions. See Editing live configuration for approved edits to other settings and restart requirements.

web_fetch and hosts

A fetch is decided by its host, not by a pattern over the URL. Hosts on the allowlist fetch at Level 1: the call shows in the conversation as it runs, and nothing asks. The allowlist is the curated defaults (docs.rs, crates.io, doc.rust-lang.org, github.com, raw.githubusercontent.com, developer.mozilla.org, stackoverflow.com, en.wikipedia.org), plus whatever [network] allow and promoted add (Network). Any other host opens a prompt:

┌ permission ─────────────────────────────────────────────────────────────────┐
│web_fetch   GET, in process: no body, no headers of the model's choosing;    │
│    a redirect to another host asks again                                     │
│                                                                              │
│  https://docs.tokio.rs/tokio/topics/shutdown                                 │
│                                                                              │
│  why: Confirm how a Notify wakes every waiter before relying on it.          │
│                                                                              │
│  y  this call only                                                           │
│  a  this session:    docs.tokio.rs                                           │
│                      = any https:// GET to exactly docs.tokio.rs (not        │
│    subdomains), by web_fetch only; shell has no network                      │
│  A  remember:        the same, written to project config [network]        │
│    promoted                                                                  │
│  e  edit the host before a or A                                              │
│  n  deny                                                                     │
│  N  deny with reason                                                         │
└──────────────────────────────────────────────────────────────────────────────┘

a allows the exact host for the session; A writes it to [network] promoted. A subdomain is another host and asks on its own. e edits the host, not a pattern: there are no wildcards, because on a shared host like github.io every subdomain is someone else's server.

Two things always ask and are never remembered: a plain http:// URL, whose whole path is visible to every network hop, and a redirect. web_fetch follows redirects that stay on the approved host and stops at one that leaves it, telling the model where the page went; fetching the new URL is a new call, and the new host goes through all of this again.

[network] deny refuses a host before anything else is considered, session approvals included, and a [permissions] deny of web_fetch refuses every fetch.

Patterns

A pattern is tool(glob), a bare tool for every call of it, or exec(program) args(glob). The glob matches the call's principal: the command for shell, the URL for web_fetch, the path for a file tool.

For path tools (view, ls, glob, grep, stat, edit, create, search_replace, blame), * stops at / and ** does not, as in .gitignore. For URLs, * matches any text.

A shell or background pattern is an exact command with no wildcard: a wildcard there would cover the shell metacharacters that chain a second command (shell(cargo *) matching cargo x; rm -rf ~). To approve a program with a wildcard over its arguments, use exec, which runs the program directly with no shell: exec(cargo) args(*) matches cargo test --lib -- --nocapture and any other cargo call, and only cargo.

Patterns live in three places, checked in this order:

  1. deny in config. A match refuses the call at any level, before anything else is considered. The model is told which pattern denied it.

  2. approve and promoted in config, then approvals given with a this session, then the run's --allow-tools, --yes, and --yes-really flags. A match on a Level 2 call runs it without asking.

  3. Otherwise, the prompt.

deny is the one that can make a Level 0 or 1 tool stop: deny = ["edit(Cargo.lock)"] refuses that edit, and the model learns why.

The Permissions chapter has the config syntax.

The repeated-call anomaly

Heph looks for recent repetition without progress, not the total number of calls in a long turn. Its last 32 completed attempts include refused calls. Unchanged results, calls dominating recent activity, and short recurring cycles can trigger assessment. Cycles compare content states and outcomes, so repeated edit/test work is normal when the content changes. Rereading an unchanged file or alternating between two patches with the same failures remains suspicious. An unrelated edit is not proof that another retry will help.

At a soft checkpoint, a fast model examines the last six completed exchanges and completed calls from the current response. It reports progress, loop, or uncertain, with observations tied to those exchanges. Observed progress clears soft suspicion. Looping, uncertainty, timeout, invalid output, or unavailable routing brings up the permission prompt with the recent pattern and diagnosis. With defaults, an unchanged read can be checked before its third attempt. Commands get three completed attempts first, accommodating transient build-cache failures. Running jobs get eight unchanged polls first; elapsed time alone is not new information.

The default hard checkpoint of 12 applies to sustained unchanged outcomes or cycles, not twelve legitimate test runs spread across productive work. Frequent calls producing different information can need assessment without consuming this hard allowance. Running-job polling has a human threshold of at least 32. New evidence starts a fresh episode, and old suspicion expires after a gap.

y approves this call and clears the turn's current pattern history and allowances. n and N deny it, with optional feedback to the working model. These prompts offer no a, A, or scope editor. Neither stored approvals nor --yes clear a checkpoint; unattended calls are refused when a human is required.

The classifier never approves commands or overrides configuration denials. Ordinary permission checks still apply after a progress assessment. History is bounded, explicitly truncated, and excludes private reasoning; the pending call is marked as not executed. Checks have their own cost and audit record, not messages in the working conversation. The separate 200-round turn ceiling is unchanged. Configure the limits in Permissions.

What a denial looks like to the model

A denied call returns an error result naming the reason: denied by the user, denied by config pattern exec(rm) args(*), or, in a script with no one to ask, no one is present to approve this call; run heph interactively, or pass --yes for Level 2 tools. The model reads it as a failed call and decides what to do next; often it asks you, or explains what it would have needed.

Background Processes

shell runs a command and waits for it, which is right for a build or a test run and wrong for a dev server, a file watcher, or anything else that is meant to keep going. For those the model has three tools:

ToolLevelDoes
background2Starts a command in the worktree and returns a job number, at once
job_status0The job's state and the last lines of its output; with no number, a list
job_kill2Stops the job and everything it started

background asks as shell does: it runs its command through a shell, so its patterns are exact commands with no wildcard. background(npm run dev) remembers exactly that dev-server command. job_kill asks too, since stopping a process the model started is still stopping a process. job_status only reads.

Execution resources

A locked background call waits before spawning and before reporting a running job. The job's supervisor retains its resources after the launch call returns, until termination and cleanup. A long-lived job can block a later command in the same ref; there is no reentrant exemption.

job_status and job_kill do not reacquire a target's resources implicitly. If their own configured rules overlap the target's held set, they fail instead of waiting on themselves. /job-kill ID requests user-authorized cancellation independently of those rules, even while publication waits on the job.

What a job is

A job runs in the ref's worktree with the same environment a shell command gets. Locked jobs are children of a dedicated supervisor, in their own process group; the job registry owns their control channel. background waits a moment before answering so a command that dies at once (command not found, a bad flag) is reported as dead with its output, not as started. Its answer is the same report job_status gives:

job 0  running  0:02  npm run dev
  > vite v5.0.0 dev server running at:
  > http://localhost:5173/
job_status {"id": 0} reads more; job_kill {"id": 0} stops it.

Output is kept in memory, the last 2000 lines of each job, with stderr lines marked ! . The model reads a tail (50 lines unless it asks for more), as you would with tail. Nothing is written to disk and nothing streams into the conversation; a job's line in the conversation is the background call that started it, and its output appears when the model asks for it.

Ending

Every job ends with the session. Leaving heph requests process cleanup. For locked jobs, a surviving Linux supervisor also handles heph crashes by stopping and reaping descendants before releasing resources. Killing that supervisor itself or deliberately escaping supervision is outside this guarantee. Within the session, job_kill stops one, and its output stays readable afterwards. At most eight jobs run at once; the ninth is refused until one is stopped.

Review

Review is where you decide. Nothing the model wrote reaches your files until you have looked at it here, hunk by hunk, with the model's reason for each beside it.

Review opens when the model says it is done and the gates have passed, or when you interrupt a turn that changed something. The status bar says review, and a notification goes out if you are not looking.

The brief

Whatever tool you review in, the review begins with the brief, heph's own summary of the change:

┌ review ─────────────────────────────────────────────────────────────────────┐
│Review: 01a0c63 on main, round 1 -- 2 files, 3 hunks                          │
│                                                                              │
│src/auth.rs                                                                   │
│hunk 1 (23-31)   edit #4                                                      │
│Read the JWT secret from the environment so the key never lands in the        │
│binary.                                                                       │
│hunk 2 (58-70)   edit #5, shell #6 (cargo fmt)                                │
│Return anyhow::Result from validate_token so callers surface the error        │
│instead of panicking.                                                         │
│                                                                              │
│src/main.rs                                                                   │
│hunk 1 (10-25)   edit #7                                                      │
│Thread the Result through main and exit non-zero on a bad token.              │
│                                                                              │
│Enter: review each hunk    q: reject all    Esc: later                        │
└──────────────────────────────────────────────────────────────────────────────┘

Each hunk shows its line range in the new file, the tool calls that produced it, and the rationale: the sentence the model wrote before making the change. A hunk touched by several calls lists all of them, and joins their reasons. A hunk from a shell command shows the command. Heph finds the calls behind a hunk with git blame over the private chain of per-call commits, so the attribution is exact, not guessed.

The header names the turn, the conversation, and the round: a review that follows a rejection is round 2 of the same turn, and shows only what changed since round 1.

Three things can follow the brief:

  • Enter starts reviewing. Where that happens depends on [review] interactive: heph's own screen (The Built-in Review), or lazygit or another tool (Reviewing in lazygit and Other Tools).

  • q rejects everything and ends the turn. The model is told every hunk was refused, so it does not build on code that is gone, but it gets no round to guess why: a wholesale "no" without words is not an instruction. Say what you wanted in your next prompt.

  • Esc puts it off. The change stays in the worktree, nothing reaches your tree, and the review is offered again before your next prompt starts a turn, or when you type /review, or when the conversation next opens (Picking Up Where You Left Off).

The rules that hold everywhere

Only the change is reviewed. The diff is from the snapshot heph took when the turn began to what the model made, so your own uncommitted work is not in it and is never at risk.

Each hunk is accepted or rejected whole. To take part of a hunk, reject it with a comment saying which part, or edit it to exactly what you want (The Built-in Review); in an external tool, stage and unstage lines as the tool allows.

Rejected hunks come back as words, not as retries. A rejection, with or without a comment, tells the model in your words what was refused and why, and bars it from proposing the same change again in this turn. The details are in Rejections, Comments, and the Next Round.

Gates run again on what you kept. If you rejected some hunks and accepted others, the accepted state is gated before it lands: what passed the tests with all of the model's changes may not pass with half of them. A failure goes back to the model, and nothing is written to your tree.

Applying is a merge, not a copy. Accepted hunks are three-way merged onto your current files against the turn's starting snapshot. A file you have since edited by hand elsewhere merges. A hunk that overlaps something you changed by hand is set aside, your text stands, and the model is told to redo it against the current text.

Nothing undecided is ever taken as decided. A review you put off, or stop partway through, leaves the hunks you did not decide in the worktree, on top of what you accepted. They are not accepted, not rejected, and not forgotten: the next review shows exactly them, with their reasons. A new prompt does not absorb them either; its turn carries them along and reviews them with its own work.

After review

When the turn ends with accepted changes and no hunks left waiting, heph offers to commit. It prepares the publication candidate and passes its final gates before showing a dedicated dialog. y publishes the shown message immediately; e chooses Yes, but edit the message, using the manual /commit editor handoff. n or Esc leaves the accepted state private. The publication lock is held while you answer, so an unchanged candidate needs no gate rerun on Yes. A partial or empty review does not offer a commit.

Reviewing on demand: /review

/review opens a review now, with no prompt and no model round. It shows whatever the conversation's worktree holds beyond its accepted state: the review a turn left waiting, with each hunk's reason; or, when nothing waits, files changed in the worktree that no review has seen, whether a turn was interrupted before its review or you edited them there yourself. It is your pass, so gates do not run first and nothing is auto-rejected as a re-proposal. What you accept becomes the conversation's private state, ready for /commit; what you reject is removed from the worktree; the model is told the result and the turn ends.

/review --skip-gates is an explicit alias for the same gate-free manual review. Both spellings require the active turn to have ended; unknown flags or extra arguments are refused. It does not disable gates on a later publication. For intentionally failing tests, follow acceptance with /commit --skip-gates.

/commit refuses while the worktree holds anything a review has not decided, and says so; /review is the answer. --skip-gates does not waive review.

The single-hunk case

There is no shortcut yet for a change of one hunk: it goes through the brief and the review like any other.

The Built-in Review

With [review] interactive = "builtin" (the default), Enter on the brief walks the hunks one at a time in heph's own screen. It needs no other program and always works.

┌ review: main ───────────────────────────────────────────────────────────────┐
│[1/3] src/auth.rs  (+14 -3)                                                   │
│hunk 1 (lines 23-31)                                                          │
│why: Read the JWT secret from the environment so the key never lands in the   │
│     binary.   (edit #4)                                                      │
│                                                                              │
│@@ -23,7 +23,9 @@ impl Auth {                                                  │
│-    let secret = "s3cr3t";                                                   │
│+    let secret = std::env::var("JWT_SECRET")                                 │
│+        .expect("JWT_SECRET must be set");                                   │
│                                                                              │
│(a)ccept (r)eject (c)omment (e)dit (s)kip  (q) reject rest  (u)ndo  Esc: stop │
└──────────────────────────────────────────────────────────────────────────────┘

The header counts the hunks and names the file with its total added and removed lines. why: is the rationale, with the calls behind it in parentheses. The diff is colored: additions green, removals red, hunk headers cyan. Up, Down, PgUp, and PgDn scroll a long hunk.

The keys

KeyDoes
aAccept the hunk as it is.
rReject it without comment. The model is told it was refused.
cReject it with a comment. A line opens; type why, Enter sends, Esc cancels the comment (the hunk stays undecided). An empty comment is a plain rejection.
eEdit the file in your editor. The file opens as the model proposed it; what you save is the file as it will be applied. Quitting without saving leaves the hunk undecided.
sSkip. The hunk is rejected without comment, exactly as r.
qReject everything not yet decided and end the review.
u, BackspaceUndo: back to the previous hunk, its decision taken back. From the first hunk, back to the brief.
EscStop here. What you decided stands; the hunks you did not reach wait in the worktree for the next review. Before any decision, back to the brief.

After the last hunk the review closes and heph does the rest: keeps what you accepted, records what you rejected, and either applies or sends the model another round (Rejections, Comments, and the Next Round).

Stopping partway

Esc in the middle of a review is not a cancel. The hunks you accepted are kept, the ones you rejected are gone and become constraints, and the ones you never reached are left in the worktree on top of what you accepted, still unreviewed. The turn ends with the review waiting; the model gets no round meanwhile, but is told what was decided so far. /review picks the review up with exactly the undecided hunks, each with its reason; so does your next prompt, before its turn starts. Nothing is lost by stepping away, and nothing undecided is ever taken as accepted or rejected.

Editing a hunk

e is for when the model's change is nearly right. Heph copies the file as the worktree holds it, with every hunk of the proposed change in it, to a temporary file and opens $VISUAL (or $EDITOR, or vi) on the copy, suspending its screen while the editor runs. Save and quit, and the saved text is the file as it will be applied, committed on the turn's chain as user: edited in review.

Because the whole file is what you saved, an edit decides the file's other hunks too: what is in the saved file is what lands, whatever you pressed on them. If nothing else in the review was rejected or commented on, the turn ends there with your version applied; the model is not consulted again. If something was, the model's next round is told the hunk was EDITED BY THE USER; the file now holds their version, and it works from your text. Use e when you want the last word on a file, c when you want the model to try again.

A comment is worth more than a rejection

r tells the model that a hunk was refused. c tells it why, in your words, beside the reason it gave. The model converges in one round on a comment where a bare rejection often takes two or three, because it can see which reasoning was wrong. When you know why you are rejecting, say so.

Rendering diffs with a pager

[review] pager names a program that renders each hunk before it is shown, for the review heph exec runs on a plain terminal:

[review]
pager = "delta"

There the hunk is piped through it and its output shown in place of heph's own coloring; anything that reads a unified diff on stdin and writes to stdout works, and a pager that fails falls back to the plain coloring. The interactive session draws its own diffs and does not use the pager yet.

Reviewing in lazygit and Other Tools

If you already review changes in lazygit, vim-fugitive, or git add -p, you can review heph's changes there. Set the tool in config:

[review]
interactive = "lazygit"

Then Enter on the brief suspends heph's screen and opens the tool in the conversation's worktree, with the whole proposed change staged against the turn's starting snapshot. It is an ordinary git checkout with an index; the tool needs to know nothing about heph.

The protocol

Still staged means accepted. Unstaged means rejected.

Unstage the hunks you do not want. Edit files if you like, and stage what you edited. Then quit the tool. When it exits zero, heph reads the index back: every hunk of the brief that is still staged is accepted, every hunk that is not is rejected. The index is the verdict.

In lazygit: select the file, press Enter to see its hunks, move to a hunk and press Space to unstage it (or in the unstaged pane, to stage it back), q to quit.

Comments, after

Once the tool exits, heph's screen returns and asks about each rejected hunk in turn:

┌ review: comments ───────────────────────────────────────────────────────────┐
│2 accepted, 1 rejected. A comment on each rejection answers the model's       │
│stated reason; Enter skips.                                                   │
│                                                                              │
│> [1/1] src/auth.rs hunk 1 (23-31)                                            │
│      why: Read the JWT secret from the environment so the key never lands    │
│           in the binary.                                                     │
│                                                                              │
│@@ -23,7 +23,9 @@ impl Auth {                                                  │
│-    let secret = "s3cr3t";                                                   │
│+    let secret = std::env::var("JWT_SECRET")                                 │
│+        .expect("JWT_SECRET must be set");                                   │
│                                                                              │
│> put it in the config file instead, not the environment                      │
│                                                                              │
│Enter: next    Shift+Tab: back    Esc: done, with the comments so far         │
└──────────────────────────────────────────────────────────────────────────────┘

Each rejected hunk is shown with the model's reason and, for the one being asked about, its diff, so you answer with both in view. Type a comment and press Enter, or press Enter on an empty line to skip. Shift+Tab goes back to the previous rejection with its comment ready to edit. Esc finishes at once: the comments given so far stand, the current one included, and the remaining rejections go without. Up, Down, PgUp, PgDn, and the mouse wheel scroll a long list. When every rejected hunk has been asked about, the review is done and the turn continues as with the built-in review.

If nothing was rejected, there is nothing to ask; the change is applied.

A tool that exits non-zero

A tool that is killed, crashes, or exits non-zero has decided nothing. Heph resets the index, applies nothing, and says so; the review is offered again from the brief. A half-finished index is never read as a verdict.

The tools

interactive = RunsNotes
"builtin"heph's own screenThe default. The Built-in Review.
"lazygit"lazygitIts staging view; Space toggles a hunk.
"git add -p"git reset -q && git add -pEverything starts unstaged; answer y to the hunks you accept.
"fugitive"nvim -c Gstatusvim-fugitive's status buffer; - toggles staging, dp for hunks.
"$EDITOR"$EDITOR .Your editor on the worktree; stage with whatever git integration it has.
anything elseThat command, via sh -cRun in the worktree; must leave the index as the verdict and exit 0.

Whatever the tool, it runs in the worktree, never in your own tree, and your own index is never touched.

What the tool sees

The worktree's HEAD is detached, for the review, at a commit whose message is the brief: it has the turn's starting snapshot as its tree and its parent, so git diff --cached there is exactly the proposed change, the commit's own diff is empty, and git log shows your project's history beneath it. That is how the reasons reach a tool that has no idea heph exists: in lazygit, the top entry in the commits panel is heph review: 2 files, 3 hunks -- each hunk's reason below, and selecting it shows the brief in the main panel while you stage and unstage. Other tools show it wherever they show the log. The commit is heph's, not part of your history; it disappears when the review ends. The private per-call commits are under refs/heph/turns/<turn>/, out of the way. A tool that shows branches will show (no branch): that is expected.

Do not commit in the tool. A commit would move the detached HEAD and the verdict is read from the index; heph resets the worktree after every review regardless, so nothing you commit there survives, but it can confuse the read back.

Rejections, Comments, and the Next Round

What you say in review is what the model hears next. This chapter is about the conversation that a rejection starts, so you can steer it.

What the model is told

After a review with any rejection or comment, the model's next round begins with a message heph writes from your verdict:

Your proposed changes were reviewed. Here is the result:

file: src/auth.rs
  hunk 1 (lines 23-31): REJECTED
    your rationale: "Read the JWT secret from the environment so the key
      never lands in the binary."
    user: "put it in the config file instead, not the environment"
  hunk 2 (lines 58-70): ACCEPTED

file: src/main.rs
  hunk 1 (lines 10-25): ACCEPTED

Address the comments. The rejected hunks are constraints: do not re-propose
them in any form. The worktree already holds only what was accepted.

Each rejected hunk carries the model's own rationale back to it, and your comment beneath. The model learns which reasoning was refused, not only which lines, and that is what lets it converge: a rejection that answers a stated reason usually takes one more round where a bare "no" takes several.

Nothing in the message is a paraphrase. Your comment is quoted exactly; the rationale is the model's own sentence.

Constraints

Every rejected hunk becomes a constraint for the rest of the turn. Each is listed once, with your comment and the rejected text, at the end of the review message that sends the model back to work; that message stays in front of the model for every later round of the turn:

The user rejected the following changes in this turn. Do not re-propose them
in any form.

src/auth.rs, round 1 -- "put it in the config file instead, not the
environment"
  +    let secret = std::env::var("JWT_SECRET")
  +        .expect("JWT_SECRET must be set");

Long hunks are elided in the middle. A constraint lasts for the turn and dies with it: the next prompt may ask for the same thing deliberately.

Re-proposals

Before each review, heph compares every proposed hunk with the turn's rejected ones: same file, whitespace ignored, by the share of the rejected hunk's lines that reappear. Above the threshold ([review] reproposal_threshold, default 0.8) the hunk is a re-proposal. It is rejected before you see it, listed in the brief as [re-proposed (round N); rejected] and left out of the hunks you are asked about, and the model is told, in the constraints, "you re-proposed a rejected change. Do not." You never reject the same thing twice.

One exception, because a comment asks for a variant: a hunk rejected with a comment does not mark re-proposals in the round that answers it. "No doc comment" on a five-line function leaves four lines standing, which would otherwise look like the same hunk. You see the model's answer to your comment and judge it; from the round after, it is a constraint like any other. A rejection without a comment is a constraint at once.

After two re-proposals in one turn, heph stops asking the model: whatever you accepted is applied, the turn ends, and the status line says so. A model that will not take no for an answer does not get a third round to spend.

Rounds after a review

The next round runs in the worktree as it stands: your accepted hunks in place, the rejected ones gone. When the model stops, the gates run again on everything you kept plus what it just changed, and a new review opens showing only the new change, as round 2 of the same turn. The header says which round you are on.

This repeats until a review ends with nothing rejected and no comments, or you press q, or the re-proposal floor is reached, or you interrupt.

When the model gets no round

Some reviews end the turn instead of starting a round: the turn was interrupted, you pressed q on the brief, the review was a /review, you stopped the built-in review with Esc partway, or the floor was reached. The model still needs to know what happened, or it would carry on from its last message believing its rejected code is in place. So the same feedback is written into the turn for it to read next time, ending not with "address the comments" but with what happens instead:

The turn ends here. The worktree holds only what was accepted; the rejected
changes are not in place, so do not build on them. If the user wants them
done differently, their next prompt will say so.

A review stopped partway lists the hunks you did not reach as UNDECIDED and says the review is not finished; a file you edited with e shows every hunk as EDITED BY THE USER, since your saved file decided them all.

q on the brief is the one refusal that never gets a round: a wholesale "no" without words gives the model nothing to act on but a guess, and a round spent guessing is a round wasted. Say what you wanted in your next prompt. In the middle of the built-in review, q refuses the hunks you have not reached and finishes the review, which then follows the ordinary rules.

Gates after a partial acceptance

Accepting some hunks and rejecting others produces a state the gates have not seen: the tests passed with all of the model's changes, not with half. So before anything is applied, the gates run on the accepted state. A failure goes back to the model as a consequence of the rejection, with the gate's output and the constraints, and nothing is written to your tree until a later round passes. If the turn was interrupted, or the model has run out of rounds, the failure is shown to you with the review instead.

Hunks set aside at apply

You accepted a hunk, but the region it changes has changed in your tree since the turn began: you edited the file by hand while the model worked. Your text stands. The hunk is set aside, the file's other hunks still land, and the model's next round is told:

file: src/lib.rs
  SET ASIDE AT APPLY: this region changed in the user's tree after the turn
  began. The current text is below. Redo your change against it, or explain
  why it no longer applies.

followed by the current text of the file. Heph first writes that text into the worktree and commits it on the chain, so the model's next edit is against what is really there. No conflict marker is ever written to your files.

Acceptance Gates

A gate is a command that must pass before you are asked to review. cargo test, cargo clippy, npm test, a lint, a formatter check: whatever your project already uses to decide that a change is acceptable. Gates turn "the model says it is done" into "the model says it is done and the tests agree".

Gates, including final publication gates, participate in execution locks. Array commands match exec(program) args(...); shell strings match shell(<expanded command>). Matching happens after {files} expansion. Skipped gates acquire nothing. Gate deadlines exclude contention; ownership covers execution and supervised cleanup. After a gate passes, resources shared with the next executed gate remain held, preventing other sessions from interleaving operations on those resources. Skipped gates do not break retention. Failure or the end of the run releases ownership; if additional resources are busy, the transition releases the held set and retries rather than risking a deadlock.

Configuring a gate

Gates live in .heph/config.toml in the project (or the user config, for gates you want everywhere):

[[gates]]
name = "test"
paths = ["**/*.rs", "Cargo.toml"]
command = ["cargo", "test"]
retry_limit = 2

[[gates]]
name = "fmt"
paths = ["**/*.rs"]
command = ["cargo", "fmt", "--check"]

[[gates]]
name = "docs"
paths = ["**/*.md"]
command = ["mdformat", "--check", "{files}"]
KeyMeaning
nameShown in the status line and to the model.
pathsGlobs. The gate runs when the turn changed a matching path. Empty or absent: every turn.
commandShell string or direct-execution array, run in the worktree. Exit 0 passes. {files}: see below.
retry_limitHow many failures the model may fix on its own before you are shown the review anyway. Default 3.

An array's first string names the executable; the rest are literal arguments. command = ["cargo", "test"] runs directly and matches a lock rule such as exec(cargo) args(*). A string still runs through sh -c; use it for shell features, for example command = "cargo test && echo passed". Stdout and stderr are captured in both forms, so a direct-exec gate needs no redirection.

Arrays cannot be empty, start with an empty executable, or contain NUL bytes. {files} must be a whole argument, never part of the executable or another argument. See Gate configuration for the exact fields.

Gates run in config order. Project gates come after user gates. The first failure stops the run; later gates wait for the next attempt.

The globs use path semantics: * stops at /, ** does not, so **/*.rs means any Rust file at any depth.

Code, Design, and Mixed Publications

A gate may use modes = ["code"], ["design"], or both; omitted modes means unrestricted. Mode and path restrictions must both match. Empty lists and unsupported names are errors. Quick and recorded review turns are code work. File extensions do not decide the mode: code work on Markdown remains code. Design gates may run test suites if that suits your project.

[[gates]]
name = "tests"
modes = ["code"]
command = "cargo test"

[[gates]]
name = "design-check"
modes = ["design"]
command = "check-designs"

[[gates]]
name = "markdown"
paths = ["**/*.md"]
command = "mdformat --check {files}"

check-designs is your own project command, not a heph command. A design turn runs its design checks and matching shared checks. Retries and partial acceptance use that turn's original mode, even if the session mode changes. Resumed reviews retain their stored class and do not repeat completed gate passes. Manual /review keeps its existing gate-timing exceptions; newly reviewed unattributed work is code work.

At /commit, modes come from accepted unpublished work that remains in the candidate. Accept code, switch to design, then publish: code checks still run. Accept both modes before publishing: both sets run, each eligible entry once, against the combined candidate's matching files. Successful publication covers only that candidate; fresh design-only work afterward does not inherit code mode. Failed gates, hooks, cancelled messages, and refused branch updates retain pending provenance across restart. Navigation and forks follow snapshot content; restored code cannot become design-only work. A fully reverted separate code path no longer forces code checks; same-path rewrites retain both modes conservatively. Incomplete legacy provenance selects both modes with a diagnostic. Git hooks are unchanged.

Checking only the changed files

A formatter or linter usually needs to check only the files the turn touched. Write {files} in the command where they go, as the docs gate above does. Heph replaces it with the changed paths that match the gate's paths. In an array, a whole {files} argument expands to separate literal filename arguments. In a shell string, each filename is quoted for the shell. In either case, a name with spaces stays one argument. A gate without paths gets every changed path. Deleted files are left out, since there is nothing to check.

Every gate also gets the list in the HEPH_GATE_FILES environment variable, one path per line, for a script that would rather read it than take arguments.

If every file a {files} gate matched was deleted, the gate is skipped with a status line saying so. Run with no arguments, most formatters would check the whole tree. A gate that reads only HEPH_GATE_FILES is not skipped; it runs with the variable empty.

The list matches what is being checked: the turn's changes before review, what you accepted when the gates run again after a rejection, and everything a /commit would publish.

What the model sees

When a gate fails, the model gets its output and a short instruction:

The acceptance gate `test` failed. First determine whether the failure is
related to your changes, using the diagnostics and relevant code. If related,
fix it and rerun the check. If unrelated or uncertain, explain the evidence
and what is blocking verification. Do not change unrelated code, weaken or
bypass the gate, or claim it passed.

running 6 tests
test tests::divides ... FAILED
...

The model first investigates relatedness. It repairs failures caused by its changes; for unrelated or uncertain failures, it explains the evidence and the verification blocker instead of changing unrelated code or weakening the gate. This is an instruction, not a passing verdict: checks and retry limits still apply, and a failed publication gate still blocks committing.

If a gate fails during /commit, nothing is committed and heph automatically starts a new repair turn with that same feedback. Normal permissions, loop detection, gates, and review apply. After accepting the repair, run /commit again; heph does not retry publication automatically. Cancelling publication does not start a repair turn.

During a turn, the model edits, stops, and the gates run again from the first. Each gate counts its own failures; when a gate has failed more than its retry_limit, heph stops asking the model and opens the review with the failure attached:

Review: 01a0c63 on main, round 1 -- 1 file, 3 hunks
(gate failure, shown as it stands)
test:
running 6 tests
test tests::divides ... FAILED

You decide what to do with a change that does not pass: accept it and fix it yourself, reject the hunks that broke it with a comment, or reject all.

Gates and review

Gates run before review, on everything the model changed. If you then accept only some hunks, they run again on what you accepted, because that is a state the model never tested. A failure there goes back to the model as a consequence of your rejection, naming what you rejected, and nothing is applied until a round passes. See Rejections, Comments, and the Next Round.

Gates do not run on an interrupted turn before its review, since the work is unfinished by definition. They do run on whatever you accept from it before it lands.

Where gates run

In the worktree, as the model's exec and shell commands do, with the same environment: CARGO_TARGET_DIR points at the project's shared target directory, so a test gate reuses the dependencies you have already built and adds only the changed crates. A gate's output streams into the conversation under the -- running acceptance gates line as it arrives, so a slow suite is visible while it runs. Each gate may run for up to thirty minutes.

Gates run with no permission prompt. They are your commands, from your config file, and configuring them is the approval.

Plan turns

A plan turn makes no changes, so no gate is due. If a gate somehow fails on one, the model is not asked to fix it, since it has no tools to fix anything with.

Choosing gates

A gate is worth having when it is fast enough to run after every turn and strict enough that you would not accept a change that fails it. cargo check is a good first gate on a large crate where cargo test is slow; cargo test alone is right for most. A formatter check is worth adding once you have seen the model produce something you would have reformatted.

A gate that fails for reasons unrelated to the change, a flaky test, a network call, teaches the model to work around it. Keep gates deterministic.

Committing

Final publication gates select execution resources in the original project's scope, using their final expanded shell commands. Waiting is visible and deadlines exclude contention. /job-kill ID can stop a job that holds a resource the gate needs.

Reviewed changes remain private until you agree to publish them. When a turn finishes with accepted changes and no review left pending, heph prepares the publication candidate, runs its final gates, drafts a message, and asks whether to commit. The dedicated dialog shows the branch, paths, and drafted message.

Choose Yes (y) to commit immediately with that message, without opening an editor. Choose Yes, but edit the message (e) to open the same draft in $VISUAL or $EDITOR, using the manual /commit editor handoff. Choose No (n), or press Esc, to leave the work private. No commit is offered for an empty review, a pending review, or a directory without Git. Queued prompts wait until you answer and publication finishes or is declined.

The publication lock stays held while the dialog is open and through the editor and commit. Both publication actions recheck upstream without releasing ownership: when nothing changed, the prepared candidate needs no further gate pass. Other heph publications wait, though turns and reviews can continue. Git operations outside heph can still move upstream; that requires rebuilding the candidate and rerunning its gates before committing. An overlap starts an automatic repair turn in the same session rather than guessing or asking you to start another turn.

To publish later using the editor-based workflow, type:

/commit

Heph shows preparing commit immediately and changes the input title to yellow commit pending until publication finishes or fails. Preparation, branch naming, gates, message drafting, waiting for another publication, and housekeeping run in the background; you can scroll and edit input while they run. New prompts, shell commands, and slash commands other than /help, /sidebar, and /quit wait until publication finishes. Unsent prompt or shell input is kept. The editor and Git take over the terminal only when the candidate is ready.

The work goes to the conversation's own branch, never to whatever you have checked out. The first /commit on a conversation names that branch from the work and says so:

-- publishing to new branch heph/wrap-lines

Heph integrates the conversation's accepted work onto its upstream (the tip of its branch, or of its base branch before the branch exists or while an explicit /base rebase is pending; Branches), builds a private index, reruns every due acceptance gate on the exact candidate tree, drafts a message, and opens it in your editor. Save to commit; save an empty message, as in git, to abort. The result shows as a -- line naming the branch:

-- committed 3f2a9c1 on new branch heph/wrap-lines (not checked out): Wrap long prompt lines

The gates run inside heph, each shown in the conversation as a gate call with its output, exactly as a turn's gates are: nothing scrolls past on a bare terminal. Only once they pass is the terminal handed over, to your hooks and your editor. A failing gate refuses the commit and automatically starts a new turn with the gate's name and output, asking the model to fix it. The repair uses the usual permissions, loop detection, gates, and review; your unfinished input stays in place. After reviewing the fix, heph offers to commit again; publication still requires your Yes. You can also run /commit explicitly. Cancellation and errors preparing or drafting the commit do not start a repair turn.

/commit Fix the off-by-one in the pager

commits with that message, no drafting and no editor.

The editor holds a separate publication lock, not the shared shadow lock. Other conversations can start turns and review while you edit the message. Another publication waits without a timeout. After acquiring publication ownership, heph rechecks committed upstream under the shadow lock. If upstream or the destination moved, it rebuilds the rebased candidate, releases publication ownership, reruns due gates, and reacquires ownership to check again before committing. Git still refuses the ref update if the destination branch moves after that check. Shadow lock waits also have no timeout; dead holders are recovered automatically.

After /base changes, /commit publishes the rebased state with the new base as parent and clears the pending rebase. Changes introduced by the base are included in checkout protection and final-gate selection. A rebase can publish new ancestry even when no file content changed.

Intentionally failing tests

To publish a reproducer or deliberately failing regression tests, stop any running turn and accept the changes with /review --skip-gates. Manual /review already skips gates; the flag makes that intent explicit. Then use:

/commit --skip-gates test: demonstrate the failing case

Without a message, /commit --skip-gates drafts one and opens your editor as usual. The flag must precede the message. Use -- to begin a literal message that starts with an option, for example /commit -- --skip-gates is documented. Unknown or repeated leading options are refused.

This bypasses only heph's final acceptance gates for this invocation, including when upstream movement requires rebuilding the candidate. No gate command runs and no gate-repair turn starts. Review, publication locks, conflict checks, branch-update checks, checkout protections, signing, and Git hooks still apply. Git hooks can still refuse the commit.

Progress, editor comments, and the committed result say gates skipped. The publication audit and any continuation receipt retain that policy without recording passing-gate coverage. A later /commit, automatic post-review offer, or next publication unit runs gates normally; no configuration or sticky session setting changes. Turn-level gates are unchanged, so use manual review to accept intentional failures rather than asking the model to repair them.

What goes in

The commit is the difference between the conversation's upstream and its accepted work: everything review accepted since the branch was last advanced, however many turns that spans. One /commit is one commit. The per-call history behind it stays in heph's shadow repository, so /blame still knows why each line was written, and whether you later merge, squash, or rebase the branch is your business and loses nothing heph keeps.

If the upstream moved since the last turn, heph replays the conversation's change on top of it in the private worktree first, as a rebase would. A clean result is gated as a whole before publication. An overlapping change remains private while heph automatically starts repair in the current session. The model receives the displaced private text and integrates its intent with the latest upstream. Repair edits go through permissions, acceptance gates, and review; after you accept them, heph offers publication again. It does not reuse your previous consent to publish new repairs. If the intent cannot be reconciled safely, the model should ask you a specific question.

The original accepted snapshot and pre-repair worktree are preserved. Unreviewed worktree edits still stop publication for /review, and cancellation or other commit errors do not start automatic repair. A gate bypass applies only to its original invocation; repair and the next automatic offer run gates normally.

This does not yet implement the full always-current branch-and-base guarantee. Once a publication branch exists, ordinary synchronization still selects its tip rather than automatically rebasing its published history onto an advancing base.

Working in several commits

Ask for several commits, separate commits, or one commit per step when you want separate review-and-publication cycles. The agent is instructed to implement only the next independently reviewable unit, including its tests and documentation, then stop for gates, review, and the commit offer. Manual git commit calls inside its private worktree do not satisfy this request.

The agent uses submit_for_review to hand over the current unit's title, summary, proposed commit message, and ordered remaining-work list. A successful handoff stops generation, including later calls in the same response. It does not approve publication. Heph takes the unit through the usual gates, review, and commit dialog, using the proposed message instead of requesting a separate draft. /commit without a message still opens the editor; an explicit message overrides the proposal.

Once the sequence is active, an ordinary response after edits or review feedback is not a completion handoff. Heph continues work on the current unit before opening automatic review. Once the submitted unit is fully approved, heph goes straight to preparing the commit offer; you do not need to ask what is left. Verification-only repairs can re-submit accepted work without manufacturing an edit or repeating review. Partial or interrupted sequence work does not automatically offer a commit. You can still stop generation and use /review or /commit explicitly.

Only verified successful publication unlocks automatic continuation of the next remaining unit. Acceptance alone is not publication. A declined offer, cancelled commit, failed gate, or aborted Git command leaves the sequence paused; /commit can publish it later. Review and gate repairs stay in the current unit. The agent submits each subsequent unit separately, with an empty remaining-work list for the final unit. Queued developer input takes precedence over automatic continuation and receives the unit's scope and verified receipt.

/sequence shows the active unit's phase and remaining work. /sequence cancel stops sequencing without discarding accepted changes. Both wait for an active turn, review, or publication to finish. To combine units or change the sequence, make that decision explicit, or cancel sequencing and continue normally.

The original submission and publication receipt survive restarts. Reopening a conversation resumes a published unit's unstarted continuation at most once. A continuation that already started and was interrupted stays paused for your instructions; it is not replayed automatically. Navigation and forks do not inherit an unrelated unit. Changed branch/base configuration or a target branch that no longer contains the receipt cannot automatically resume it.

The agent must also ask about earlier unpublished work before starting the sequence. The handoff refuses to include carried accepted work unless include_unpublished records an explicit inclusion decision. One /commit still includes all accepted work since the last publication, not just the latest unit. Before any tool handoff establishes a sequence, the instruction-based stop-and-publish workflow still applies, but there is no automatic continuation.

Your checkout

What you have checked out decides only one thing: whether your checkout moves with the commit.

  • You have the conversation's branch checked out. The commit moves the branch under you, so your index and files follow: the committed paths are brought up to the new commit. Because of that, /commit refuses when one of those paths is staged, or differs in your checkout from the branch, and says which; commit, stash, or move that edit first. The result line is committed 3f2a9c1 on heph/wrap-lines: ....

  • Anything else is checked out (another branch, the base branch, a detached HEAD). Your checkout is not read and not written; dirty files, even in the same paths, neither refuse the commit nor change. The result line says (not checked out).

Either way your other files, staged or not, are neither committed nor disturbed.

/commit also refuses while the conversation's worktree holds anything no review has decided: a review you put off or stopped partway, or files you changed there by hand. Only what a review accepted is published. /review shows exactly what is waiting and lets you decide it (Review); then /commit again.

How the commit is made

The commit is made with git's own plumbing in your repository, with the terminal handed back, so it looks exactly like one of yours:

  • your pre-commit hook runs on the candidate and can refuse;

  • prepare-commit-msg runs before the editor, and commit-msg after it, and can rewrite the message or refuse;

  • commit.gpgSign signs, and can ask for its passphrase;

  • post-commit runs once the branch has moved.

Your identity and configuration apply. Your HEAD, index, and checkout are not touched except as above, and your repository receives nothing but the commit's objects, the branch update, and its reflog entry (heph: /commit on ref <name>). The branch moves only if it still points where the candidate was built; if something moved it in between, nothing is committed and heph says to retry. /commit needs git 2.36 or newer and says so on an older one.

[git] attribution adds a trailer, placed where your trailer.* configuration says and never twice; by default there is none (Git).

The draft is written by the commit-message model from the diff, your last twenty commit messages for style, and the reasons the model gave for its changes during the turn. The post-review dialog shows it before y commits it as drafted; choose e to edit it first without preparing or drafting again.

Final gates and hooks run in the private worktree with the configured command environment, including the project-wide shared CARGO_TARGET_DIR. Publication is serialized across heph processes.

Existing conversations

The first turn on a conversation created by an older heph build migrates it to private workspace semantics. Old snapshots included the whole dirty checkout, so heph does not adopt the latest snapshot wholesale. It walks that conversation's turns and replays the before/after snapshots of turns that recorded applied work. If committed upstream overlaps legacy work, upstream remains in the reconstructed base and the displaced legacy text is reported instead of silently discarded. Before replacing any files, heph also commits the exact old worktree under a durable rescue ref. Whatever it holds beyond that conversation's last snapshot is carried onto the reconstruction and shown in the migration turn as unreviewed changes, so its review is the point where stale or unrelated hunks are separated from work to keep.

/commit refuses until this first migration turn completes; a resumed old pending review remains legacy, so send one subsequent turn before publishing it.

Legacy accepted files may still be present unstaged in your checkout. With the conversation's branch checked out, /commit can consume one when its bytes, file type, link target, and executable mode exactly match the private candidate.

What is coming

/push and opening a pull request from heph; the git_commit tool, which lets the model queue the commit during the turn, so "add the feature and commit it" is one prompt; and heph exec --commit. See What Is Not Here Yet.

Branches

Every conversation starts from a branch of your repository, its base branch, and builds on that branch's tip at the start of every turn. Your checkout is not where a conversation starts from: what you have checked out, staged, or left dirty is never read, and switching branches in your checkout changes no conversation.

The base branch question

The first time a conversation runs a turn, heph needs its base branch. If the project's config names one, that is it:

# .heph/config.toml
[git]
base_branch = "main"

It is written onto the conversation the first time it is used, so changing the config later does not move a conversation that has already started.

With no base_branch, heph asks, once per conversation, before the turn begins: a list of your local branches and then your remote-tracking ones (origin/main), with the branch you have checked out first and selected. Type to filter the list, Up and Down to choose, Enter to take it. Esc cancels the turn and puts your prompt back in the input. A conversation made with /fork or /new starts from the base branch of the one you made it from, so in the common case you are asked once per project.

heph exec has no one to ask: it needs [git] base_branch or --base BRANCH, and ends before any turn without them (heph exec).

A name is looked up as a local branch first and a remote-tracking branch second. If the base branch is deleted or renamed, the next turn stops and names it; choose another with /base.

/base

/base

shows the branch the conversation starts from, and the branch it publishes to if it has one yet.

/base release/2.4

starts the conversation from another branch. It works on a conversation that already has turns: the next turn replays the conversation's accepted work onto the new branch's tip, as it replays it onto new commits on the old one, and where the two changed the same lines the branch's text stands and the model is handed the displaced text to reapply. /base waits while a turn runs.

This also works after the conversation has published a branch. The new base wins until a successful /commit publishes the rebase to that same branch; turns never automatically republish. /base reports the pending rebase, which survives restarts and further turns. Only private changes relative to the recorded upstream are replayed: commits belonging solely to the old branch are not automatically copied. A different /branch cancels the override.

A conversation from before base branches asks the question at its next turn and then carries its work onto the branch you choose in the same way.

The conversation's branch

The first /commit names the branch from the work: two to four words from the title model, given what the commit changes, what you asked for, and your recent branch names for style, after [git] branch_prefix (heph/ unless you change it; Git). A name that is taken, by a local branch, a branch on any remote, or another conversation, gets -2, -3, and so on. The name is kept on the conversation and shown in the status bar as soon as it is generated, before gates and message drafting. A commit that a gate or a hook refuses keeps the same branch when you try again.

Set [git] branch_prefix = "team/" for names such as team/wrap-lines, or branch_prefix = "" for names such as wrap-lines. Put it in the user config for all projects, or the project config to override it for one project. Changing the setting does not rename a branch already chosen.

/branch

shows the branch, or that none is chosen yet.

/branch fix/pager-wrap

names it yourself. If your repository has that branch, the conversation adopts it: its tip is where the next turn starts, and the next /commit advances it. If not, it is the name the next /commit creates. /branch refuses the conversation's base branch and any remote's default branch (origin/HEAD): a conversation cannot be pointed at the branch it started from, or at the one a remote calls its default. That, and that heph writes no other branch and never pushes, is all the branch protection there is. /branch waits while a turn runs.

The status bar shows the branch after the conversation's name, main -> heph/wrap-lines, and /refs lists each conversation's branch, or (base main) for one that has not committed yet.

Working through pull requests

  1. Set the project's base branch once, in .heph/config.toml: [git] base_branch = "main", or "origin/main" to start from what the remote has.

  2. Prompt, review, and /commit. The conversation's branch now holds one commit per /commit, on top of the base.

  3. Push the branch and open the pull request yourself, from your checkout: git push -u origin heph/wrap-lines. You never need to check it out.

  4. Keep going in the same conversation. Review feedback becomes the next prompt; the next /commit adds a commit to the same branch.

What you do to the branch between turns is fine; the next turn takes it as it finds it:

  • Advanced (you committed on it, merged the base into it, or pressed the forge's update button): the new commits come in under the conversation's own work, and where they changed the same lines the model is handed its displaced text.

  • Rebased or amended: the conversation's own difference is replayed onto the rewritten tip; work already published is found already there and not proposed again.

  • Deleted after the pull request merged: the next turn starts from the base branch, which holds the work, with nothing private left, and says branch heph/wrap-lines is gone; the next /commit starts a new one.

What the model is told

The system prompt says where the conversation's work goes, from the conversation's own record and never from your checkout (What the Model Is Told). In the worktree, git log shows the base branch's history, and the branches tool lists your remote-tracking branches with the local ones.

Plan Turns

Sometimes you want the model to think, not act: investigate a bug, propose an approach, explain a module. A plan turn gives the model a catalog with no write tools and no execution tools, so the answer is words, and nothing needs review.

heph --plan
heph exec --plan 'how would you add rate limiting to the API?'

Every turn of a --plan session starts as a plan turn. In the plan turn the model is told:

This is a plan turn. Your tool catalog has no write tools and no execution tools: read and reason, then answer the question, offer a plan concrete enough to approve, or ask a clarifying question. Do not attempt or announce an edit you cannot make.

Plan and design turns also receive shared scope instructions: general questions and examples do not request implementation or live inspection. A real session ID is context unless you explicitly ask to inspect that session. The model should answer from source and available read tools, not open live databases, run programs, or probe blocked tools.

When it would materially change the work, the model can use ask_user to clarify whether you want a general answer or a specific investigation using code-mode tools. That scope question is not a mode switch. Only an explicit request for work needing code-mode tools or writes outside the current mode should trigger request_code; heph still owns the confirmation.

Plan turns run on the design tier, the one whose role includes design, which by default is the same frontier model that codes. Give design a tier of its own to plan on another model. There is no rush and the answer is meant to be read.

Switching mode mid-session

Plan mode is a mode, not a fixed property of the session: in an interactive session you turn it on and off between turns.

/plan          enter plan mode (read-only turns), sticky until you leave
/plan local    enter plan mode with a named tool set
/plan how would you split auth?
               enter plan mode and send the prompt as its first turn
/plan --tools local where is Session built?
               the same, with a named tool set
/code          leave plan mode; turns run as they did before
/code --clear  leave it and start the model fresh, as /clear does

The status bar shows plan while the mode is on, and the switch is noted in the conversation. It is sticky until /code or an accepted mode-switch question. If you explicitly ask for work requiring code-mode tools, such as building or inspecting a live database, it calls request_code to pause for heph's own confirmation dialog. A call blocked by the mode also opens that dialog before it runs. No further model rounds or tool calls run while the question is open. Press y to switch to code mode and continue the same turn with its context, code tools, and routed model; the pending call still goes through normal permissions, including allowlists. Press n or Esc to keep the mode and stop the turn. Mode confirmation cannot be skipped by --yes or remembered tool approvals. This does not clear context or perform an --implement handoff. Non-interactive heph exec stops instead of switching.

The usual flow is to /plan, talk through an approach, then /code and say "do it" — the model keeps the whole conversation, now with the full catalog. /code returns to the class the session was started with (coding, or --review).

Switching also swaps the tier, so the model shown in the status bar can change: plan turns run on the design tier, coding turns on the coding tier.

Tool sets

A plan turn's catalog is a tool set: a named list of tools that only read. The default is research; --tools NAME picks another. Every plan catalog also includes request_code, the mode-switch signal:

heph --plan --tools local
SetContains
researchEvery tool that reads, web_fetch included, plus ask_user.
localresearch without the network: no web_fetch.
historysearch_history, read_turn, and ask_user: the conversation and nothing else.

You can define your own in [toolsets]; a set may name only tools that read, and heph refuses one that names edit or shell (Tool Sets).

What a plan turn is for

  • Before a large change: "read the auth module and tell me how you would add refresh tokens; do not change anything yet." The next turn, without --plan, can say "do it".

  • Understanding: "explain how requests flow from main to the handler."

  • Deciding: "we could store this in SQLite or in a flat file; which, and why?" The model can read the codebase to answer and can ask_user if the answer turns on something only you know.

--review is a related flag: it keeps the full catalog but runs the turn on the review tier, for a turn that may edit but is mostly reading, such as a code review with fixes.

What it cannot do

A plan turn cannot leave you a file. If you want the plan written down as a design document, switch to a design turn: /design keeps the conversation and lets the model write under your [design] paths, and the document goes through review like any other change.

Design Turns

A plan turn answers in a reply. Some plans should outlive the conversation: a design document or an RFC, kept in the repository as the record of what was decided and why. A design turn is a plan turn that may write that document, and nothing else.

The document is your project's, not heph's. It lives where your project keeps such documents, in the form your project uses, and is reviewed and committed like code. Heph is told only where those documents live:

# .heph/config.toml
[design]
paths = ["docs/designs/**/*.md"]

What a design looks like is your convention; teach it the way you teach any convention, in AGENTS.md or a skill.

The workflow

/design how should we rate-limit the API?
  ... several turns of discussion; ask it to write the design ...
  ... review the document hunk by hunk ...
/commit
/code --clear --implement

Three commands carry a design from a conversation to a build.

/design

/design                  enter design mode, sticky until /code
/design <prompt>         enter it and send the prompt as its first turn
/design --tools local    the same, with a named tool set
heph --design            start a session in it
heph exec --design '<prompt>'

A design turn reads with a tool set, research unless you name another, as a plan turn does. It also has edit, create, search_replace, and markdown_format, confined to [design] paths. Before a call writes outside those paths, heph pauses for its own code-mode confirmation, as it does for calls to absent execution tools. request_code lets the model request the same pause immediately when you explicitly ask for work needing code-mode tools or writes outside those paths. Press y to switch modes and resume the same turn through normal permissions; n or Esc keeps design mode and stops the turn. Nothing is written before confirmation. In non-interactive heph exec, the turn stops without switching. shell and everything else that runs are absent until the mode changes. The model is told:

This is a design turn. Your writes are confined to docs/designs/**/*.md, and you have no execution tools: read and reason, answer questions, clarify scope, and when the developer asks, write the design there in the project's own form. Do not attempt or announce an edit outside those paths.

The same scope guardrails as plan turns apply: a general question or a real session ID does not ask for implementation or live inspection. The model may clarify whether you want a specific investigation using code-mode tools, but that question does not replace heph's mode-switch confirmation.

With no [design] paths, /design refuses and names the line to add.

A design turn ends like any turn that changed something: the gates run, so a gate that checks your documents' formatting checks the design, and the review shows the document hunk by hunk. The review is where you edit the design before it is built; a rejected hunk with a comment goes back to the model as any rejection does.

The status bar shows design while the mode is on. /design from plan mode keeps the conversation, so a plan that turns out to deserve a document is one command from being one.

Review and commit

Commit the design with /commit before any code is written. The commit is the approval: nothing builds a design that has accepted changes /commit has not published. A design that is decided and never built is still a decision, and the repository says so.

/code --clear --implement

/code                                    leave the mode; context kept
/code --clear                            leave it and start the model fresh
/code <prompt>                           leave it and send the prompt
/code --implement [<path>...]            leave it and start the build
/code --clear --implement [<path>...]    the handoff
/code --clear --implement docs/designs/rate-limiting.md start with the schema
heph exec --implement docs/designs/rate-limiting.md

--clear is /clear and the mode change in one step: the next turn is sent nothing of the earlier ones, and live token/cache counters restart. Persisted spend remains. If the clear is refused, the mode does not change either.

--implement builds the first prompt and sends it:

  • Which design. The paths you give. With none, every file under [design] paths that a design turn of this conversation changed and that no earlier --implement named. A design written in another conversation is named by path. Words after the paths are added to the end of the prompt.

  • What the model is sent. Which documents to build and the commit they were read from, then each document's text in full. The text is in the prompt, not read with a tool, so it stays in the model's context for the whole build. When this conversation wrote the design, the model is told the discussion is in earlier turns that search_history can find, to look once for a question the document leaves open, and not to reopen what the document decided.

  • Where it runs. /code returns to the class the session started as, so the build runs on the coding tier.

--implement refuses while a review is waiting, while a design path has accepted changes not yet committed (the design is not committed; /commit first), and when there is no design to build.

The pair is the handoff. Without --clear, the build carries the whole design conversation, which a short conversation can afford; the status bar's cost will tell you.

heph exec --implement <path> builds in a new conversation of its own, named build- and the document's name, so it never touches the conversation you were working in. It starts from --base or [git] base_branch, so the design must be committed on that branch; if it was committed on the design conversation's own branch, pass that branch:

heph exec --implement docs/designs/rate-limiting.md --base heph/rate-limiting

Nothing is created if the document is not there, and heph --continue build-rate-limiting opens the new conversation afterwards.

To build in the conversation that wrote the design instead, as /code --clear --implement does, name it:

heph exec --continue rate-limiting --implement docs/designs/rate-limiting.md

Its context is cleared first, and stderr says so.

Acceptance checks

Configure design checks with modes = ["design"] on a [[gates]] entry, code test suites with modes = ["code"], and shared checks without modes. Both mode and path restrictions must match. Existing unscoped gates keep their behavior; design mode alone does not skip a test suite. At /commit, accepted unpublished code still selects code checks even after a switch to design. Mixed publications run both sets, each eligible entry once against the combined candidate. See Acceptance gates for examples.

Designing on one model, building on another

Plan and design turns run on the tier whose roles include design, which in the built-in profiles is the same frontier model that codes. To design on a stronger model and build on the tier below, give design a tier of its own:

[profiles.handoff]

[profiles.handoff.tiers.architect]
models = ["anthropic/claude-fable-5-1"]
roles = ["design"]
effort = "max"

[profiles.handoff.tiers.frontier]
models = ["anthropic/claude-opus-5"]
roles = ["coding", "review"]
heph --profile handoff

--review turns stay on the review role; move it to the architect's tier if you want the stronger model to review too (Tiers and Profiles).

When the design is wrong

A small correction the builder can make itself: the design paths are in its catalog like any other, so it amends the document in the same turn as the code, and the review shows both. A larger one goes back to the architect: /design, say what the builder found, review, /commit, then /code --clear --implement again. The revising turn is a design turn after the last handoff, so the revised document is found without naming it.

/diff marks design turns [design] and builds [builds <path> at <commit>], and /diff <path> shows the turns that built a design and what they changed.

Models and Cost

Heph does not have "a model". It has tiers: named groups of one model each, and each piece of work is sent to the tier for its role. The coding you ask for goes to the tier whose role includes coding; a plan turn goes to the review tier; a --quick turn to the quick tier. A profile is a set of tiers, and each conversation runs on one (Tiers and Profiles). You can change which model fills which role in config, or override the choice for one conversation.

The default tiers

Each provider's catalog defaults make a built-in profile of three tiers; with one provider, it is the default profile. heph profiles prints them all:

TierRolesDefault model (Bedrock)
frontiercoding, reviewThe catalog's large model, e.g. Claude Opus
workhorseresearch, summarizeThe catalog's small model, e.g. Claude Haiku
fastquick, commit-message, titleThe same small model, as its own tier so it can be pointed elsewhere

Only coding, review, and quick are used by anything today; the other roles exist so the layout does not change when the features that use them arrive. The first-run config file lists these tiers as comments, with the actual model ids, so you can see what was picked.

Thinking

A tier whose role includes coding or review has extended thinking on by default, at high effort; the others have it off. Thinking is the model reasoning before it answers, and it shows in the conversation folded to one line. It costs output tokens and improves the work on hard problems; for a quick question it is waste, which is why the fast tier has it off.

[tiers.NAME] thinking = "off" or "adaptive" overrides the default; effort = "low" | "medium" | "high" | "xhigh" | "max" sets how much. Models that do not take an effort level (Haiku 4.5, Sonnet 4.5, Opus 4.1) get a thinking token budget derived from the effort instead; heph knows which is which from the catalog. A GPT model reasons at its own default effort when the tier says off, since its API has no switch for it, and on Bedrock returns no summary of the reasoning to fold into the conversation.

Overriding for one conversation

--profile picks the profile; --tier NAME=MODEL runs one of its tiers on another model, and --model is short for --tier on the tier doing the conversation's role:

heph --profile work                             # every role from the work profile
heph --model fast                               # the turns on the fast tier's model
heph --model claude-haiku-4-5-20251001          # a model id, if one configured provider has it
heph --model bedrock/us.anthropic.claude-sonnet-4-6   # provider/model, when several could
heph --tier fast=claude-sonnet-4-6              # commit messages and titles on Sonnet

A bare model id is looked up across your configured providers; if two of them offer it, heph asks you to say which with the provider/ prefix. The tier keeps its own settings (roles, max_tokens, thinking) on the new model. The conversation keeps the choice: --continue routes it the same way.

The status bar shows the model's short name: opus-5, sonnet-4-6, haiku-4-5-20251001.

Native Ollama

heph --model ollama/NAME selects an already installed tool-capable model without a cloud credential. With no routing, all roles use it and continuation retains that bootstrap. Existing profiles keep their other role assignments; selecting a local conversation model is not a local-only promise, and mixed routing produces a warning. No automatic download or cloud fallback occurs. heph models --provider ollama discovers the selected endpoint; ordinary cloud startup does not probe an unselected local server.

Text attachments and vision-capable image attachments use the normal workflow. Thinking defaults to the server's behavior unless the tier explicitly configures supported controls. Runtime allocation, attachment limits, locality cautions, and experimental validation status are in Providers.

What things cost

Every request is recorded with its token counts, split four ways, and its cost at the model's catalog rates:

TokensRate
inputPrompt tokens sent fresh
outputWhat the model wrote, thinking included
cache writePrompt tokens written to the provider's cache
cache readPrompt tokens served from the cache, at a fraction of the input rate

heph models shows the four base rates for every model. The status bar shows muted totals like $1.00 / $4.00 / $8.00, in this order:

  • Session: requests made by this active heph process, across ref switches. A new process starts at zero, even when resuming a pending turn.

  • Total: all requests in this ref's ancestor history. Resuming restores it; /clear does not erase it. Forks inherit ancestor spend, but daily totals count each request only once. Moving the ref changes its reachable total.

  • Day: all requests across projects, sessions, providers, and models under this heph data root today. Days use the OS timezone through Jiff (or TZ), including daylight-saving transitions; totals refresh every five seconds.

Dollar amounts have no approximation markers. The status bar uses two decimal places; other cost displays use two above $1 and four otherwise. A request interrupted before final provider usage keeps its input estimate; it is not a confirmed bill. Accounting runs on independent read-only workers, not on prompt edits or sidebar toggles. The status bar keeps last-good amounts without refresh messages; - means no compatible sample, not zero spend. In /stats and the sidebar, loading means the first sample is pending; updating or stale keeps a compatible last-good value visible. Routine refreshes do not pulse valid compact labels. Known ledger changes show updating; ten seconds without a compatible successful sample shows stale. Retries keep failures visible until recovery. Errors remain visible in /stats, and missing data is unavailable, not zero. A ref/tip or local-day change clears incompatible values rather than displaying them under the new label. These totals include title, branch-name, and commit-message requests as well as normal rounds. They are displays, not enforced daily budget limits.

Each turn-end line shows tokens and USD separately for uncached input, output (including thinking), cache reads, and cache writes. It survives reopening. The cache status figure is the share of live prompt tokens served from cache; /clear resets live token/cache counters, not persisted spend.

/cost opens a focused dashboard at any width: session, thread, and today split into uncached input, output (including thinking), cache read, and cache write tokens and USD. It also shows cross-project daily spend, requests, and all-category token counts for today plus the previous six local calendar days. Empty days are included; today is marked partial. The seven-day total and daily average include partial today. Weekly provider/model totals are ranked by spend. Request and estimate counts indicate how much usage lacks final provider data. The input cache hit rate is cache-read tokens divided by uncached input plus cache-read and cache-write tokens; without input tokens it is n/a.

The dashboard opens from cached data and refreshes asynchronously while open. Weekly reads open each project once per batch. Failures retain compatible last-good values with visible errors, never a misleading zero; changed dates or timezones clear incompatible samples. Up/Down, PgUp/PgDn, and the wheel scroll; Esc or another non-scroll key closes it. A late completion never reopens it. There are no arguments or filters. The sidebar's cost hint points to /cost.

In a wide terminal the sidebar shares skills with today's provider/model cost breakdown. /stats opens immediately at any terminal width using cached totals, then updates the same window with fresh details. Tool attribution has its own loading notice until the detailed sample arrives. Up/Down, PgUp/PgDn, and the wheel scroll; Esc or another non-scroll key closes it. A late completion never reopens a closed window. Reopened conversation text appears first; its cost summaries arrive as a batch and show unavailable rather than zero if that read fails.

The stats view also shows estimated tool request shares for this thread. A request that generates several recorded tool calls is split equally among them, including its input and output tokens. This does not measure the execution cost of a tool, does not include later requests processing its results, and leaves requests without tools unattributed. Shares are not added to spend again.

Cost is computed from the catalog's rates, not from a bill, so it is an estimate that is exact when the rates are. heph models is the place to check them; the catalog is a snapshot embedded in the binary.

If an Ollama response omits final usage counts, the status bar shows tok unknown and cost incomplete instead of claiming actual zero usage. Native cached-token metrics and nanosecond timings are retained when supplied. Default local rates are zero API charges, not zero electricity or hardware cost.

Failover

A tier may list several provider/model references for the same model:

[tiers.frontier]
models = ["bedrock/us.anthropic.claude-opus-5", "anthropic/claude-opus-5"]
roles = ["coding", "review"]

The first configured provider that is usable is the one heph talks to. Falling over to the next on a failed request is not implemented yet; today the list picks the first provider you have credentials for.

Choosing

The defaults are a reasonable start: a frontier model for the work you review and a small one for the rest. Reasons to change them:

  • Cost. Point coding at Sonnet for everyday work and keep Opus for a --model override when a problem is hard, or a profile for it.
  • Provider. You have both an API key and Bedrock; configure both providers and choose per tier.
  • GPT. The openai and bedrock-openai providers carry OpenAI's models, the second under the same AWS credentials as bedrock. A tier is one protocol, so a GPT tier lists GPT providers only; see Providers.
  • A local or gateway model. A server speaking the Messages or the Responses protocol can be a provider with its own models and rates; see Providers.

Project Instructions and Skills

At each turn boundary Heph reads $XDG_CONFIG_HOME/heph/context.md (or ~/.config/heph/context.md), root .cursor/rules, HEPH.md, CLAUDE.md, and AGENTS.md, in that order. Each contributes at most 64 KiB and the rendered block at most 256 KiB. Directory-form Cursor rules are reported but not loaded. Read or decoding errors block submission with diagnostics.

Recognized project paths changed in this conversation's accepted state overlay the developer checkout; an accepted deletion does not fall back to checkout. Pending and rejected edits never become instructions. Every turn stores its exact text and source manifest and keeps them frozen through retries and review. Acceptance activates only accepted text next turn here, without restart; /commit publishes later. Forks and other refs retain their own accepted state. External source changes are picked up next turn with a notice.

/instructions lists logical paths, origins, absent paths, unsupported formats, errors, effective bytes, truncation, digests, and snapshot IDs. It separates the active or last frozen snapshot from the next-turn preview and does not print contents.

Create or Refine Guidance

An idle interactive launch without an initial prompt offers a reviewed AGENTS.md when no recognized project path exists. Empty files, symlinks, unreadable guidance, and directory-form Cursor rules suppress the offer. User context and skills alone do not. The centered permission-style dialog uses y to generate (g is an alias), n or Esc to dismiss this launch, and d to disable future offers for this repository, shared across Git worktrees. Drafts and armed skills stay untouched; dismiss the dialog before typing ordinary work. /init --offer clears persistent suppression without a model call. Exec and pipe mode never show it.

/init defaults to AGENTS.md when no root Markdown guidance exists, refines a single existing target, and asks which target when several exist. Use /init --file AGENTS.md, HEPH.md, or CLAUDE.md to name one explicitly. Creating beside other guidance requires confirmation; Cursor rules themselves are not editable targets. Arbitrary paths and extra options fail locally. /init --help prints the contract.

When generation starts, an unnamed conversation immediately gets the local name init-agents, init-heph, or init-claude for the selected target. Conflicts get numeric suffixes such as init-agents-2. Existing names are kept, and no title-model request is needed. Selecting or cancelling a target alone does not name the conversation.

Finish current work, queued prompts, and pending review first. Init uses the configured coding model for a normal reviewed turn without changing sticky plan/design mode or consuming armed skills. It can read repository evidence, ask consequential policy questions, and create or exact-edit only the selected file. It cannot run commands, fetch the network, activate skills, change config, or delegate writes. Discovered commands are not labeled tested. Host-run gates still use normal permissions.

Preserve human policy and inspect the model's evidence in the review brief. No useful change can mean no diff. Checkout-only uncommitted guidance must be committed before refinement; differing checkout/ref versions require explicit confirmation that the accepted ref version is intended. Accepted output is active next turn only in this conversation and remains private until /commit. The injected task stays excluded from ordinary prompt-based naming.

Discovery

Heph recursively discovers SKILL.md under project .agents/skills/, .heph/skills/, .claude/skills/, and .cursor/skills/; user ~/.agents/skills/, ~/.claude/skills/, ~/.cursor/skills/, and ~/.codex/skills/; and heph/skills/, crush/skills/, and opencode/skills/ under $XDG_CONFIG_HOME (default ~/.config). These are the directories Claude Code, Cursor, and OpenCode read, so skills installed for them work in Heph too.

Project skills take precedence over user skills with the same name, and earlier directories in the lists above over later ones. A skill found more than once is loaded once: a directory reached twice through a symbolic link is read once, and a SKILL.md identical to the one already kept is skipped silently. Only a different skill with the same name is reported as shadowed.

Discovery follows the Agent Skills specification. The name is 1-64 lowercase letters, digits, or single hyphens and matches its directory; the description is 1-1024 characters. The optional fields are read too:

  • license and compatibility (each 1-500 characters) are shown in /skills. compatibility is for you; it is not sent to the model.

  • metadata, a map of strings, is kept and not used.

  • allowed-tools is shown as requests tools: ...; not granted. Heph never turns it into approvals: everything a skill leads the model to do goes through the permission engine like any other call.

A skill with a malformed field, required or optional, is not loaded; /skills lists it under "Ignored skills" with the reason. Fields the specification does not define are tolerated, since skill directories shared with other agents carry their own.

The catalog and activation

Only each enabled skill's name and description stays in the system prompt, and only on a turn whose tools include skill: a plan turn on a tool set without it is not told about skills it could not load. The Level 0 skill tool loads a skill's body when the model decides it applies. /skills lists what Heph discovered, grouped as project or user skills, where each came from, which are active or disabled, and why malformed or shadowed files were ignored.

Use /skill NAME to require a skill for your next prompt. Heph inserts its structured instructions before that prompt; the prompt remains what the TUI shows and recalls. The activation is stored in conversation history, survives a restart, and is inherited by a fork. Start /new when you want a context without an earlier activation. For instructions that must apply to every task, use AGENTS.md or HEPH.md instead of making a nonstandard always-active skill.

An activation names the skill directory and lists up to 100 bundled resources without loading them. The model can load a root-confined resource through the same skill tool. Scripts still require an ordinary, permission-checked shell or exec call.

Arming, queueing, and delivery

Tab after /skill completes the name, cycling the enabled skills that begin with what you typed and are neither active nor already waiting. /skill refuses a skill that is already armed or waiting with a prompt.

A skill you arm waits for the next prompt you write. If a turn is running when you send that prompt, the prompt is queued and takes the armed skills with it; a skill you arm after that waits for the prompt after. Each queued prompt keeps its own skills; a later message never takes over an earlier message's skills. Ctrl-U or Up returns only the newest queued prompt's skills to the next prompt you write.

Nothing you arm is silently lost. After each turn Heph reads the conversation back: a skill the turn's prompt carried that is not in it, because the turn failed before storing the prompt or could not start at all, is armed again, with a line saying so. An interrupted turn whose prompt was stored has delivered its skills, and nothing is re-armed. Switching to another conversation drops what was waiting, with a notice naming it, since whether it was already active was decided against the conversation you left. /clear keeps it: it was never in the context. /skills ends with what is armed for the next prompt and what the queued prompts carry.

The skills sidebar

In a terminal at least 100 columns wide, a 30-column sidebar right of the conversation lists every skill you can use, and marks the ones the conversation holds or that wait for a prompt:

RowMeaning
* nameActive; the model is following it
* name (changed)Active, but its file has changed since (below)
* name (disabled)Active, and disabled since; it stays in the conversation
* name (missing)Active, and no longer discovered
+ name (queued)Goes with the queued prompt
+ name (next)Armed for the next prompt you write
- nameAvailable: enabled, not active; /skill name arms it

A skill the running turn's prompt carries is shown as active, since the model is reading it. Active and waiting skills come first, then the available ones in name order; when they do not all fit, a line says how many more there are. The skill roster's last line counts the active and the available skills. Below it, the bottom of the sidebar shows the running heph version. If heph -V from PATH reports a different installed version, the row turns yellow and adds (outdated), with an installed version row beneath it so you can see how far behind the running build is. Restart heph to use the installed build. The check runs at startup and once a minute without blocking input. Failed checks leave the last known result unchanged. By default the sidebar shows whenever there is a skill or spend to show; it shares space with today's provider/model costs (Models); [ui] sidebar = "on" or "off" (UI) forces it, /sidebar on|off|auto sets it for the session, and Ctrl-S or a bare /sidebar toggles it. Where the sidebar is not drawn, the input's title counts the skills waiting: message (+2 skills).

When a skill changes

What the conversation holds is the exact text the model was given. Heph compares it with the skill as it is discovered now, on the instructions and the resource list; the directory line is not compared, so moving the checkout does not count as a change.

  • An active skill that is unchanged is not injected twice: /skill refuses it and the skill tool answers that it is already active.

  • An active skill that has changed on disk shows in /skills as [active] (changed on disk). /skill NAME arms the new version, and the skill tool returns it with a line saying it replaces the earlier one. The newer copy is the one that counts from then on; both stay in the conversation.

  • An active skill that is no longer discovered, because it was deleted, moved away, or its project directories were turned off, shows as [active] (not found on disk). It stays in the conversation, since history is not rewritten, and cannot be armed again.

Reloading

Heph discovers skills when it starts. /skills --reload discovers them again and rereads [skills] from both config files, so an edited, added, or deleted skill, or a change to disabled, takes effect without a restart. The reply names what changed, or says nothing did. Only [skills] is reread; every other setting, and the context files, still need a restart.

A turn running during the reload keeps the skills it started with; the next turn uses the new ones. When the reload changes the catalog in the system prompt, the reply says so: the next request writes the cached prompt prefix again, which costs more once. A config file that no longer loads, including a project file that sets [skills] project, fails the reload with its error and leaves everything as it was.

Disabling skills and trusting projects

[skills] disabled keeps named skills from the model: they are left out of the catalog and the skill tool, and both /skill and the tool refuse them, naming the config file that disabled them. [skills] project = false in your user file refuses every project's skills. A project skill is otherwise trusted as the project's AGENTS.md is: it can change what the model is told, and nothing else. See Skills.

Outside a session

heph skills prints the /skills report for the project, and with --ref REF marks what is active on that conversation. heph skills show NAME prints one skill in full: every frontmatter field, where it came from, its resources, and the activation payload the model would receive. heph exec --skill NAME arms a skill for a non-interactive turn (heph exec).

heph skills check is for skill authors and CI: it validates every discovered skill, or the SKILL.md files and directories you name, prints each problem, and exits 1 if any skill is invalid. It is heph's counterpart of the specification's skills-ref validate, and differs from it in two ways:

  • A frontmatter field the specification does not define is a warning, and the exit status stays 0. skills-ref rejects it, but skill directories shared with other agents carry their own fields, and heph loads such skills.

  • Names must be ASCII: lowercase a-z, digits, and single hyphens. skills-ref also accepts other lowercase letters; heph does not load a skill so named, and says why.

None of these needs a model configured, and only --ref opens the database.

Non-interactive Use: heph exec

heph exec runs one turn and exits. It is the same engine as the session, the same worktree, gates, and review, without the screen: for scripts, for a quick question from the shell, for piping something in.

heph exec 'fix the failing test in src/parse.rs'
git diff | heph exec 'review this change and list anything risky'
heph exec --plan 'summarize what this crate does' | less

The prompt is the arguments, joined with spaces. If stdin is not a terminal, whatever is on it is appended to the prompt as context, so git diff | heph exec 'review this' gives the model the diff.

What comes out

The model's final text goes to stdout. Everything else, tool calls as they run, status lines, prompts, the review, goes to stderr, so the text can be piped or captured on its own. --stream echoes the model's text to stderr as it streams too, for watching a long answer arrive.

The last stderr line summarizes the turn:

-- turn 01a0c63e on main: EndTurn, 4 rounds, $0.2140; applied src/parse.rs

It ends with ; skills a, b when the turn activated skills, whether you armed them or the model loaded them.

Skills

--skill NAME activates a skill with the prompt, as /skill does in the session; repeat it for more, and they go in the order given:

heph exec --skill rust-change --plan 'how are errors typed here'

A name that is not discovered, or that [skills] disabled names, ends the run before any turn starts, with exit 1. A skill already active on the conversation --continue names is skipped with a note on stderr, since it is in effect. --skill needs a prompt to go with. When --continue offers a pending review and you leave it undecided, the prompt does not run, and the summary line ends ; not delivered: skills a, b.

Exit codes

CodeMeaning
0The turn ended with nothing left to decide: the model finished, and any change was applied.
2The turn ended short: a call no flag covered, an interrupt, the model's output limit, or changes waiting for review. The reason is on stderr.
1Heph itself failed: bad config, no provider, a database or git error.

With a terminal

Run from a shell, heph exec has a tty even though it has no screen, and uses it: permission prompts, ask_user questions, and the review appear as plain prompts on the terminal, and you answer by typing.

exec   in worktree of ref main   (mirrors /home/you/project)

  cargo test --lib auth

  y  this call only
  a  this session:    exec(cargo) args(*)
                      = any exec of cargo, any arguments; only cargo is run,
                        and each argument is passed to it literally, never
                        through a shell
  A  remember:        the same, written to project config [permissions] promoted
  e  edit the pattern before a or A
  n  deny
  N  deny with reason
>

The review works the same way: the brief, then Enter for hunk-by-hunk prompts (a, r, c, e, s, q) or the configured external tool, which opens on the terminal. Heph reads the tty directly, so this works even when stdout is piped or stdin carries the prompt's context.

Without a terminal

In a cron job, a CI step, or a pipe with no tty, there is no one to ask, and heph fails closed:

  • A Level 2 call (shell, web_fetch) that no flag or config pattern covers is denied. The model is told no one is present, and usually explains what it would have needed. The turn continues.

  • ask_user returns an error telling the model to decide or state its assumption.

  • Changes the model made stay in the worktree, unreviewed, and the turn exits 2 with the conversation to continue on:

    changes await review in the worktree of ref main; run `heph exec --continue
    main` in a terminal to review them, or pass --accept-edits
    
  • SIGINT ends the turn as interrupted.

Three flags widen what runs unasked. They approve tool calls, and nothing else: none of them stands in for review.

FlagApproves
--allow-tools PATTERNCalls matching the pattern, e.g. --allow-tools 'exec(cargo) args(*)'. Repeatable.
--yesEvery Level 2 call.
--yes-reallyLevel 2 and Level 3.
--accept-editsNot a tool flag: applies the turn's changes without review.

--accept-edits is the one to think about. With it, whatever the model changed lands in your tree when the gates pass. It is right for a change you will look at in git diff afterward anyway, and wrong for anything you would want to reject hunk by hunk.

Picking a review up later

A turn that exited 2 with changes waiting can be finished from a terminal:

heph exec --continue main

With --continue and no prompt, heph offers the pending review and exits when it is decided. With a prompt as well, the review comes first and the new turn after. The same review opens in the interactive session:

heph --continue main

See Picking Up Where You Left Off.

The base branch

A conversation starts from a branch you name for it (Branches). The session asks; exec cannot, so a conversation with no base branch needs [git] base_branch in the project's config or --base on the command line:

heph exec --base main 'add a test for the parser'

Without either, the run ends before any turn with a message naming both. Once a conversation has a base branch, --base with another name is refused rather than moving it; /base in a session does that.

The other flags

--continue NAME runs on a conversation other than main. --profile, --tier, --model, --plan, --design, --tools, --review, and --quick mean what they mean in the session (Models and Cost, Plan Turns, Design Turns). --implement PATH builds a committed design in a new conversation, or in the --continue one with its context cleared (Design Turns). --quick runs the turn on the quick tier; it does not yet cap the turn at one round.

The global flags go before the subcommand: heph -C DIR exec ..., heph --data-root DIR exec ....

Working in tmux

Heph is built to be left alone. A turn takes minutes; you start one, go and do something else, and come back when the model needs an answer or has finished. tmux is where that way of working lives, and heph is made to fit it: the session takes one pane and stays out of the others, it announces itself when it needs you, and it can be found from anywhere. This chapter is how the pieces are meant to go together. Nothing here is required; heph runs in a bare terminal too.

heph tmux list now selects the current project's live panes, including from subdirectories. Use heph tmux list --all-projects to see all panes, or heph tmux list 'auth|login' to match names and saved branch metadata. --match is the explicit regex spelling. Unknown pane identities appear only globally, labeled as unknown projects. status and next remain global attention tools.

One pane per project

Give each project a heph of its own, in a pane or window of that project's tmux session, and leave it running. The conversation persists, so there is no cost to leaving the pane open between prompts, and a heph that is already open is one you can hand something to without ceremony.

A typical window:

┌──────────────────────────────────┬──────────────────────────────────┐
│ $ nvim src/parse.rs              │ You: the parser rejects @tests/  │
│                                  │      fixtures/odd.toml; why?     │
│                                  │                                  │
│                                  │ ok view src/parse.rs             │
│                                  │ ok grep quoted                   │
│                                  │ >> shell cargo test parse  0:04  │
│                                  │   | running 12 tests             │
├──────────────────────────────────┤                                  │
│ $ git log --oneline -5           │                                  │
│ $                                │  message ─────────────────────── │
│                                  │ >                                │
│                                  │ main | running shell 0:04 | ...  │
└──────────────────────────────────┴──────────────────────────────────┘

Your editor and a shell on the left, heph on the right. The two sides look at different trees: your editor is in your working tree; heph's model works in a worktree, a copy that mirrors your tree as it was when the turn began. Nothing the model does shows up on the left until you accept it in review, and nothing you type on the left is seen by the model until the next turn begins, when the worktree is refreshed from your tree.

That separation is what makes it safe to keep editing while a turn runs. If you change a region the model also changed, review sets the model's hunk aside and keeps yours (Rejections, Comments, and the Next Round).

Looking at the worktree

Sometimes you want to see what the model has built before review opens, or run something against it. !cmd runs a command in the worktree from inside heph (Running Commands Yourself); !pwd prints the worktree's path, and you can cd there in another pane:

$ cd ~/.local/share/heph/wt/6746d59d741e4d0e/01a0c4e6-.../
$ git diff HEAD

It is an ordinary git checkout. git diff HEAD there is the model's change so far; git log shows your project's history beneath it. Read all you like; writing there by hand is not a good idea while a turn runs, since the model will not know.

Tools that take the screen

When review opens in lazygit, or Ctrl-E opens your editor, heph suspends its screen in the pane and gives the pane to the tool, as vim does for :!. The tool runs in the same pane, at the pane's size; when it exits, heph redraws. Nothing else in the window is disturbed. A pane wide enough for lazygit's three columns is a pane wide enough for heph.

Being reached

The status bar at the bottom of heph's pane shows what the turn is doing. Once you switch to another window it is out of sight, so heph also sends a notification whenever it needs you or a wait ends: a question, a permission prompt, a review opening, a turn finishing. Under tmux without allow-passthrough, that is a tmux display-message, which appears in the status line of whichever window you are in. With passthrough, it is your terminal's own notification.

Nothing is sent while you are looking at the pane. tmux itself knows whether heph's pane is the active pane of an attached client, and heph asks it.

Several hephs

With a heph per project and several projects open, the question becomes "which one needs me?". Every heph publishes its state as tmux pane variables, and heph tmux reads them across all sessions. Publication runs on a bounded background worker; slow or unavailable tmux does not block keyboard input. Failures retry after five seconds, each subprocess has a one-second deadline, and exit cleanup waits at most two seconds. Two lines in tmux.conf put the count of waiting hephs in your status bar and bind a key that jumps to the one most in need of you.

heph tmux: finding the one that needs you

Put this in tmux.conf (heph tmux conf prints it):

set -g status-interval 5
set -g status-right '#(TMUX="#{socket_path},#{pid},0" heph tmux status) | %H:%M '
bind H run-shell 'TMUX="#{socket_path},#{pid},0" heph tmux next --client "#{client_name}" --pane "#{pane_id}"'

Now the status bar says heph 1 waiting / 1 busy (heph 0 waiting / 0 busy when no heph is running; the waiting count turns red when anything waits), and prefix H jumps to the pane most in need of you: a question outranks a permission prompt, which outranks a review, which outranks a finished turn. Press again to cycle among equals. The binding identifies the client and pane that invoked it, so detach/reattach and multiple attached clients do not confuse the jump. It switches that client, window, and pane, in any session.

Both commands explicitly select the tmux server that invoked them, rather than relying on environment changes after attaching locally or over SSH. If you used the older snippet, replace it and reload your configuration with tmux source-file ~/.tmux.conf (or your configuration's actual path). These commands also work with older heph versions; they use no new CLI flags.

Counts come from pane variables, without waiting for the session's JSON status reply. A live heph stays counted while an editor or review tool owns its pane, and differences in the running session's status schema do not affect counts. Linked windows count only once, no matter how many sessions or clients see them. If querying tmux itself fails, the segment says heph status unavailable rather than silently disappearing or reporting zero activity.

$ heph tmux list
 PANE         PROJECT     REF   STATE       WAITING  SPEND
 work:1.1     api-server  main  permission  1        $0.31
 docs:0.0     handbook    main  done        0        $0.02

heph tmux opens no database and reads no config; it runs from tmux's own status line in milliseconds. A pane whose heph has exited is ignored even if its variables were left behind.

The variables

VariableValue
@heph-stateasking, permission, review, paused, done, idle, or working
@heph-refThe conversation's human label (<unnamed> when absent)
@heph-ref-idIts full UUID, used for switching rather than the label
@heph-waiting1 when the session is waiting on you, else 0
@heph-projectThe project directory's name
@heph-sockThe session's control socket

Changed values are published on state transitions; missing options are repaired every five seconds, even while idle. Repairs leave existing options untouched. This avoids periodic tmux redraws that can clear your terminal's text selection, even in other sessions with no heph pane. Options are unset when heph exits. Read-only control-socket status queries do not redraw heph's screen. You can use the variables in your own status line: #{@heph-state} in a pane-format string.

The control socket

Each session listens on $XDG_RUNTIME_DIR/heph/<pid>.sock, mode 0600, and removes it on exit. It accepts two lines and nothing else:

  • status answers with the session's state as JSON: the active conversation, its state, its spend, whether it is waiting.

  • switch NAME asks the session to switch conversations. Switching arrives in a later phase; today the session acknowledges and says so.

heph tmux list uses status to show spend; heph tmux next uses it to check which conversation is active. There is no way to send a prompt, approve a call, or read a conversation through the socket.

Notifications

Whenever something needs you, or a wait ends, heph sends one line out of its pane:

heph: main "add auth to the API..." -- approve cargo test 2>&1 | tail -20?
heph: main "add auth to the API..." -- asking "Which adapter is live?"
heph: main "add auth to the API..." -- review: 2 files, 3 hunks
heph: main "add auth to the API..." -- turn done, 1 file applied, $0.67

Each names the conversation and the first line of your prompt, so you know which piece of work is asking without going to look. Events within one second of each other become one message naming them all.

Nothing is sent for a pane you are looking at. Under tmux, heph asks tmux whether its pane is the active pane of the active window of an attached client; outside tmux, it uses the terminal's focus reporting. A turn that ends under your eyes shows done on the status bar and nothing else. [notify] suppress_focused = false turns this off.

Persistent desktop notifications

For notifications that remain until heph no longer needs you, enable the native freedesktop backend:

[notify]
desktop = true
channel = "none" # desktop only; leave command unset too

This uses the Linux desktop session's D-Bus notification service directly. No libnotify or notify-send is needed. It can also run alongside the existing terminal channel and command hook if you want both.

Heph requests no expiration and keeps one desktop notification per process, updating it as attention requests change:

  • Permissions and questions clear when answered or cancelled.

  • Reviews clear when resolved or put off, or when their turn ends.

  • Finished-turn notifications clear when the session starts new turn work, switches ref, or exits.

Quickly answered prompts disappear from the one-second delivery queue too. Focusing the pane suppresses new notices but does not clear an existing one. Dismissing a notification on the desktop does not cause heph to recreate it for that same condition; a new condition can notify again.

Your desktop daemon decides whether an unexpired notification stays visibly onscreen or lives in its notification center. Heph cannot force a pinned banner. If the service is unavailable, heph logs the failure and the TUI keeps working; terminal and command delivery are independent.

How it reaches you

Heph decides once, at startup, which channel this terminal answers to:

ChannelWhen
osc9The terminal is iTerm2, WezTerm, Ghostty, Kitty, or foot (by $TERM_PROGRAM and $TERM): a desktop notification through the terminal, no daemon.
osc777The terminal is rxvt-unicode.
tmuxUnder tmux without allow-passthrough on, or under tmux in a terminal with no OSC support: the message shows in tmux's message line, in whichever window you are in.
bellAnything else: the terminal bell, which lights tmux's window flag and the terminal's urgency hint.

Under tmux with set -g allow-passthrough on in your tmux.conf, the OSC sequence passes through to the outer terminal and you get its notification. [notify] channel forces a channel; "none" sends nothing.

[notify] command runs a program of yours in addition, for delivery outside the terminal:

[notify]
command = "notify-send 'heph {project}/{ref}' '{message}'"

{event}, {ref}, {project}, and {message} are substituted. The Notifications chapter lists the per-event switches.

Picking Up Where You Left Off

Every turn is recorded as it happens, so nothing you were in the middle of is lost when you leave, when the terminal closes, or when heph is killed. This chapter is about what you find when you come back.

The conversation is there

heph --continue opens your latest branch conversation, or heph --continue NAME / heph --continue FULL-UUID opens a specific one, with everything that was said on the screen: your prompts, the model's replies with their thinking folded, every tool call with its result, and the line that ended each turn. Your prompt history is there for Up. A half-typed prompt you left in the input is back in it.

The interactive mode is kept too: code, plan, or design, including the class /code returns to and your selected plan/design tool set. A successful mode change is saved immediately, even before you send another prompt. Resume and /switch restore each conversation's mode, model role, and tool catalog. Explicit startup flags (--plan, --design, --review, --quick, or --tools) override the saved choices. Older conversations without saved mode state start in code mode. /new and /fork inherit the active mode. heph exec uses its own class flags and does not change the saved interactive mode.

Resuming by publication branch

If you remember the Git branch rather than the conversation name:

heph --continue-branch heph/auth-refactor

Heph searches the containing checkout, including from a subdirectory or with -C, under the effective data root. The branch must match exactly, including case, against a current reserved publication branch or a recorded past local publication. Merely starting from that branch is not a match. Archived sessions participate; subtask refs do not.

One match resumes directly with the usual history, draft, routing, mode, and pending review. Several matches show a numbered terminal list, newest substantive activity first. Each entry includes its name and full UUID, creation and activity times, mode, tip, base/current branch, publication history, and full cached /summary content with age and coverage. Missing summaries are labelled; stale summaries remain visible with their warning. Choosing makes no provider call and does not refresh summaries.

Enter a session number to resume. Invalid numbers prompt again. Press Enter or type q to cancel; EOF also cancels. There is no default choice, even with -y or --yes-really. Without a controlling terminal, select explicitly with heph --continue FULL-UUID. No matches error without starting a new session; locked selections still error. Selecting a past publication does not change the session's current publication branch. The flag excludes startup prompts, --continue, --fork, --adopt, and subcommands.

Finding a saved session

heph sessions reads saved databases, not just live tmux panes. It selects the current checkout, including from a subdirectory, with archives hidden. These are compatibility changes from the earlier global, archive-inclusive inventory. Use explicit global or archive selectors when needed:

heph sessions --limit 20
heph sessions --summary --limit 20
heph -C /path/to/project sessions
heph sessions --name auth
heph sessions --branch heph/auth --published
heph sessions --json
heph sessions 'auth|login' --uncommitted
heph sessions --all-projects --include-archived
heph sessions show UUID
heph sessions show UUID --diff
heph sessions open UUID

The compact table shows UUID, name, reserved branch, independent work flags, runtime, and last recorded work/publication activity. show and JSON retain full source and shadow paths, base, cached summary freshness, blockers, and publication history. --branch searches base, current, and historical branches. A reserved branch does not prove a commit succeeded; --published requires a recorded success. These are local /commit publications, not remote git push events. Earlier publications are unknown, and older source paths remain unknown until the project is opened again (or supplied with -C for the listing).

Add --summary to see each session's full cached work summary in a SUMMARY column. Missing caches show -, and older caches are marked [stale]. This reads the same cache as /summary without generating or refreshing it, probing Git/runtime, or updating activity. --summary --json includes saved caches as summary objects; the flag conflicts with UUID-only --ids output.

Stopped sessions remain discoverable without a worktree. After removing a checkout or Git worktree, use heph -C /former/worktree sessions with its original root path. The missing path selects that former project's saved sessions, not a surviving parent checkout, and listing does not recreate project state. Relative paths are relative to the current directory. Alternatively, heph sessions --all-projects includes projects with missing source directories; add --include-archived for archives. Inspection migrates existing schemas but creates no worktree and runs no recovery. open UUID uses normal resume when no live pane matches; opening can therefore perform recovery. Unique UUID prefixes require at least six characters.

A regex matches names and branch data, case-sensitively unless it starts with (?i). Use --match for patterns that look like subcommands. All selectors combine with AND. --uncommitted includes accepted private snapshots and unsnapshotted source files; a clean Git worktree is not proof of publication. --merged requires incorporated publications and no private remainder. --unmerged requires positive absence evidence; failed ancestry after a squash or rebase is unknown, selected with --unknown, not proof of lost work.

--runtime STATE selects the separate runtime dimension. --inactive-for 30d uses recorded work or publication, not resume or inspection time. Explicit --disk-usage counts owned worktree files without following links or claiming shared caches as reclaimable. --ids emits full UUIDs and conflicts with --json. JSON includes sessions and diagnostics; incomplete coverage exits nonzero but preserves successful rows.

Ordinary listing takes the fast metadata path: it opens current schemas read-only, batches inventory reads, and displays labelled cached observations or not-inspected. It does not probe Git or sockets. Use show or work/runtime filters for fresh evidence; they may take longer. Actual draft/name edits also advance substantive activity, while resume, inspection, and summary reads do not. Cached flags never authorize cleanup.

Semantic discovery

heph sessions find 'the OAuth refresh bug' --limit 5
heph sessions find 'the flaky build workaround' --include-archived --json

The default is up to five likely matches on the configured fast tier. Every selected candidate is considered in bounded batches, then finalists are reranked from compact relevance assessments. Only metadata and cached summaries are sent, with missing/stale and truncation markers. No summaries are generated, no source files or transcripts sent, and no tools offered. Returned IDs are checked against candidates. A recommendation is not merge evidence or permission to act.

--max-cost sets a positive finite request cap, default $0.50, checked before the next request including its projected cost. --profile chooses a configured provider profile. Empty input and zero limits fail; an empty candidate set makes no model request. Provider errors and malformed output are explicit, not replaced by lexical guesses. JSON exposes coverage, cuts, explanations, summary freshness, request count, and frozen-rate cost. All requests are charged to a detached audit in an existing project database, without creating a session or changing activity.

Archive, clean, or delete

heph sessions archive UUID
heph sessions clean --merged --inactive-for 30d
heph sessions clean --merged --inactive-for 30d --apply
heph sessions delete UUID --apply

Every action previews by default, even with UUIDs. IDs and selectors cannot be mixed. --apply authorizes that invocation's exact set; freeze a prior preview with IDs in scripts. Global actions need explicit --all-projects. In a terminal, deletion additionally shows history loss and requires typing DELETE.

Archive hides the session and keeps its files and history. Clean removes only its reconstructible owned worktree; opening it later restores retained state. Delete removes the ref and exclusive canonical transcript, tool history, and summary. Shared history, immutable request audits, and frozen charges remain. External publication branches, user-owned linked paths, shared caches, and shared object stores are never independently reclaimed per session. No object garbage collection runs.

Cleaning and deletion require proven merged or untouched empty work, with no live owner, pending review/recovery/base change, draft, unknown state, or inconsistent resource. There is no force option. Application rechecks ownership and eligibility under locks and reports each changed, skipped, or failed target; skips/failures or incomplete coverage exit nonzero.

Retry interrupted maintenance using the same action, exact UUID, and --apply. Deletion receipts support retry after the ref disappears. Pre-commit refusal restores quarantine and releases the resume guard. After the database commits, retry completes only the remaining owned cleanup. Ordinary resume refuses unfinished maintenance rather than silently repairing or racing it.

Adopting a session from another checkout

If a linked Git worktree was removed, its published branches may still exist in the main repository, while heph's session still belongs to the removed path. From the main checkout, adopt the session by full UUID:

heph --adopt 01a11228-9185-70e7-bb6c-73a886e16220
heph -C /path/to/main --adopt FULL-UUID -y

The confirmation shows the old project, whether it still exists, the destination, UUID/name, private storage, saved mode and routing, branch/base/pin, draft size, retained history, and latest publication. The default is no. -y / --yes skips this check and retains its usual Level 2 tool auto-approval after resume. Your restored draft is not submitted automatically. --adopt cannot be combined with a startup prompt, --continue, --fork, or a subcommand.

Discovery uses the effective data root, including archived sessions. The old checkout need not exist, but its heph database and shadow repository must. Adoption requires shared Git history and refuses live ownership, unfinished turns or apply/synchronization/maintenance, pending launch handoffs, and destination identity/name/branch conflicts. Stop or recover unsafe work first; there is no force option.

The UUID, conversation, saved settings, private snapshots, pending-review roots, staged files, and non-ignored dirty files follow the session. Consumed launch records retain their original input and fork checkpoint, even after later navigation. Worktree links use the destination config and checkout. Other source sessions and shared history stay put. Original private resources, ignored files, request audits, and caches are not deleted. Global accounting counts copied request IDs only once. No publication branch or main checkout file is changed by adoption itself.

After transfer, ordinary resume runs. If interrupted, rerun the same command with the same destination and data root. A durable journal blocks ordinary resume and lifecycle actions until verification and source-ref retirement finish. Do not delete it to bypass recovery. If the session is already in the destination, adoption simply resumes it.

A review you put off

If a turn ended with changes you did not review, because you pressed Esc on the brief or stopped partway through the hunks, or you were in a script with no terminal, or a gate kept failing and you were not there to look, the changes waited in the worktree, and the conversation was marked as waiting. Its snapshot is only what you accepted; nothing undecided is ever taken as accepted.

When you next open it, the review is offered at once, before anything else:

-- ref main has a review waiting from turn 01a0c63e

and the brief opens, for the same round the turn stopped in, with the change staged as it was, or with exactly the hunks you had not reached. Decide it as usual. Within a session, the review comes first too: a prompt you type while it waits opens the review, and the prompt is queued behind it, so its turn starts from what you decided. /review opens it on the spot.

Putting the review off again is allowed, and safe: the next prompt's turn carries the unreviewed work onto its own chain, each hunk still credited to the call that wrote it, and reviews it together with its own work. The work is never quietly absorbed as if accepted.

The same happens from the command line:

heph exec --continue main            # offer the review, exit when decided
heph exec --continue main 'and now...'  # the review, then a new turn

A turn heph was killed under

If heph dies with a turn in flight, from kill -9, a crash, a closed terminal, or two presses of Ctrl-D, the next heph on the project finds the turn and closes it as interrupted by exit: the model's stream is dropped, a tool call left without a result gets one saying so, and the worktree is put back at the last thing the model wrote. If the turn had changed anything, that change is offered for review as above, exactly as if you had pressed Esc while it ran. Gates did not run on it, and run on what you keep.

Two plain heph invocations create distinct UUIDs and worktrees. Resuming a conversation already leased by another process errors. A turn belongs to the process that started it, and another process never mistakes a live turn for an abandoned one.

A failed save or full disk

If a tool result could not be saved, clear enough disk space before continuing. The next request repairs missing call/result pairs even when the failed turn was already closed. Saved results stay intact; only missing ones get an error saying the call may have executed. Heph does not rerun the tool automatically or rewrite the stored conversation. Inspect the current files and external state before retrying a call whose outcome is uncertain.

An apply that was interrupted

The one moment heph writes to your tree is the apply at the end of a review. It journals the merge first: what your files were, what they are meant to become. If it dies partway through writing, the next start finishes the job before any turn begins. For each file, if your tree still has the old content it is written; if it already has the new content it is left; if it has something else, you edited it in the meantime, and your version stands. Your tree is never left half-written, and nothing about the recovery is guesswork.

What is not kept

The permission approvals you gave with a last for the session. Approvals you gave with A are in .heph/config.toml and last. The constraints from a review's rejections last for their turn only; a new turn starts clean.

Where it all is

Everything is under ~/.local/share/heph/, keyed by a hash of the project's path: the database of turns, the shadow git repository, and the worktrees. Deleting a project's entries there forgets its conversations and nothing else; your repository is untouched. See Where Heph Keeps Things.

Configuration Files

Heph reads two TOML files and merges them, project over user.

FileScope
~/.config/heph/config.tomlYou, on every project. $XDG_CONFIG_HOME/heph/config.toml if that variable is set.
.heph/config.tomlThis project. Check it in if the team shares gates and permissions.

Both are optional. With neither, heph finds a provider in the environment and writes the user file on its first run (The First Run). An explicit ollama/ model instead discovers that server and bootstraps absent routing without writing config; see Providers. --user-config FILE and --project-config FILE select other read and write destinations. Overriding one leaves the other layer at its default path.

The sections

SectionWhat it configuresChapter
[providers]How to reach a model endpointProviders
[profiles]Named sets of tiers: which model does which workTiers
[tiers]The profile default, written wholeTiers
[permissions]What runs without asking, what never runsPermissions
[permissions.repeat]Soft and hard repeated-call checkpointsPermissions
[locks]Exclusive named execution resourcesExecution Locks
[network]Which hosts web_fetch reaches without askingNetwork
[[gates]]Commands that must pass before reviewGates
[review]How you reviewReview
[worktree]What is copied into the worktree, what is linkedWorktree
[toolsets]Named read-only tool lists for plan turnsTool Sets
[design]Where design turns may writeDesign Turns
[notify]Which events leave the pane, and howNotifications
[input]Attachments, ! commands, the clipboardInput
[ui]Image displayUI
[context]What of the conversation each round carriesContext
[decisions]Decision and command-output policyTiers
[skills]Which discovered skills the model may seeSkills
[git]Which branch conversations start fromGit

A section heph does not know is accepted and ignored, so a file written for a later heph still loads. A key it does not know inside a section it does know is an error, so a typo is caught rather than silently ignored.

[locks] configures exclusive execution resources, separately from permissions.

How the two files merge

Merging is by section, and each section merges the way that makes sense for it:

  • [providers] and [tiers]: by name. A tier named in both files is the project's whole entry. A provider named in both files is also replaced whole, including Ollama settings and model overrides.

  • [profiles]: by name, then field by field: a project profile's extends and each tier field it writes replace yours, and the rest of yours is kept. default_profile is the project's when its file sets it.

  • [permissions]: the lists concatenate. Project deny patterns are added to yours, never replace them. A project [permissions.repeat] table replaces the user's limit pair as a unit; omitted keys in that table use defaults.

  • [locks]: by name, appending project patterns to user patterns and deduplicating identical entries. An empty project array cannot remove a user requirement. This policy is a startup snapshot; restart to change it.

  • [decisions]: field by field. Each project key overrides the user key; omitted keys retain user policy values. command-output-commands replaces the whole user argv-contract list, including with an empty list. Native progress is experimental; command-output visibility is opt-in. Other decision hooks remain unavailable.

  • [network]: the host lists concatenate; defaults is the project's when its file sets it.

  • [[gates]]: concatenate, user gates first.

  • [toolsets]: by name; a project set replaces a user set of the same name.

  • [design]: whole. A project file's [design] replaces yours.

  • [skills]: the disabled lists concatenate, like [permissions], so a project can disable more skills, never fewer. project is read only from the user file; a project file that sets it fails to load.

  • [review], [worktree], [notify], [input], [ui], [context], [git]: field by field. A key the project file sets wins; a key it leaves out keeps your value. Since TOML cannot say "unset", a project file cannot remove a key you set, only give it another value. [worktree] link lists concatenate.

A complete example

The user file, for someone with both an API key and Bedrock:

[providers.anthropic]
api_key_env = "ANTHROPIC_API_KEY"

[providers.bedrock]
region = "us-west-2"

[tiers.frontier]
models = ["bedrock/us.anthropic.claude-opus-5", "anthropic/claude-opus-5"]
roles = ["coding", "review"]

[tiers.workhorse]
models = ["anthropic/claude-sonnet-4-6"]
roles = ["research", "summarize"]

[tiers.fast]
models = ["anthropic/claude-haiku-4-5-20251001"]
roles = ["quick", "commit-message", "title", "progress-check"]
max_tokens = 2048

[review]
interactive = "lazygit"

[notify]
command = "notify-send 'heph {project}/{ref}' '{message}'"

A project file, checked in:

[permissions]
approve = ["exec(cargo) args(*)"]
deny = ["edit(Cargo.lock)", "exec(git) args(push *)"]

[[gates]]
name = "check"
paths = ["**/*.rs", "Cargo.toml"]
command = "cargo check --all-targets 2>&1"

[[gates]]
name = "test"
paths = ["**/*.rs"]
command = "cargo test 2>&1"
retry_limit = 2

[worktree]
link = ["node_modules"]

Editing live configuration

Native Ollama edits reuse startup discovery only for the same provider ID and normalized endpoint. Permission edits need no network discovery; model limits and capability restrictions are validated anew. A different server or an undiscovered model cannot borrow that snapshot. Configure those outside the live editor and restart to discover them before using live edits.

Config is live project or user state, not conversation source state. Reads and writes use the original files, even from a shadow worktree and even if .heph/ is ignored by Git. If the config is tracked, saving leaves an ordinary uncommitted change in the original checkout; it does not publish a commit.

A on an ordinary permission prompt appends the edited pattern or host to [permissions] promoted or [network] promoted in the effective project config. The heading names the destination, including --project-config. Comments and unrelated formatting survive. If saving fails, the approval is explicitly session-only.

For other changes, ask the agent to use config_view, then config_edit. Both accept target = "project" (the default) or "user". The edit supplies the complete current file as old and the complete proposed file as new. Its once-only permission prompt shows the actual path and before/after text. It always asks, including with --yes, --yes-really, remembered patterns, or --accept-edits. Generic edit, create, and search_replace cannot edit heph config in the shadow. Plan and design turns cannot call config_edit.

Writes validate TOML, config types, project trust, and merged model settings, then atomically replace the file. Concurrent heph writes serialize; a stale old refuses the edit instead of overwriting another change. The writer keeps sidecar .toml.lock files beside the destinations. Leave these in place while heph sessions run; ignore them in Git if you track your config.

Permission and network policy refresh before the next tool decision in every session using these paths. Unreadable or invalid config pauses all tools, including config tools, at a heph-owned error. Edit the authoritative file externally, then press r to retry the pending call under the repaired policy. Press s or Esc to stop. Retrying a still-broken file shows the error again. Ordinary policy denials still go to the working model as tool results.

Alongside the error, heph requests advisory guidance from the selected profile's quick role (normally the fast tier), using the last successfully loaded provider settings. This separate, tool-free request sends the running version's embedded config docs, the error, and both authoritative config files to that provider. It does not send conversation history or add messages to the working model's context, and cannot edit files or approve a call. Usage is recorded as config-help. Advice has a 20-second deadline and a 1024-token output ceiling; files over 64 KiB or packets exceeding the helper model's context window skip advice. Retry and Stop work immediately, even if advice is unavailable. Closing the prompt cancels pending guidance.

Startup config errors remain ordinary heph errors; no helper runs before a usable provider configuration exists. Non-interactive runs fail with the config diagnostic rather than asking the working model to fix it. Other settings require restarting heph. /skills --reload refreshes skills without restart; a running turn keeps its skill snapshot.

Providers

A provider is an endpoint heph can send requests to, and how to authenticate. Configure them under [providers.<id>].

[providers.anthropic]
api_key_env = "ANTHROPIC_API_KEY"

[providers.bedrock]
region = "us-west-2"
profile = "work"

[providers.bedrock-openai]
region = "us-east-1"
profile = "work"

[providers.openai]
api_key_env = "OPENAI_API_KEY"

[providers.gateway]
kind = "messages"
base_url = "https://llm.internal.example/anthropic/"
api_key_command = "pass show llm-gateway"
headers = { "X-Team" = "platform" }

[providers.gateway.models."claude-sonnet-4-6"]
context_window = 200000
default_max_tokens = 8192
cost_per_1m_in = 3.0
cost_per_1m_out = 15.0
cost_per_1m_in_cached = 3.75
cost_per_1m_out_cached = 0.3
can_reason = true
supports_attachments = true

Catalog providers and custom providers

Heph ships a catalog of providers and models, with endpoints, rates, and capabilities; heph models prints it. A provider whose id is in the catalog (anthropic, bedrock, bedrock-europe, bedrock-openai, openai, and others) needs only authentication in config; everything else comes from the catalog, and any key you give overrides the catalog's value.

A provider whose id is not in the catalog is a custom one: a gateway, a proxy, or a local server. A Messages or Responses endpoint must give base_url and at least one model under models, with rates. Native Ollama endpoints instead discover their installed models; they need no manual catalog.

The [providers] section replaces the environment's choice entirely: once you name any provider, heph no longer looks at ANTHROPIC_API_KEY or AWS credentials to decide which provider to use. It still reads them for authentication where config says so.

Keys

KeyApplies toMeaning
kindanymessages (default), bedrock, responses, bedrock-responses, ollama, or vertex. Catalog providers infer it. vertex is recognized but not implemented; naming it is an error that says so.
api_key_envmessages, responses, ollamaEnvironment variable holding the API key. Default for anthropic: ANTHROPIC_API_KEY; for openai: OPENAI_API_KEY.
api_key_commandmessages, responses, ollamaA shell command whose stdout is the key, for password managers. Takes precedence over api_key_env.
base_urlany but vertexThe endpoint root. Required for a custom provider; overrides the catalog for a known one. For responses, the /v1 root. For the Bedrock kinds, the endpoint host (a VPC endpoint, say).
headersmessages, responses, ollamaExtra request headers, as a table.
regionbedrock, bedrock-responsesAWS region. Default: AWS_REGION, then AWS_DEFAULT_REGION, then the profile's region. No region anywhere fails the first request with a message saying so.
profilebedrock, bedrock-responsesAWS profile name. Default: the SDK's default credential chain, which is what aws-vault exec and SSO sessions feed.
cachinganyPlace cache breakpoints (default true). Set false for an endpoint that rejects them; every request then pays full input rate.
cache_ttlanyHow long the tool definitions, system prompt, and earlier turns stay cached: "1h" (default) or "5m". An hour costs more to write and survives a coffee break; set "5m" for an endpoint or an older model that rejects ttl. The Responses kinds ignore it.
models.<id>anyA model declared in config; see below.
num_ctxollamaRuntime context override; model context_window takes precedence
keep_aliveollamaDuration string or integer seconds; omitted preserves residency policy

project, for vertex, is accepted with the section but unused until Vertex lands.

Three protocols

The messages and bedrock kinds speak Anthropic's Messages API and carry Claude. The responses and bedrock-responses kinds speak OpenAI's Responses API and carry GPT: openai in the catalog is the first, with a bearer key; bedrock-openai is the second, signed with the same AWS credential chain as bedrock and reaching the us. and global. inference profiles (global.openai.gpt-5.6-sol and the like).

A conversation is stored in one form whichever model wrote it, and heph translates it for a GPT provider on every request, so you can point a tier at GPT and another at Claude in the same project, and switch a conversation between them across turns. What does not carry across is the model's private reasoning: each API reads only its own, so heph stores GPT's reasoning items as the API returns them and sends them back the same way, as it does Claude's thinking, and a tier's providers must all speak one protocol. Naming a Claude provider and a GPT provider in one tier is a config error that says which protocols it found.

GPT reasoning is billed as output whether or not the endpoint returns a summary. When a summary is returned, heph displays it in the folded thinking line. Cache reads on Bedrock show from the second request onward and can lag the first by a moment.

Maintained Bedrock metadata

The embedded catalog includes AWS-verified US/EU Claude metadata and US/Global GPT Runtime profiles, including Claude Opus 5.5, Sonnet 5.5, Haiku 5.5, US Fable 5.1, and GPT-6 Astra, Sol, and Luna. Existing provider defaults are unchanged. Every included GPT family uses Standard pricing with the exclusive 272000-input-token tier and the US CRIS premium. Haiku 5.5 reprices the whole request at five times its short-context rates above 100000 input tokens, including cached input. Opus 5.5 and Haiku 5.5 default to medium effort in their API metadata, while Sonnet 5.5 defaults to high. An explicit tier effort still wins.

The US/EU Claude providers are replaced by a maintained file after loading the Catwalk snapshot, and GPT is added by a separate supplement. make catalog does not overwrite either file. See the AWS model cards and the verification notes in crates/heph-catalog/data/README.md for pricing sources and scope. Pricing is frozen per request, so a catalog refresh does not reprice history.

The old EU Opus 4.1 and US/Global GPT-5.4/5.5 Runtime IDs are not supported and have been removed. AWS lists GPT-5.4 and GPT-5.5 as Mantle-only, not Runtime profiles. Opus 5.5 and Fable require thinking; set thinking = "adaptive" on their tiers, rather than "off". The catalog does not select Ultrafast service or include access-restricted cybersecurity model families.

Explicit model pricing in user/project config continues to replace catalog pricing, including the basis and tiers. Remove or update stale overrides if you want the refreshed catalog prices; declaring only capabilities still replaces pricing with the config's default zero rates.

The openai-compat providers the catalog lists (heph models --all) are not loaded on their own, since most speak only Chat Completions, which heph does not. One that speaks the Responses API is enabled by naming the kind:

[providers.xai]
kind = "responses"

The endpoint, key variable, and models then come from the catalog as for any catalog provider.

Declaring models

[providers.<id>.models."<model-id>"] declares a model, or adds to a catalog model's description:

KeyMeaningDefault
context_windowTokens the model acceptsCatalog, else 0
default_max_tokensmax_tokens when the tier gives noneCatalog, else 4096
cost_per_1m_inUSD per million uncached input tokens0
cost_per_1m_outUSD per million output tokens0
cost_per_1m_in_cachedUSD per million cache-write tokens0
cost_per_1m_out_cachedUSD per million cache-read tokens0
can_reasonSupports extended thinkingCatalog, else false
supports_attachmentsAccepts images and documentsCatalog, else false
context_tier_basisThreshold count: input, output, or totalinput
context_tiersWhole-request rates above token thresholds[]

When a declared model is also in the catalog, the rates always come from config, because declaring a model is how you say what it costs you (a gateway that bills differently, a local model that costs nothing); everything else comes from config where given and the catalog otherwise. A model's reasoning_levels, which decide whether it takes an effort level or a thinking budget, come from the catalog only.

Context-dependent prices

For an endpoint that changes prices above a token-count threshold, supply all four rates in each tier (USD per million). context_tier_basis selects the threshold count:

  • "input" (default): uncached input plus cache reads and writes.

  • "output": all generated tokens, including reasoning.

  • "total": input plus output, including cached input and reasoning.

The following example matches AWS Standard Global CRIS pricing for GPT-6.1 Sol, verified in October 2026. These prices are already in the bundled catalog; this shows how to override pricing explicitly:

[providers.bedrock-openai.models."global.openai.gpt-6.1-sol"]
cost_per_1m_in = 2.0
cost_per_1m_out = 10.0
cost_per_1m_in_cached = 2.5  # cache write
cost_per_1m_out_cached = 0.1 # cache read
context_tier_basis = "input"

[[providers.bedrock-openai.models."global.openai.gpt-6.1-sol".context_tiers]]
above = 272000
input = 4.0
output = 15.0
cache_write = 5.0
cache_read = 0.2

above is exclusive: exactly 272000 input tokens use base rates; 272001 input tokens reprice all input, cached input, and output at long-context rates. The largest matching threshold wins, regardless of order. Rates must be finite and nonnegative (zero-cost models are allowed), thresholds must be distinct, and every tier must specify all four rates. A matched tier prices the whole request, not just excess tokens; one-hour cache writes remain twice the selected input rate. Graduated per-excess-token pricing is not supported.

For an output-length threshold, set context_tier_basis = "output" in the model table, before its [[context_tiers]] entries. Use "total" when the threshold is based on combined input and output length. Estimates use estimated input and zero output. Final provider usage can cross a threshold the estimate did not.

Request rows freeze base rates, the basis, and tiers before sending, and final usage chooses the applicable tier. Later config/catalog updates do not reprice historical requests. Older stored pricing without a basis keeps input-based selection. Declaring a catalog model replaces its entire pricing definition, including the basis and tiers. User/project provider tables merge by provider ID, with the entire project provider definition winning for the same ID.

The bundled us.openai.gpt-6.1-sol entry includes AWS's 10% US CRIS premium on both base and long-context prices. The bundled prices are for Standard requests; heph does not select Ultrafast or automatically match rates to a service tier. Declaring a provider in config still requires its normal authentication and endpoint settings.

Native Ollama (experimental)

With a running Ollama server and an already installed tool-capable model:

heph --model ollama/qwen3-coder:latest
heph models --provider ollama

No key or configuration is needed. Heph does not install Ollama, download weights, or choose among models. Discovery lists exact names, digests, capabilities, parameter sizes, quantizations, and context metadata; unknown values are not guessed. The name after the first / stays intact, including namespaces and quantization tags. An omitted tag resolves to the installed :latest name only.

The reserved ollama provider uses http://127.0.0.1:11434, overridden by OLLAMA_HOST or, with highest priority, an explicit base_url. Host/port forms are accepted; wildcard bind addresses and API paths are refused.

[providers.ollama]
kind = "ollama"
# Optional runtime allocation and residency policy:
num_ctx = 32768
keep_alive = "5m"

[tiers.local]
models = ["ollama/qwen3-coder:latest"]
roles = ["coding", "review", "design", "research", "summarize", "quick", "commit-message", "title", "progress-check"]
max_tokens = 4096

A custom server ID uses kind = "ollama" and its server-root base_url. Optional api_key_env, api_key_command, and headers can authenticate a reverse proxy. Loopback bypasses environment proxies; redirects are refused. Remote plaintext HTTP is warned about. keep_alive accepts a duration string or integer seconds; omit it to keep server residency policy. num_ctx and keep_alive are rejected on other kinds.

The runtime context is the model's explicit context_window, else provider num_ctx, else the model-configured num_ctx, else 32768. Explicit settings above a known architecture ceiling fail; inherited values are clamped. The architectural ceiling is shown separately and stays unknown if not advertised. Preflight reserves the requested output (including reasoning), 2048 template tokens, and 4096 per image, with an approximate text estimate of bytes divided by three rounded up. Requests that do not fit fail before inference; allocation errors suggest lowering context explicitly, never automatic resizing.

Unknown tools require supports_tools = true in the model table. Optional supports_images and thinking_controls = { values = [false, true] } fill missing metadata or restrict features; they cannot enable known unsupported features. No tier thinking setting means the server's default; explicit off or named effort must match supported controls. Thinking is stored and replayed for the same native model; other protocols and models omit it.

The progress-check role uses the same native route and tier controls. It offers only report_progress and validates the exact report because Ollama cannot force tool selection. Output is capped at 2048 tokens and the existing classifier deadline remains ten seconds; invalid, incomplete, or late reports require human approval rather than automatic renewal or cloud substitution.

Text-file attachments remain text and need no vision. Images require vision, including replayed history. Inline base64 PNG, JPEG, GIF, and WebP are accepted, with limits of 10 MiB, 32 million pixels each, 16 images across history, and 40 MiB per serialized request. Decoding is limited to 128 MiB allocation. PDFs, remote image URLs, and unsupported content fail rather than disappear.

There is one active inference per endpoint in a process. Foreground requests precede queued background naming; cancellation releases the client permit, not a promise of instant GPU reclamation. Connect, discovery, initial-response, and stream-inactivity timeouts are 10, 30, 300, and 120 seconds respectively. Tools run only after a valid terminal response. Zero default rates mean zero API charges, not zero hardware cost. Native timings, queue wait, endpoint, and digest are recorded; missing usage is explicitly incomplete.

A loopback server may route to cloud models. Chat discovery refuses known cloud-backed models and warns about unknown locality. A separate decide tier accepts any configured model without discovery or locality checks; see decision tiers. Set OLLAMA_NO_CLOUD=1 on the server and restart it for a stronger boundary. Network tools retain their ordinary permissions. Route every role locally to avoid cloud inference.

Tool replay and PNG/JPEG/GIF/WebP smoke tests passed on Ollama 0.34.2 with installed qwen3.5:9b; this does not establish a minimum supported version. Older-version and manual interactive release validation remain outstanding.

A compatibility-endpoint local model

[providers.local]
kind = "messages"
base_url = "http://localhost:8080/v1"
api_key_env = "LOCAL_KEY"

[providers.local.models."qwen3-coder"]
context_window = 128000
default_max_tokens = 8192

[tiers.offline]
models = ["local/qwen3-coder"]
roles = ["quick"]

Rates default to zero, so the status bar's cost stays at $0.00 for that tier. A messages provider needs a key source even if the server ignores the key: without api_key_env or api_key_command, the first turn fails with no api_key_env or api_key_command configured. Point it at any set variable.

Keeping Bedrock credentials fresh

Heph asks the AWS credential chain for credentials when the ones it holds are a minute from expiring, so any source the chain can renew keeps a long session signed in: aws-vault's credential server (aws-vault exec --ecs-server work -- heph), an SSO profile, or a credential_process profile. What cannot be renewed is a set of fixed credentials exported into the environment, which is what plain aws-vault exec work -- heph provides; when they expire, heph says so and names the alternatives. Bedrock Credentials walks through setting up each one.

Bedrock regions

The catalog has two Bedrock entries, because the inference profile ids differ: bedrock for US regions (us.anthropic.claude-...) and bedrock-europe for EU regions (eu.anthropic.claude-...). On the first run heph picks by AWS_REGION: an eu-* region gets bedrock-europe. If you configure [providers.bedrock] with an EU region, use the eu. model ids in your tiers, or configure [providers.bedrock-europe] instead.

Bedrock Credentials

Everything heph does on Bedrock is signed with AWS credentials, and the credentials most people have are temporary: they come from a role, an SSO login, or an MFA session, and they expire after an hour or a few. A heph session outlives them. This chapter is how to give heph credentials that keep working for as long as the session does, written for someone setting this up for the first time.

If you already know the AWS credential chain, the short version: heph asks the chain for credentials when the ones it holds are a minute from expiring, so any source the chain can renew keeps heph signed in. Static credentials in the environment are the one source nothing can renew. Skip to Which to choose.

What goes wrong, and why

The simplest way to run anything with AWS credentials is to put them in the environment. That is what these do:

aws-vault exec work -- heph              # exports fixed credentials, then runs heph
eval "$(aws configure export-credentials --profile work --format env)"; heph

Both set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN to values that were true at that moment. An hour later the session token has expired, and the values in heph's environment are still the old ones. Nothing can change a running process's environment from outside, so nothing can renew them. When that happens, the next request fails:

-- turn failed: provider bedrock: AWS credentials expired at 2026-09-21T23:31:50Z:
they were exported into the environment as fixed values, which nothing can renew.
Start heph again under fresh ones, or give it credentials that renew themselves:
`aws-vault exec --ecs-server <profile> -- heph`, an SSO profile, or a profile
with `credential_process` (the book: Bedrock Credentials)

The turn's work is kept; you restart heph under fresh credentials and continue the conversation. But you would rather not have to.

The fix is to give heph not credentials but a way to get credentials. The AWS SDK inside heph knows several such ways, and asks them again whenever what it holds is about to expire. Three are practical on a developer machine. All three work with the [providers.bedrock] section as heph writes it on the first run; you change how you start heph, or one line in ~/.aws/config, not heph's config.

Which to choose

You haveUse
aws-vault, and you run everything through aws-vault execaws-vault's credential server: add --ecs-server to the command you already type
An IAM Identity Center (SSO) login and the AWS CLIAn SSO profile: no aws-vault needed
aws-vault, and you want plain heph to work with no wrapperA credential_process profile
A role to assume on top of any of theseAny of the three; see Assuming a role
An EC2 instance or container with a roleNothing to do: the instance or task role renews itself

aws-vault's credential server

aws-vault keeps your long-lived AWS keys in the OS keychain and hands out short-lived session credentials. Its usual mode, aws-vault exec profile -- command, exports those credentials into the command's environment, which is the arrangement that expires under heph. Its --ecs-server mode does something different: it starts a small HTTP server on localhost for the life of the command, and tells the SDK where it is. The SDK fetches credentials from the server when it needs them, and aws-vault renews them behind the scenes.

Setup

You need aws-vault with a profile in it. If you have been using aws-vault, you have this. If not:

aws-vault add work        # prompts for an access key id and secret; stores them in the keychain

and a profile in ~/.aws/config:

[profile work]
region = us-west-2

Running heph

aws-vault exec --ecs-server work -- heph

That is the whole change: --ecs-server (or -s) where you used to have nothing. Everything else about aws-vault exec still applies: --duration to ask for a longer session, --mfa-token if the profile needs one, and the prompt for your keychain or MFA code at the start.

What is happening

Run the same with env instead of heph and compare:

$ aws-vault exec work -- env | grep ^AWS_
AWS_ACCESS_KEY_ID=ASIA...
AWS_SECRET_ACCESS_KEY=...
AWS_SESSION_TOKEN=...
AWS_CREDENTIAL_EXPIRATION=2026-09-22T18:31:32Z
AWS_REGION=us-west-2

$ aws-vault exec --ecs-server work -- env | grep ^AWS_
AWS_CONTAINER_CREDENTIALS_FULL_URI=http://127.0.0.1:34567/
AWS_CONTAINER_AUTHORIZATION_TOKEN=...
AWS_REGION=us-west-2

Without --ecs-server, the credentials themselves are in the environment, with an expiry an hour out. With it, the environment holds only an address and a token for reaching aws-vault's server. The SDK's default chain includes "container credentials" as a source, originally for ECS tasks, and follows that address. Each answer from the server carries an Expiration; when heph's credentials come within a minute of it, heph asks the server again, and aws-vault, still running as heph's parent, returns fresh ones, renewing the session against AWS as needed.

The server dies with the command, so the address is useless to anything else once heph exits, and the authorization token stops anything else on the machine from asking while it runs.

Checking it works

aws-vault exec --ecs-server work -- heph wire-check
wire check passed
  provider     bedrock
  model        us.anthropic.claude-haiku-4-5-20251001-v1:0
  ...

wire-check signs and sends one small request. If it passes, a session will.

If it does not

no identity-based policy allows the bedrock:InvokeModelWithResponseStream action: the credentials work but the profile's IAM identity may not call Bedrock. Use a profile that can; often that means a role (Assuming a role).

aws-vault: error: exec: running in an existing aws-vault subshell: you are already inside an aws-vault exec shell ($AWS_VAULT is set). Run from a plain shell, or unset AWS_VAULT first.

The SDK ignores the server and complains about missing credentials: the container provider needs AWS_CONTAINER_CREDENTIALS_FULL_URI, which aws-vault sets. If something in your shell startup clears AWS_* variables, it clears this too.

In tmux

Start the heph pane with the wrapped command and leave it: aws-vault exec --ecs-server work -- heph. The keychain or MFA prompt happens once, when the pane starts. A convenient alias:

alias heph-work='aws-vault exec --ecs-server work -- heph'

An SSO profile

If your organization signs in to AWS through IAM Identity Center (the service formerly called AWS SSO), the AWS CLI can hold that login for you and the SDK inside heph can turn it into credentials, renewing them as long as the login lasts. No aws-vault is involved.

Setup

You need the AWS CLI v2. Configure a profile for the SSO login:

aws configure sso

It asks for a session name, the start URL your administrator gave you (it looks like https://d-xxxxxxxxxx.awsapps.com/start or https://yourcompany.awsapps.com/start), the SSO region, then opens a browser for you to sign in, then lists the accounts and roles you may use. Pick one, and it writes two sections to ~/.aws/config:

[sso-session mycompany]
sso_start_url = https://d-xxxxxxxxxx.awsapps.com/start
sso_region = us-east-1
sso_registration_scopes = sso:account:access

[profile work]
sso_session = mycompany
sso_account_id = 123456789012
sso_role_name = DeveloperAccess
region = us-west-2

The sso-session is the login; the profile is one account and role reached through it. You may have several profiles on one session. Any profile of this shape, however it was written, works.

Running heph

Sign in, then run heph with the profile named:

aws sso login --profile work
AWS_PROFILE=work heph

or put the profile in heph's config so you can type heph alone:

# ~/.config/heph/config.toml
[providers.bedrock]
profile = "work"

aws sso login opens a browser; you approve the device code it shows, and it writes an access token to ~/.aws/sso/cache/. It does not touch the environment, so nothing is fixed at the moment you ran it.

What is happening

Each time heph needs credentials, the SDK reads the cached SSO token and asks Identity Center for role credentials, which come with an expiry, usually an hour. When those near expiry heph asks again, and gets a new hour, for as long as the token is valid. The token itself lasts as long as your administrator configured the session to: eight hours, a day, longer.

When the token lapses, requests fail with a message about loading the cached SSO token. Run aws sso login --profile work again, in any pane; you do not restart heph. The next credential refresh finds the new token.

Checking it works

AWS_PROFILE=work heph wire-check

If it does not

failed to load the cached SSO token: you have not run aws sso login for this session, or the token has expired. If you normally sign in through aws-vault (aws-vault login), note that aws-vault keeps its own token store; the SDK reads only ~/.aws/sso/cache/, so aws sso login is still needed.

The SSO session associated with this profile has expired or is otherwise invalid from aws sso login: run it again; it will open the browser.

Sign-in on a machine without a browser: aws sso login --profile work --no-browser prints the URL and a code:

Browser will not be automatically opened.
Please visit the following URL:

https://oidc.us-east-1.amazonaws.com/device?user_code=XXXX-XXXX

Open it anywhere you have a browser, on another machine if need be, and enter the code.

The profile has no sso_session, only sso_start_url in the profile itself: that is the older (legacy) SSO form. The SDK reads it too, but its tokens cannot be refreshed, only re-issued by logging in again; running aws configure sso to make a session-based profile is worth the two minutes.

A credential_process profile

The AWS config file can say "to get credentials for this profile, run this program". The SDK runs it, reads credentials and an expiry from its output, and runs it again when they near expiry. This is credential_process, and it lets plain heph with no wrapper renew through aws-vault, or through any tool that can print credentials.

With aws-vault

Add a profile to ~/.aws/config whose credentials come from aws-vault export:

[profile work]
region = us-west-2

[profile work-heph]
credential_process = aws-vault export --format=json work
region = us-west-2

work is your existing aws-vault profile; work-heph is a second profile that gets its credentials by asking aws-vault for work's. Then tell heph to use it:

# ~/.config/heph/config.toml
[providers.bedrock]
profile = "work-heph"

Now heph alone works, with no aws-vault exec around it. The first request of the session runs aws-vault export; when the credentials near expiry, an hour later, the SDK runs it again. aws-vault answers from the session it keeps in your keychain, so this is normally silent. If it has to prompt, for a keychain passphrase or an MFA code, the prompt lands under heph's screen where you cannot answer it; run aws-vault exec work -- true in another pane first, which stores a session, and heph's request then finds it.

A credential_process profile cannot also be the profile it exports: [profile work] must not name a credential_process that exports work, or aws-vault and the SDK call each other forever. Hence the second profile.

With the AWS CLI

The AWS CLI can be the program too. aws configure export-credentials prints credentials for a profile in exactly the format credential_process expects (its default --format is process), so a profile can borrow another profile's credentials, an SSO profile's included:

[profile work-heph]
credential_process = aws configure export-credentials --profile work
region = us-west-2

This is useful when work is an SSO profile and you want the profile = "work-heph" indirection for some other reason; with an SSO profile you can also just name it directly, as An SSO profile shows.

With any program

The program must print one JSON object to stdout:

{
  "Version": 1,
  "AccessKeyId": "ASIA...",
  "SecretAccessKey": "...",
  "SessionToken": "...",
  "Expiration": "2026-09-22T18:31:32Z"
}

Version is always 1. Expiration is RFC 3339, UTC. Leave Expiration out and the SDK treats the credentials as permanent and never asks again, which is right for a long-lived key and wrong for anything temporary. The program runs with the environment heph has, in an unspecified directory; give it absolute paths.

A script that reads from a password manager, or from a company tool that issues credentials, is a few lines:

#!/bin/sh
# ~/bin/aws-creds-from-op: credentials kept in 1Password as a JSON document
exec op read "op://Private/aws-work-session/credential"
[profile work-heph]
credential_process = /home/you/bin/aws-creds-from-op
region = us-west-2

Checking it works

heph wire-check

To see the chain decide, turn on the SDK's own logging:

RUST_LOG=aws_config=debug heph wire-check 2>&1 | grep -o '"provider":"[A-Za-z]*"'

prints each source tried and the one that answered, Profile for this arrangement.

If it does not

Error retrieving credentials from external process, could not parse response: the program's output is not the JSON above. Run it by hand and look; a common cause is an empty Expiration because the source it read from was not set, or extra text printed before the JSON.

external process exited with code exit status: 1. Stderr: aws-vault: error: export: profile 'work' not found: aws-vault reads ~/.aws/config through the same AWS_CONFIG_FILE variable the SDK does. If you point that variable at another file for testing, both profiles must be in it.

The request hangs, or the screen shows stray characters: the program is prompting under heph's screen. Interrupt the turn with Esc, y, run aws-vault exec work -- true in another pane to satisfy the prompt there, and send the prompt again; heph's next request finds the stored session.

Assuming a role

Often the identity that may call Bedrock is a role, not your user. All three arrangements handle that in ~/.aws/config, the same way the AWS CLI does:

[profile work]
region = us-west-2

[profile bedrock-dev]
source_profile = work
role_arn = arn:aws:iam::123456789012:role/BedrockDeveloper
region = us-west-2

Then use bedrock-dev wherever the examples above say work: aws-vault exec --ecs-server bedrock-dev -- heph, profile = "bedrock-dev", credential_process = aws-vault export --format=json bedrock-dev. The role's credentials expire on their own schedule (an hour by default) and renew the same way. For an SSO profile, the role is sso_role_name; there is nothing extra to assume.

If the role needs MFA, add mfa_serial = arn:aws:iam::123456789012:mfa/you to the profile; aws-vault prompts for the code when it first needs it.

What heph does with credentials

For completeness, the behavior all of this relies on:

  • Heph asks the SDK's default credential chain for credentials when it holds none, and again when the ones it holds are within one minute of their stated expiry. Credentials that state no expiry are asked for again after fifteen minutes. These are the AWS SDK's own margins.

  • Between refreshes, every round of every turn signs with the credentials in hand; a refresh is one call to the chain, not one per round.

  • The chain is consulted in the SDK's standard order: the environment; then the profile named by [providers.bedrock] profile or AWS_PROFILE or default, whether it holds static keys, SSO, credential_process, or a role over any of those; then a web identity token; then container credentials (the --ecs-server case); then instance metadata. The first that answers wins, so a [default] profile with keys in ~/.aws/credentials takes precedence over aws-vault's server.

  • If the environment holds fixed credentials with AWS_CREDENTIAL_EXPIRATION in the past, heph refuses the request before signing it, with the message shown at the top of this chapter. A 403 from Bedrock about an expired security token is reported the same way.

  • Heph never writes credentials anywhere: not to its database, not to its logs, not to config.

Checking any of them

heph wire-check

passes only if credentials resolve, sign, and are accepted by Bedrock for the coding tier's model. Run it once when you set an arrangement up, and again if a session fails in a way you do not understand; its error is the SDK's, in full.

RUST_LOG=aws_config=debug heph wire-check 2>&1 | grep -o '"provider":"[A-Za-z]*"'

shows each source of the chain as it is tried and which one supplied the credentials: Environment, Profile, EcsContainer, and so on.

Tiers and Profiles

A tier names one model, says which providers to reach it through, and which roles it fills. A profile is a named set of tiers, and every conversation runs on one profile: it decides where all of the conversation's work goes, its turns and its commit messages and titles alike.

Built-in profiles

Every configured provider the catalog has defaults for brings a profile named by its id, with three tiers:

TierRolesModel
frontiercoding, review, designThe provider's default large model
workhorseresearch, summarizeIts default small model
fastquick, commit-message, title, progress-checkIts default small model

heph models marks each provider's default large and default small models. heph profiles lists every profile with its tiers, the default first:

$ heph profiles
Default profile: anthropic

anthropic (anthropic's catalog defaults):
  frontier   coding, review, design             anthropic/claude-sonnet-4-6  thinking high
  workhorse  research, summarize                anthropic/claude-haiku-4-5-20251001  thinking off
  fast       quick, commit-message, title, progress-check  anthropic/claude-haiku-4-5-20251001  thinking off

Progress checks use this conversation's progress-check tier. Older custom layouts without that role use their quick tier; if neither exists, a repeated call checkpoint asks the user. Checks always disable thinking and cap output at 1024 tokens. See Permissions.

Experimental native progress

An explicit, separate decide tier can try Ollama native progress before the existing classifier. Configure any model Ollama can run, including custom names, aliases, and tags. Heph has no model allowlist and does not require advertised decision capability, a particular server version, or local weights. Decision-only models are not probed at startup; Ollama receives the configured name at runtime and determines whether it supports /v1/systemone and whether the request fits. Requests retain shared 64 KiB text-body bounds and typed answer validation, not model-specific prompt caps. API failures are shown as status messages before fallback. No decider is added to a bootstrap profile or pulled automatically. Live accuracy and latency validation remain unfinished.

[tiers.decide]
models = ["ollama/nimble:latest"]
roles = ["decide"]
timeout_ms = 1500

[decisions]
progress = "on"
high_probability_threshold = 0.85

For Clef Flash, set models = ["ollama/clef-flash:latest"]; a custom model might use models = ["ollama/team/my-decider:q8"]. Neither name needs registration in heph.

The positive timeout includes admission and native failover. Uncertain or failed native checks fall back once to the existing ten-second classifier. Conclusive native loops ask the human without another classifier; hard checkpoints and tool approval remain unchanged. progress = "off" keeps the checkpoint but asks the human without classification. With no decision tier, today's classifier is unchanged.

heph decide --schema progress --state FILE audits a bounded schema-state JSON fixture in its own diagnostic conversation and prints a proposed resolution. It never applies that resolution or invokes legacy fallback. Permission triage, turn-end classification, cloud-native decision adapters, and report/replay are not built. Choice-question chat emulation is available for the opt-in command output workflow below; configuring a decide tier alone does not enable it.

Successful command output

To keep routine successful build/test chatter out of the working model's context, enable this and restart:

[decisions]
command-output = "on"                 # defaults to off
command-output-commands = [["make", "check"]]

Ordinary direct cargo build, check, test, and clippy status commands have built-in eligibility rules. Exact program/argument arrays add project contracts; there are no wildcards or argument joins. This list is replaced by the project value, including an empty array. Permissions still control whether the command can run. Shell/background output is never compacted this way.

Enabling this can send complete eligible command logs and bounded task/intent evidence to configured native decision recipients, or the selected profile's quick route if no native candidate resolves before dispatch. It does not choose a new provider by price. Keep all these routes local if logs must remain local. Known environment-held credentials veto classification, but unknown secrets can still be transmitted. Native failure never starts a chat-model stage.

Exit zero and complete capture are prerequisites, not model judgments. A checked native or emulated probability of at least high_probability_threshold (default 0.85) for status_only selects a fixed command-success message. It does not assert that tests ran or all tests passed. Diagnostics, requested data, uncertainty, provider failure, and oversized evidence retain capped output. Logs of 400 bytes or fewer skip classification. The shared deadline is the native tier timeout, or 1,500 ms without a tier. Emulation caps output at 1024 tokens or the smaller quick-tier ceiling. No savings measurement is claimed, and live native compatibility is still experimental.

When inspecting output, ask the agent to set exec keep_output = true; this prevents capture/classification for that call. Help/query modes, test listing, compile-only tests, requested machine-readable output, and unknown argument modes retain ordinary output too. No chunked clearance is built.

Terse results include a read_output log ID. The tool selects stdout or stderr, a 1-based byte offset, and a positive byte limit capped at 16 KiB. Text rendering replaces invalid or split UTF-8; encoding = "hex" recovers exact original bytes. Streams are stored separately, not with a total ordering. Logs survive restart/adoption, are restricted to this conversation's ancestry, and are deleted with their tool-call evidence. Recovery never reruns a command.

Your own profiles

A [profiles.<name>] builds on another profile and changes only what it writes:

[profiles.work]
extends = "bedrock"                 # a built-in profile, or another of yours

[profiles.work.tiers.frontier]      # replaces this tier's models; keeps its roles and thinking
models = ["bedrock-openai/global.openai.gpt-5.6-sol"]

[profiles.work.tiers.fast]
thinking = "off"
max_tokens = 2048
  • With no extends, a profile builds on the default profile.

  • Each field a tier writes replaces the base tier's; the fields it leaves out, and the tiers it does not mention, are kept.

  • A tier the base does not have is new, and needs both models and roles.

  • Two tiers of one profile may not do the same role: the profile is refused with role title is claimed by tiers fast and extra.

  • A profile may not take a provider's name, and profiles may not extend each other in a loop.

The default profile

A conversation that names no profile runs on the default one:

  1. the profile default_profile names, at the top of the file:

    default_profile = "work"
    
  2. else the profile called default;

  3. else the only configured provider's built-in profile, or the only one the catalog has defaults for.

With several providers and none of these, there is no default, and heph says so when work needs a model: pass --profile, or set default_profile.

The default profile itself, with no extends, builds on the built-in profile of rule 3. So with one provider, a [profiles.default.tiers.fast] changes the fast tier and keeps the others:

[profiles.default.tiers.fast]
models = ["anthropic/claude-sonnet-4-6"]

[tiers]

The [tiers] table is the profile default, written whole: it extends nothing, so it must cover every role it needs. It is how heph was configured before profiles and still works; writing both [tiers] and [profiles.default] is an error.

[tiers.frontier]
models = ["bedrock/us.anthropic.claude-opus-5", "anthropic/claude-opus-5"]
roles = ["coding", "review", "design"]

[tiers.workhorse]
models = ["anthropic/claude-sonnet-4-6"]
roles = ["research", "summarize"]

[tiers.fast]
models = ["anthropic/claude-haiku-4-5-20251001"]
roles = ["quick", "commit-message", "title", "progress-check"]

Choosing for a conversation

FlagEffect
--profile NAMERun on this profile
--tier NAME=MODELRun one tier on another model: a tier name, provider/model-id, or a model id one configured provider offers. Repeatable
--model MODELShort for --tier on the tier doing the conversation's role (frontier for an ordinary turn). Other work keeps its own tiers
heph --profile work
heph --tier fast=claude-sonnet-4-6
heph --model us.anthropic.claude-opus-5-5     # this conversation's turns only

With no routing, an explicit --model ollama/NAME creates an in-memory local tier for all roles, including progress-check, and retains that bootstrap with the conversation. It does not write a config file or replace an invalid profile. Exactly one provider with catalog defaults supplies existing routing, as do default_profile, [tiers], and [profiles.default]. Multiple catalog defaults and valid named profiles without a selected default leave routing absent, so the explicit Ollama selection bootstraps rather than failing with an ambiguous-profile error. Configured profiles are validated first and keep their original layouts and inheritance. An explicit --profile still retains that profile's auxiliary assignments. Ollama has no automatically selected default installed model.

With an existing profile, --model changes one tier. A commit message still goes to the profile's commit-message tier, even when that is on another provider than the model you named: the profile is where you decide which provider sees what.

A conversation keeps its profile and overrides. heph --continue NAME routes the same way it did; flags given with it change what was kept and are kept in turn, and a new --profile drops the --tier changes made for the old one. /new and /fork start with the session's routing, and /switch to a conversation that has none gives it the session's.

heph tiers shows the tiers a set of flags gives, with overrides marked:

$ heph tiers --profile anthropic --tier fast=claude-sonnet-4-6
Profile anthropic (anthropic's catalog defaults; the default):
  frontier   coding, review, design             anthropic/claude-sonnet-4-6  thinking high
  workhorse  research, summarize                anthropic/claude-haiku-4-5-20251001  thinking off
  fast       quick, commit-message, title       anthropic/claude-sonnet-4-6  thinking off  (overridden)

In a session, /tiers opens a window over the conversation with the same profile: which tier the turns run on (marked >), each tier's roles, then its model and thinking on the line below, and the other profiles you could start with. Up and Down scroll it; any other key closes it.

Tier keys

KeyMeaningDefault
modelsprovider/model-id references, in order. All must name the same model; several entries mean several providers for it.Required for a new tier
rolesThe roles this tier fills, as a list.Required for a new tier
max_tokensOutput ceiling per request, thinking included.The model's catalog default_max_tokens
thinking"adaptive" or "off"."adaptive" if any role is coding, review, or design, else "off"
effortWith thinking on: low, medium, high, xhigh, or max.high

Thinking is also off for a cloud model the catalog says cannot reason. For Ollama, omission uses the model default; explicit thinking or effort is checked against discovered or overridden controls, not inferred from the name.

A tier's providers must all speak one protocol: Claude's Messages API or GPT's Responses API, or Ollama's native API (Providers). A tier that mixes them is refused when the config loads, with the two protocols named.

Roles

RoleUsed by
codingAn ordinary turn
review--review turns
design--plan and --design turns
quick--quick turns
researchNothing yet (subtasks)
summarizeWork summaries and context state updates
commit-message/commit's drafted message
titleNaming /fork and /new conversations

Work that needs a role no tier of the profile has fails with no tier has role coding; add it to a tier's role in config.

Several providers, one model

A tier with two entries is one model reachable two ways. Today heph uses the first entry whose provider is configured; automatic failover to the next on a failed request has not landed. Two entries naming different models are an error (a tier is one model on several providers): a tier is a price and quality point, not a list of alternatives. See Models and Cost.

Permissions

[permissions] says which tool calls run without asking and which never run. It can only narrow what a tool's level allows: nothing here makes a tool quieter than its level (Permissions explains the levels).

[permissions]
# Level 2 calls that run without a prompt.
approve = [
    "exec(cargo) args(*)",
    "exec(git) args(diff *)",
]

# Never allowed, at any level. The model receives matching patterns and reasons.
deny = [
    "exec(git) args(push *)",
    "exec(rm) args(*)",
    { pattern = "edit(Cargo.lock)", reason = "Use the package manager to update the lock file." },
]

# Written by heph when you press A on a prompt. Matches like `approve`.
promoted = [
    "exec(cargo) args(clippy *)",
]
KeyMeaning
approvePatterns whose matching Level 2 calls run unasked.
denyPatterns whose matching calls are refused, at any level, before anything else is considered.
promotedPatterns heph appends when you answer A to a prompt. They match exactly like approve; the separate list keeps what you wrote apart from what heph wrote.

Project lists are added to the user's, never replace them. A team's deny list in .heph/config.toml binds every member on top of their own.

web_fetch is the one tool whose approvals are not patterns: a fetch is allowed by its host, in [network]. A web_fetch(...) pattern here still matches the URL as text, which is what deny = ["web_fetch"] is for; to allow a host, add it to [network] allow.

Authored deny reasons

A deny entry is a pattern string or a table with a required string pattern and an optional string reason. Both forms may coexist; a table without a reason is equivalent to the string. Unknown fields, missing or invalid patterns, and nonstring fields are config errors. approve and promoted still accept only strings, as does [network].deny for hosts.

[permissions]
deny = [
    "exec(rm) args(*)",
    { pattern = "edit(generated/**)", reason = "Edit the source schema and regenerate instead." },
    { pattern = "edit(generated/api/**)", reason = "API bindings come from schemas/api.yaml." },
]

Editing generated/api/client.rs returns both matching patterns and their reasons to the model, and shows the same explanation in developer status. There is no permission prompt and nothing executes. Every matching entry is included in array order, user rules first and project rules second, even when patterns repeat. A project cannot replace a user's reason or remove a user denial.

Reasons are plain text, with no interpolation or shell expansion. Empty or whitespace-only reasons add no explanation; nonblank text and line breaks are preserved, with continuation lines indented under Reason:. They reach the selected model only when the rule denies a call, as ordinary tool-result feedback, not higher-priority instructions. Avoid secrets, since normal tool results may be retained. Suggested alternatives still require permission.

Typed serialization emits reasonless rules as strings and reason-bearing rules as tables, without promising exact lexical formatting. Approval promotion leaves untouched deny entries, reasons, and comments intact.

Pattern syntax

A pattern is tool(glob), a bare tool, or exec(program) args(glob).

  • tool is a tool name from the catalog: lowercase letters, digits, underscores.

  • glob matches the call's principal parameter: the command for shell, the URL for web_fetch, the path for view, ls, glob, grep, stat, edit, create, search_replace, and blame. Tools with no principal (status, branches, ask_user) match only as a bare name.

  • A bare tool matches every call of the tool.

  • * matches any run of characters. For the path tools it stops at /; ** crosses directories. For URLs * matches anything, slashes included.

  • ? matches one character.

No wildcards in a shell command

A shell (and background) pattern is an exact command, with no * or ?. A shell call is a string run through sh -c, so a wildcard in the pattern would cover the shell metacharacters that chain a second command: shell(cargo *) would match cargo x; rm -rf ~, approving a command it never meant to. A pattern with a wildcard for shell or background is a configuration error.

exec pins the program and globs the arguments

To approve a program with a wildcard, use exec, which runs one named program directly -- never through a shell -- with its arguments passed to it literally. The pattern is exec(program) args(glob): the program is exact (no wildcard), the args glob matches the arguments joined by spaces. exec(cargo) args(*) approves any cargo invocation and only cargo; nothing an argument contains can start a second process, because there is no shell to interpret it. exec(program) with no args(...) matches only a call with no arguments.

Examples:

PatternMatches
exec(cargo) args(*)Any cargo invocation, any arguments, run directly
exec(cargo) args(test *)cargo whose arguments begin test
exec(make)make with no arguments
shell(make release)Exactly the command make release, run through a shell
web_fetchEvery fetch
edit(src/*)Files directly in src/, not in subdirectories
edit(src/**)Anything under src/
edit(**/*.lock)Any lock file at any depth
view(.env)That one file (in deny, keeps secrets out of the context)

An exec call may also set child-only environment variables with its env map. The prompt displays those overrides, but patterns match only the program and arguments. For example, exec(cargo) args(*) covers cargo test both with and without env: {"RUST_BACKTRACE": "1"}; no approval of the generic env launcher is needed.

What the prompt offers

When a prompt opens, heph proposes a pattern to remember: for an exec call, the program and args(*); for a path, its directory and *; for a shell command, the command exactly, since no wildcard is safe there. A web_fetch prompt proposes the URL's host instead (Network). e on the prompt edits it before a (session) or A (written here). The = line under the pattern spells out its meaning in plain words, generated from the pattern itself:

PatternExpansion
exec(cargo) args(*)any exec of cargo, any arguments; only cargo is run, and each argument is passed to it literally, never through a shell
shell(make release)exactly the command "make release", and nothing else; a shell approval is one exact command, with no wildcards
shellevery call of shell
edit(src/*)edit on any file directly under src/, not in subdirectories

Repeated-call checkpoints

[permissions.repeat]
soft_limit = 3
hard_limit = 12

soft_limit controls assessment of suspected recent repetition. hard_limit requires a human when unchanged outcomes or cycles persist. The defaults are 3 and 12, and 0 < soft_limit < hard_limit is required. These are not lifetime call counts: productive edit/test iterations do not exhaust an allowance.

Each turn retains 32 completed attempts, including denials. Detection considers unchanged results, matching calls dominating at least half of the last eight activities, and recurring cycles of two to six operations. Content states and outcomes distinguish productive work from oscillation. A progress verdict clears soft suspicion; a new evidence pattern or a sufficiently long gap clears the old episode. Human approval clears the turn's current pattern history and allowances, not ordinary permissions.

Commands allow three completed attempts before the earliest assessment, so transient compilation failures have a small retry allowance. A job still running may be polled eight times unchanged before assessment; polling raises the effective soft and hard limits to at least 8 and 32. It is not permanently exempt.

A project [permissions.repeat] table replaces the user's pair as a unit; omitted keys in that table take their built-in defaults. Without a project table, the user's pair is retained. These limits refresh with live permission policy, just like the pattern lists.

The progress-check role uses the selected profile's fast tier by default, respecting tier overrides. Older custom layouts without this role use their quick tier. With neither route, the checkpoint requires human approval. See The repeated-call anomaly for the prompt and renewal behavior.

Order of evaluation

For each call, in this order, the first that applies decides:

  1. Any deny match refuses the call, with every matching rule's explanation. Otherwise, a [network] deny host refuses web_fetch.

  2. The hard repeated-call limit requires a human. At the soft limit, a fast model assesses progress; looping, uncertainty, or failure requires a human. A positive assessment clears only this repetition checkpoint, not policy.

  3. Level 0 and 1 calls run.

  4. An approve or promoted match runs a Level 2 call.

  5. A pattern approved with a this session runs it.

  6. For web_fetch, a host on the allowlist runs it at Level 1, and a host approved with a this session runs it.

  7. --allow-tools, --yes, or --yes-really runs it (--yes-really alone covers Level 3).

  8. Otherwise, the prompt, or a denial when no one is there.

Flags

The same patterns work on the command line, for one run:

heph exec --allow-tools 'exec(cargo) args(*)' 'fix the tests'

Execution Locks

Use [locks] to serialize operations that share build output, a test database, a deployment endpoint, or another exclusive resource. Permissions decide whether a call may run; locks decide when an authorized call may run.

[permissions]
approve = ["exec(cargo) args(build)"]

[locks]
build-output = [
    "exec(cargo) args(*)",
    "exec(make) args(*)",
    "shell(make check)",
    "background(make check)",
]

cargo build is approved by this example. Another Cargo invocation still needs approval, but every authorized Cargo invocation takes build-output. Session approvals, remembered approvals, and --yes do not bypass resources. Denied and malformed calls acquire nothing. When a call needs approval, its permission dialog or plain-text prompt lists every selected name as Locks if approved: before the call. The preview acquires nothing and is omitted when no names match; approved execution uses the same selected set.

Names and matching

Names match [a-z][a-z0-9_-]{0,63}. An absent or empty table acquires nothing; an empty pattern array selects no calls. Invalid names and patterns fail config loading rather than being ignored.

Patterns use the permission syntax. Direct exec calls pin the program and can glob the space-joined arguments. Shell and background patterns are exact command strings, without wildcards. Bare tool names select every call of that tool. Every matching resource applies; there is no first-match precedence.

exec(cargo) does not match shell(cargo build). shell(make check) does not match background(make check). Match the outer operation, not subprocesses or commands hidden inside scripts. Ordinary tools can have lock patterns too.

Gates, including final publication gates, match by their command form. Arrays such as command = ["cargo", "test"] match exec(cargo) args(test) and exec(cargo) args(*). Strings match shell(<expanded command>). For a gate with {files}, matching uses the final argument vector or shell-quoted expansion. Bare shell conservatively covers all shell operations, including string gates and user commands, but not array gates. There is no separate gate-lock field.

Within one gate run, a successful gate keeps resources also needed by the next executed gate. Skipped gates do not break retention. Other resources release before that next gate; failure, cancellation, and the end of the run release all resources after cleanup. If an additional resource is busy, heph releases the retained set and retries the complete set to avoid deadlocks. This applies to acceptance and final publication gates, not across separate runs or retries.

Scope and configuration

Resources belong to the canonical original project, across all its refs, worktrees, and heph processes using the same data root. Symlink aliases coordinate. Distinct projects, clones, or data roots do not. A user-level rule applies independently in each project, not globally.

User and project maps merge by name: project patterns append to user patterns, and identical entries are deduplicated. An empty project array cannot remove a user requirement. Remembering an approval never modifies this table.

Heph reads the authoritative config files at startup. Restart to change lock policy. A selected set stays fixed while its operation waits and runs.

Waiting and ownership

Contention adds waiting for lock build-output to scrollback, naming the resource that actually blocked acquisition and its last recorded owner when available. Owner records include the conversation name and operation (including job or gate identity). If the blocker or recorded owner changes, another wait line appears. Metadata can be stale or briefly absent; it is not proof of ownership.

The bottom status bar shows locks: worktree, build-output while those locks are held. worktree is the session's worktree lease; publication appears while it owns publication. Named execution resources stay listed through cleanup, including background jobs after a turn ends. Long lists are shortened with ...; scrollback retains full acquisition and release names and timings. Publication contention also names the holding session, falling back to its PID for older lock records, and scrollback records acquisition and release.

Waiting is interruptible and has no automatic timeout. Command and gate deadlines begin after acquisition. Input, scrolling, and unrelated operations remain responsive. Esc or Ctrl-C asks before aborting a queued or running user command.

Resources remain held through command execution and supervised cleanup. Background jobs keep their resources after returning a job ID. If a later call waits on one, inspect or stop that job; /job-kill ID requests cancellation without acquiring resources. A configured job_status or job_kill call whose selected resources overlap its target job fails instead of waiting on itself.

Lock files live at locks/<project-hash>/<name>.lock under the heph data root. They are persistent OS advisory locks: do not delete them to release ownership. Owner metadata is diagnostic, potentially stale, and never proves a lock is free. Acquisition failures refuse execution; there is no unlocked fallback.

A dedicated Linux supervisor owns locked commands and their descriptors. It terminates and reaps descendants before releasing resources, including when its heph owner crashes or drops the control channel. Forcibly killing the supervisor itself, or deliberately escaping supervision, is outside this guarantee. Detached writers are not supported; use a managed background job. Replacing the installed heph binary does not break supervisor startup in an existing session: supervisors continue using that session's running version. Restart heph to use the new version.

This is advisory coordination among heph operations, not a sandbox. Ordinary terminal commands do not participate. Serializing operations prevents overlap; it does not fix sequential cache-freshness errors or provide per-ref artifacts. There are no capacities, shared modes, global resources, or external wrapper.

Network

[network] says which hosts web_fetch may reach without asking. It is the allowlist behind the web_fetch levels described in Permissions.

[network]
# Include the curated default hosts listed below.
defaults = true

# Hosts web_fetch may reach at Level 1, in any turn. Exact hostnames only;
# wildcards are rejected.
allow = ["docs.python.org", "wiki.internal.example.com"]

# Hosts web_fetch may never reach, whatever else says.
deny = ["pastebin.com"]

# Hosts you accepted at a prompt with A. Heph appends here.
promoted = ["docs.tokio.rs"]
KeyMeaningDefault
defaultsWhether the curated hosts below are on the allowlist.true
allowHosts fetched at Level 1: no prompt, the call shown as it runs.none
denyHosts refused before anything else is considered, session approvals included. The model is told which host refused it.none
promotedHosts heph appends when you answer A to a web_fetch prompt. They fetch like allow; the separate list keeps what you typed apart from what heph wrote.none

The curated defaults are documentation and forge hosts, each a single tenant with its own request logs: docs.rs, crates.io, doc.rust-lang.org, github.com, raw.githubusercontent.com, developer.mozilla.org, stackoverflow.com, en.wikipedia.org. defaults = false with an empty allow makes every fetch ask.

Project lists are added to the user's; a project's defaults = false wins over the user's file.

What a host is

An entry is one exact hostname as it appears in the URL, lowercase: docs.rs, rust-lang.github.io. It matches that host and nothing else: not its subdomains, not a port, not a path. Wildcards (*.github.io) are rejected when the file is read, because on a multi-tenant host any subdomain is a server whose logs anyone can provision, and a wildcard would allowlist them all.

The list applies to https:// URLs only. A plain http:// URL shows its whole path to every network hop, so it asks every time and cannot be remembered.

What is accepted but not yet read

search_max_uses and search_domains are for web_search, which the model provider runs and which has not landed. They parse and do nothing.

Gates

[[gates]] lists the commands that must pass before you are asked to review a change. Each [[gates]] table is one gate; they run in order, user gates before project gates. For intentional failures, manual /review --skip-gates opens gate-free review and /commit --skip-gates bypasses only that invocation's final publication gates (Committing). Neither changes the configured gates for later turns or commits.

[[gates]]
name = "check"
paths = ["**/*.rs", "Cargo.toml"]
command = ["cargo", "check", "--all-targets"]

[[gates]]
name = "test"
modes = ["code"]
paths = ["**/*.rs"]
command = ["cargo", "test"]
retry_limit = 2

[[gates]]
name = "fmt"
paths = ["**/*.rs"]
command = ["cargo", "fmt", "--check"]
retry_limit = 1

[[gates]]
name = "docs"
paths = ["**/*.md"]
command = ["yucky", "--check", "{files}"]
KeyMeaningDefault
nameShown in status lines and to the model.Required
pathsGlobs over the files the turn changed; the gate runs when any matches. * stops at /, ** does not.Any path
commandShell string or executable-and-arguments array, run in the worktree. Exit 0 passes. {files}: see below.Required
retry_limitFailures the model may fix on its own before the review opens with the failure attached.3

An array runs directly, without a shell: its first string is the executable and all remaining strings are literal arguments. No variable expansion, globbing, pipes, or redirection occurs. Use a string, such as command = "cargo test 2>&1 && echo passed", when you need sh -c. Both forms capture stdout and stderr; direct execution needs no 2>&1.

Arrays must contain a nonempty executable, only strings, and no NUL bytes. {files} must occupy a whole argument and cannot appear in the executable.

modes optionally restricts a gate to code, design, or both. Omission is unrestricted. Explicit empty lists, unknown or wrong-case names, and non-string values are configuration errors. Repeated valid modes are deduplicated in stable order. The mode allowlist and path globs must both match. Quick and recorded review turns count as code; plan turns are read-only and run no gates.

[[gates]]
name = "design-check"
modes = ["design"]
paths = ["designs/**/*.md"]
command = "check-designs {files}"

check-designs represents your own project command. Leave shared checks such as Markdown formatting unscoped. Existing gates need no edits and remain unrestricted. Final publication selects modes from accepted unpublished work, not the session's current mode.

A gate's files are the changed paths its paths match, or every changed path when it has none, less any that were deleted. In an array, each {files} argument expands to those paths as separate literal arguments. In a shell string, they are each quoted for the shell. HEPH_GATE_FILES holds them one per line in both forms. A gate that uses {files} and has no files is skipped. See Checking only the changed files.

A gate may run for up to thirty minutes; its output streams into the conversation as it arrives and is given to the model whole when it fails. CARGO_TARGET_DIR is set to the project's shared target directory when [worktree] shared_target is on (the default).

Gates need no permission, but do acquire matching execution locks. Array commands match exec(program) args(...); strings match shell(<expanded command>), after file expansion in both cases. Acceptance Gates explains when they run and what the model sees.

Review

[review] says how you review a change.

[review]
interactive = "lazygit"
pager = "delta"
reproposal_threshold = 0.8
KeyMeaningDefault
interactiveWhere you accept and reject: "builtin", "lazygit", "git add -p", "fugitive", "$EDITOR", or any command, run in the worktree."builtin"
pagerA program that renders each hunk in the built-in review on a plain terminal (heph exec): delta, difftastic, diff-so-fancy, anything that reads a unified diff on stdin. The interactive session draws its own diffs and does not use it yet.none
reproposal_thresholdFraction of a rejected hunk's lines that must reappear in a later hunk of the same file for heph to reject it as a re-proposal before you see it.0.8
lsp_edits"direct" or "staged". Accepted for forward compatibility; there are no LSP tools yet."direct"

An unrecognized interactive value is taken as a command. A value heph cannot read at all falls back to "builtin".

Review and its sub-chapters cover what each choice looks like.

Worktree

[worktree] says what goes into the conversation's worktree, the copy of your project the model works in, and what is linked to your tree instead.

[worktree]
link = ["node_modules", ".venv"]
max_file_size = "10MB"
shared_target = true
KeyMeaningDefault
linkPaths symlinked from your tree into each worktree rather than copied, and excluded from the snapshot whether or not your repository ignores them. Never diffed, never reviewed. User and project lists concatenate.none
max_file_sizeFiles above this size are symlinked rather than copied, so the model's tools can still read them, and are never diffed or reviewed. Written as 10MB, 512KB, 1G, or a number of bytes.10MB
shared_targetSet CARGO_TARGET_DIR, for model commands and turn- and commit-time gates, to one directory per project under heph's data directory, shared across all worktrees, so builds reuse what is already compiled. Concurrent builds serialize on Cargo's own lock.true

How the worktree is filled

At the start of every turn heph fetches the conversation's upstream, its base branch or its own branch (Branches), and combines it with the conversation's accepted private snapshot. It does not import staged, unstaged, or untracked files from your checkout. Paths your repository ignores are not in the snapshot; configured linked paths remain symlinks.

So target/ and node_modules/ are not copied, which is why link exists: a model that needs to run your tests needs node_modules to be there, and a symlink to yours gives it that without a copy. Anything linked is outside the change heph tracks and reviews.

A project that is not a git repository works too: the whole directory is observed, less target/, node_modules/, and .git/, plus whatever you link.

Tool Sets

[toolsets] defines named lists of tools for plan turns. A plan turn's catalog is a tool set; --tools NAME picks one.

[toolsets]
docs = ["view", "glob", "grep", "web_fetch", "ask_user"]
git = ["log", "show", "diff", "blame", "branches", "status"]

Every name must be a tool in the catalog, and every tool in a set must read only: edit, create, search_replace, and shell are refused at startup with the set and the tool named. web_fetch counts as reading; so does ask_user.

A set with the same name as a built-in replaces it.

The built-in sets

SetContains
researchEvery read-only tool: the file, history, conversation, and environment tools, web_fetch, and ask_user. The default.
localresearch without web_fetch.
historysearch_history, read_turn, ask_user.

Tool sets apply to plan turns only. An ordinary turn always has the full catalog.

Notifications

[notify] says which events reach you outside the pane, and how. Notifications explains the channels.

[notify]
channel = "auto"
desktop = false

turn_done = true
prompt = true
background_paused = true
background_review = true
background_done = false
budget_warning = true

suppress_focused = true

# command = "notify-send 'heph {project}/{ref}' '{message}'"
KeyMeaningDefault
channelauto, osc9, osc777, tmux, bell, or none. auto picks OSC 9 or 777 by terminal, tmux display-message under tmux without passthrough, else the bell."auto"
desktopPersistent freedesktop notifications over the session D-Bus service, in addition to the channel and command.false
turn_doneNotify when a turn finishes.true
promptNotify when a permission prompt, a question, or a review opens.true
suppress_focusedSend nothing for a pane you are looking at: the active pane of an attached tmux client, or a terminal reporting focus.true
commandA shell command run for each notification in addition to the channel. {event}, {ref}, {project}, and {message} are substituted. {event} is turn_done, prompt, or review.none

background_paused, background_review, background_done, and budget_warning are accepted and have no effect yet: background turns and budgets have not landed.

Notifications within one second of each other are sent as one message naming every conversation involved. Resolved notices are removed before delivery. Desktop notices are retained until their prompt or review clears, or an idle session starts new turn work, switches ref, or exits. Focus does not clear them; manual desktop dismissal is respected. See Notifications.

Project settings override explicitly supplied user settings, including desktop = false.

Input

[input] configures attachments, ! commands, and the clipboard. Typing a Prompt and Running Commands Yourself explain the features.

[input]
max_attach = "64KB"
bang_runs_in = "worktree"
# clipboard_image = "wl-paste --type image/png"
KeyMeaningDefault
max_attachA file attached with @ larger than this is described by path and size rather than inlined; the model reads what it needs with view. Images are always inlined."64KB"
bang_runs_inWhere !cmd runs: "worktree" (heph's copy, with the model's edits) or "tree" (yours). !!cmd runs in the other."worktree"
clipboard_imageCommand that prints the clipboard's image as PNG on stdout, for Ctrl-V.wl-paste --type image/png if wl-paste is on PATH, else xclip -selection clipboard -t image/png -o if xclip is

Sizes are written as 64KB, 1MB, 2G, or a bare number of bytes.

UI

[ui] configures image display in the conversation, how wide its text runs, and when the skills and cost sidebar shows.

[ui]
image_max_rows = 24
# image_viewer = "imv"
images = "auto"
sidebar = "auto"
wrap_width = 120
KeyMeaningDefault
image_max_rowsTallest an inline image is drawn, in rows.24
image_viewerCommand that opens an image full-size on Enter. Default: the system opener.none
imagesauto, kitty, iterm2, or off: which terminal graphics protocol to use."auto"
sidebarauto, on, or off: when the skills/cost sidebar shows."auto"
wrap_widthThe widest the conversation's text runs, in columns; 0 is always the pane's width.120

The image keys are read and validated, but drawing images in the terminal has not landed yet. An attached image shows as a chip with its path and size, and the model sees it; the conversation does not draw it. The section is here so a config written now keeps working when it does.

sidebar = "auto" draws the sidebar whenever there are skills, recorded spend, or an accounting error to show. Its cost section shares space with skills; on draws it always and off never, in a terminal at least 100 columns wide either way. /sidebar and Ctrl-S change it for the session; /cost shows category costs and seven-day usage at any width, while /stats adds tool attribution.

The conversation wraps between words at wrap_width columns, or at the pane's width when that is narrower, so prose stays a readable measure in a wide terminal. Markdown tables use the same limit: columns grow to fit their content, then cells and headers wrap without truncation. Long words and URLs break across lines when needed. In a pane too narrow for all columns and separators, rows become stacked header/value pairs so all content stays readable.

Context

[context] says what of the conversation each round carries.

[context]
recent_turns = 0
KeyMeaningDefault
recent_turnsFinished turns before the current one whose tool results are sent whole. The current turn's always are. The results of every older turn are replaced by a one-line stub at the start of each turn.0
high_waterHistory fraction that triggers a turn-boundary state update.0.8
low_waterSee the threshold and cap rules below.0.5
summary_max_tokensApproximate state-record cap, excluding verbatim active skills.4000
instruction_max_tokensSee the threshold and cap rules below.400

Watermarks must satisfy 0 < low_water < high_water <= 1; token caps are positive and the instruction cap cannot exceed the summary cap. Defaults are high_water = 0.8, low_water = 0.5, summary_max_tokens = 4000, and instruction_max_tokens = 400.

Before a prompt turn starts, history is estimated after ordinary result elision. Its budget is half the model window after reserving output. Crossing high water folds older finished turns toward low water, leaving room for the state record and verbatim active skills. Current and configured recent turns stay whole.

The state record carries verbatim instruction excerpts (with source turn IDs), the original goal, last write actions, original commands with observed completion lines, exact questions/answers, and structured decisions, rejected approaches, facts, and open items. The summarize role supplies only the judgment sections and standing-instruction selection. Reports retry once if malformed; every attempt is billed. Failed, oversized, canceled, or stale updates retain raw history. A protected request that cannot fit fails before provider dispatch; shorten the prompt or use /clear rather than expecting silent truncation.

/summary is a separate human-readable work cache, not a context-state inspector.

What the model sees

Until a state document covers older turns, requests carry your prompts, the model's replies, and each tool call with its parameters, from the first turn on. What changes at a turn boundary is the results of the turns before it. A file the model read last turn is sent to it this turn as

[view src/auth.rs: 412 lines, elided]

and a command it ran as

[exec cargo test: error, 96 lines, elided]

The tool is named, so is its target, whether it failed, and how much there was. The file is still on disk, and read_turn recovers recorded earlier results. For retained command logs, use read_output instead of re-executing a command that might have side effects. Within a turn the recorded initial representation stays fixed. Opt-in successful command output can choose a terse status before the result's first model appearance; original stdout/stderr remain recoverable. This is source-level selection, not later pressure-driven elision of current-turn evidence.

Some results are never stubbed: short ones, since an edit confirmation or a todos list is about the size of its stub and says more; your answers to ask_user, which are your words; and activated skills, which must stay in the conversation to count as active.

Why the default is zero

A round costs what it carries, and tool results are most of what a conversation carries: across the sessions this was measured on, results from earlier turns were a quarter of everything spent. Stubbing them at every turn boundary is the largest single saving heph makes.

The window is zero rather than a few turns because of how the prompt cache prices a change. Eliding a turn moves the point from which the cached prefix no longer matches, and everything after that point is written to the cache again, at about twelve times the price of reading it. Keeping the last R finished turns whole means rewriting those R turns at the start of every turn to save the reads of the turns before them. On the recorded sessions a window of zero saved 22%, one saved 6%, two broke even, and four cost more than no elision at all. Set recent_turns above zero if you would rather the model keep the last turn's reads in view, knowing that each turn kept whole is also paid for again at every turn start.

What was stored is not changed. The conversation on screen shows every result in full, and so does the read_turn tool.

Skills

[skills] decides which of the discovered Agent Skills the model may see.

[skills]
disabled = ["slow-review", "legacy-deploy"]
project = true
KeyDefaultMeaning
disablednoneSkills to keep from the model
projecttrueWhether the project's skill directories are read. User file only.

Disabling a skill

A disabled skill is still discovered, so /skills lists it, marked [disabled], and a misspelled name is caught. It is left out of the catalog in the system prompt and out of the skill tool, and /skill NAME and the tool both refuse it by name, saying which file disabled it:

skill "slow-review" is disabled by [skills] disabled in the project config

A name that matches no discovered skill is not an error. /skills lists it under "Disabled but not found", so a list in your user file can name skills that only some projects have.

A skill that was already active when you disabled it stays in the conversation: history is not rewritten. /skills shows it as active and disabled. Disabling stops future activations only; /new starts a conversation without it.

Refusing a project's skills

A project's skills are project instructions, trusted as AGENTS.md is, and heph loads them from any repository it starts in. To keep every project's skill directories out of discovery, set this in your user file:

[skills]
project = false

Your own skill directories are still read. Only the user file may set project: in .heph/config.toml it is an error at startup that says so, since a project deciding whether its own skills are trusted would mean nothing.

How the two files merge

The disabled lists concatenate, yours first, as [permissions] deny does. A project can disable more skills than you do, never fewer.

/skills --reload rereads this table from both files, with the same rules, so a change here takes effect without a restart.

Git

[git] says which branch conversations start from, how generated branch names are prefixed, and how commits are attributed (Branches).

[git]
base_branch = "main"
KeyMeaningDefault
base_branchThe branch new conversations start from: a local branch (main) or a remote-tracking one (origin/main). Written onto a conversation at its first turn, then kept.unset: asked
branch_prefixPut before every generated branch name. Empty, or one or more branch name parts each ending in / (team/heph/). A name you give with /branch is used as you give it.heph/
attributionA trailer added to every commit heph makes, placed where your trailer.* configuration says. Not added again when the message already has it. Empty means none.none

The project file's value wins for each key it sets. base_branch belongs in the project file: a project's default branch is a fact about the project, and the team that checks in .heph/config.toml shares it.

Branch name prefix

Generated names default to heph/wrap-lines. Set a different prefix in either config file:

[git]
branch_prefix = "team/"

This produces names such as team/wrap-lines. A nonempty prefix must end in / and contain valid git branch name components; nested prefixes such as users/jb/ work too. To disable the prefix:

[git]
branch_prefix = ""

This produces names such as wrap-lines. An empty project value also overrides a nonempty user value; omitting the key keeps the user's setting, or the heph/ default if neither file sets it.

The prefix applies only when heph generates a new branch name. It does not rename a branch already chosen for a conversation, and /branch fix/pager-wrap uses exactly fix/pager-wrap, regardless of the configured prefix. Collision suffixes (-2, -3, ...) still apply with any prefix, including none.

An unknown key in [git] is an error, as in every section heph reads.

Command Line

-h, --help, and heph help [COMMAND...] show compact help wrapped at 80 columns, including when redirected. Flag descriptions have no blank lines between entries.

heph [OPTIONS] [SESSION FLAGS]
heph exec [OPTIONS] [FLAGS] [PROMPT]...
heph wire-check [OPTIONS] [--role ROLE] [--earlier-reasoning]
heph models [OPTIONS] [--all] [--provider ID]
heph tiers [OPTIONS] [--profile NAME] [--tier NAME=MODEL]... [--model MODEL]
heph profiles [OPTIONS]
heph skills [--ref REF] | check [PATH]... | show NAME
heph sessions [--name TEXT] [--branch TEXT] [--published] [--limit N]
              [--summary] [--json]
heph tmux status | list | next | conf

Text tables from sessions, models (including Ollama), tiers, profiles, and tmux list use serde_grid's default bordered renderer for column sizing and alignment, without command-specific padding or width rules. Chat Markdown tables are unchanged. For scripts, sessions --json and sessions --ids provide structured records or one full UUID per line.

Global options

These go before any subcommand and apply to all of them.

OptionMeaning
-C, --project DIRThe project directory. Default: the current directory.
--user-config FILERead this user config instead of ~/.config/heph/config.toml.
--project-config FILERead this project config instead of .heph/config.toml.
--data-root DIRKeep state under this directory instead of ~/.local/share/heph. Also HEPH_DATA_ROOT.
-v, --verboseMore logging; repeat for more. RUST_LOG overrides. Subcommands log to stderr; the interactive session logs to log/heph.log under the data root.
-h, --help, -V, --versionThe usual.

heph: the interactive session

With no subcommand, heph creates a fresh unnamed conversation. Trailing arguments joined with spaces supply an initial prompt; quotes preserve embedded whitespace and newlines. Session flags precede prompt arguments. Use -- for a prompt starting with a dash or a subcommand name, for example heph -- models need better error messages. Empty or whitespace-only input opens without a turn. A prompt and either resume flag are mutually exclusive. Session flags:

FlagMeaning
--continue [REF]Resume the latest branch, or the exact existing name/full UUID given. Missing and locked targets error. Pending review is offered first.
--continue-branch BRANCHResume by exact current or recorded publication branch in this checkout. One match opens directly; several prompt with metadata and cached summaries.
--fork REFCreate a new unnamed child at the existing ref's latest completed accepted checkpoint, excluding live edits and pending review. Optional prompt; excludes --continue. Manual startup needs no tmux.
--adopt UUIDTransfer this exact saved session from another project into this Git checkout, then resume; confirmation defaults to no.
--profile NAMERun the conversation on this profile instead of the default. Kept with it.
--tier NAME=MODELRun one tier of the profile on another model. Repeatable; kept with the conversation.
--model MODELShort for --tier on the tier doing the conversation's role: a tier name, provider/model-id, or a bare id one provider offers.
--reviewEvery turn on the review tier, with the full catalog.
--planEvery turn a plan turn: read-only tools, no shell, on the design tier.
--designEvery turn a design turn: a plan turn that may write only under [design] paths, on the design tier.
--quickEvery turn on the quick tier.
--tools NAMEWith --plan or --design: which tool set the turns read with. Default research.
--accept-editsApply changes without review.
--allow-tools PATTERNPre-approve calls matching the pattern. Repeatable.
-y, --yesAuto-approve Level 2 calls and bypass adoption confirmation. Never a review.
--yes-reallyAuto-approve Level 2 and 3 calls.

--review, --plan, --design, and --quick exclude each other. Session flags given beside a subcommand are refused with a message naming the flag; each subcommand takes its own.

--continue-branch BRANCH matches case-sensitively against current reserved branches and recorded local publications, never bases or names. Includes archives, not subtasks. No matches error without creating state. Multiple matches require a controlling terminal and show a numbered list with metadata and full cached /summary content and freshness, newest substantive activity first. Missing summaries are labelled; no summaries are generated to choose. Invalid numbers retry; Enter, EOF, or q cancels. There is no default, including with -y or --yes-really. Without a controlling terminal, use --continue FULL-UUID. Historical selection does not retarget the session's current branch. The flag excludes prompts, --continue, --fork, --adopt, and subcommands. See Resuming by publication branch.

--adopt UUID conflicts with a prompt, --continue, --continue-branch, --fork, and subcommands. It searches the effective data root, including archives, and adopts into the containing checkout root. Review its source/destination and saved-state details before answering y; empty input cancels. Without a controlling terminal, -y is required. This also retains the usual Level 2 tool auto-approval during the resumed session. See Adopting a session.

The session needs a terminal on stdout; without one it says to use heph exec.

heph exec: one turn

heph exec [FLAGS] [PROMPT]...

Runs one turn on the prompt and exits. The prompt is the arguments joined with spaces; if stdin is not a terminal, its contents are appended as context. With --continue REF the prompt may be omitted, in which case a review pending on the conversation is offered and nothing else happens.

exec takes the session flags above except interactive-only --adopt and --fork, and:

FlagMeaning
--streamEcho the model's text to stderr as it streams. Stdout still gets the final text.
--skill NAMEActivate this skill with the prompt, as /skill does. Repeatable, in order. An unknown or disabled skill ends the run before any turn, exit 1; one already active on the --continue ref is skipped with a note. Needs a prompt.
--base BRANCHThe branch a conversation with none starts from, standing in for the question the session asks (Branches). Refused when the conversation already starts from another.
--implement PATHBuild this committed design document: in a new conversation, build-<stem>, from --base or [git] base_branch; with --continue, in that one with its context cleared. Its text is the first prompt and the prompt's words follow it (Design Turns). Repeatable; the prompt may be omitted.

Stdout: the model's final text. Stderr: everything else. Exit codes are in Exit Codes and Errors.

heph skills

heph skills [--ref REF]
heph skills check [PATH]...
heph skills show NAME

The skills of the project at -C or the current directory, with no model configured and no database opened, except with --ref.

  • heph skills prints the /skills report. --ref REF marks the skills active on that conversation.

  • heph skills check parses and validates every discovered skill, or the SKILL.md files and directories given, prints each problem, and exits 1 if any skill is invalid. A frontmatter field the specification does not define is a warning and does not change the exit status.

  • heph skills show NAME prints the skill's frontmatter, source, resources, and the activation payload exactly as the model would receive it.

heph sessions

Lists the current checkout's saved conversations, newest recorded work or publication first. Subdirectories select their containing checkout. Use --all-projects for the effective data root; it conflicts with -C. Archives are hidden unless --archived or --include-archived is selected. No tmux or provider is needed. Outside a recognized project, choose explicit scope. -C DIR also accepts a removed checkout's original root path. A missing path selects that former project directly, not a surviving parent checkout, without recreating project state. Relative paths are relative to the current directory. See Finding a saved session for the workflow.

FlagMeaning
--name TEXTCase-insensitive substring of the conversation name
--branch TEXTCase-insensitive substring of base, reserved, or historical branches
--publishedOnly conversations with a recorded successful local /commit
--limit NMaximum results after filtering and sorting; unlimited by default
[REGEX], --match REGEXRust regex over names and branch metadata; (?i) opts into case-insensitive matching
--uncommitted, --unmerged, --merged, --unknownIndependent evidence filters, combined with AND
--runtime STATErunning, waiting, failed, stopped, or unknown
--inactive-for DURATIONPositive integer followed by s, m, h, d, or w
--disk-usageWorktree-only apparent bytes; symlink targets and shared stores excluded
--summaryInclude cached work summaries; off by default, no generation; conflicts with --ids
--idsFull UUIDs, one per line; conflicts with --json and --summary
--jsonObject containing full sessions records and coverage diagnostics

Filters combine, and limits apply last. Subcommands take precedence over regex arguments; use --match show for that pattern or -- before dash-prefixed patterns. show ID [--diff] provides detailed inspection, including archives. IDs are full UUIDs or unique prefixes of at least six characters. A complete read-only diff may be unavailable when replay or recovery is needed; heph reports that limitation rather than mutating state. open ID jumps to a unique live pane or uses ordinary resume; ambiguity never creates a new session.

The compact table shows ID, name, reserved branch, work flags, runtime, and last substantive activity. Full records and paths are available through show and JSON. Global tables add PROJECT; requested disk accounting adds OWNED BYTES and RECLAIMABLE BYTES (EST.) when available. Empty inventories succeed. Project and probe failures are retained in JSON and cause a nonzero exit without hiding successful records.

--summary adds SUMMARY with the full cached body, - for a missing cache, and [stale] before a cache covering an older tip. It reads the same ref-local cache as /summary without refreshing it, probing Git/runtime, calling a model, or changing activity. With --summary --json, saved caches appear as summary objects with body, turn_id, and generated_at (UTC Unix seconds); missing caches omit the field. Without the flag, the listing and JSON shape stay unchanged.

A current branch can be reserved without a successful commit. Publication records track verified local /commit results from this version onward, not remote pushes. They survive branch changes and deletion. Earlier publications are unknown, not counted by --published. Older source directories are unknown until the project is opened again, or provided by -C for the listing.

Ordinary inventory batches metadata and labelled cached observations without Git/runtime probes. A missing observation is not-inspected; a cache is never current cleanup evidence. Work/runtime selectors and show probe fresh state. Current schemas open read-only without migration write admission; old schemas migrate once. Actual input/name edits count as substantive activity, unlike resume, searching, or summary reads.

find QUESTION [--limit N] [--max-cost USD] [--profile NAME] ranks all selected candidates from metadata and cached summaries. The default limit is five, positive; input is nonempty and at most 8,000 characters. Packets are bounded to 80,000 bytes, candidate fields are explicitly cut, and large inventories use batches plus compact finalist assessments. The default request cap is $0.50. JSON includes matches/explanations, freshness, coverage, cuts, requests, cost, and its detached accounting anchor. No tools or source/transcript input are used. Malformed/provider responses fail without lexical fallback. No candidates means no provider setup or request.

archive|clean|delete ID... or shared selectors preview exact sets. Require IDs or selectors, not both; global actions require --all-projects. --apply authorizes the resolved set; interactive deletion also confirms history loss with DELETE. Lifecycle JSON contains items and coverage diagnostics. Archive retains private state. Clean retains the session and required snapshots while removing only its reconstructible worktree. Delete removes the ref and exclusive canonical history, preserving shared nodes, request audits, and charges. No external branches, shared caches, or object stores are pruned. Clean/delete require proven merged or untouched empty work, with no blockers and no force option. Live, unknown, pending, drafted, or changed targets are skipped/refused. Any skipped/failed requested target or incomplete coverage exits nonzero. Retry interrupted actions by UUID with --apply; completed reference deletion does not lose its retry receipt. Pre-commit refusal restores quarantine; committed cleanup finishes without replaying the operation.

Existing databases are migrated on open, but no project or worktree is created, no process registered, and no startup recovery run. Missing source directories and worktrees do not hide their conversations. See Finding a saved session.

heph wire-check

Sends one streaming request through the configured provider with a tool and thinking, stores every block, reads it back, and confirms nothing changed. When the model calls the tool, as the prompt asks it to, heph answers the call itself and sends a second round, so the model's reasoning goes back to the API that produced it and a cache read shows. Prints the provider, model, blocks, rounds, token counts with the second round's cache read, cost, time to first token, and duration. --role ROLE picks the tier to test by role (default coding). The check's system prompt is padded past the smallest prefix a provider will cache, so the two rounds cost a cent or two.

--earlier-reasoning adds two paid requests after this replay check. Both send an identical new-turn prompt, first with the earlier Responses reasoning and then without it. The report shows both usage categories and the difference in total input, including cache reads and writes; uncached input alone cannot separate omission from a cache hit. It also shows the probe cost, included in the overall total. Choose a GPT reasoning tier that returns encrypted reasoning; absent reasoning, incomplete usage, or unfinished responses fail the comparison. This is a single paired observation, not a general billing guarantee. Normal session replay and stored history are unchanged.

heph models

Lists every provider heph can address and each model with its rates (USD per million tokens: input, output, cache write, cache read) and context window. --all includes providers whose protocol heph does not speak. These are the ids to use in tiers and with --tier and --model. Each provider's default large and small models are marked; they fill its built-in profile.

--provider ID restricts the list to one provider. For ollama, or a configured provider of that kind, the command contacts its native endpoint and lists exact installed names, digests, sizes, quantizations, capabilities, architectural ceilings, and runtime allocations. Unknown values are labeled unknown; models without known tool support remain visible but are ineligible for agent work. No weights are downloaded. See Providers.

heph tiers

Prints the tiers (name, roles, model, thinking) of the profile --profile names, or the default, with --tier and --model applied and marked (overridden). A new conversation given the same flags runs on these (Tiers and Profiles).

heph profiles

Lists every profile, each provider's built-in one and those in config, with its tiers and where it came from, the default first; or says why there is no default.

heph tmux

Reads the pane variables every session publishes under tmux. Opens no database and reads no config; safe to run from tmux's status line every few seconds.

CommandOutput
statusOne line: heph 2 waiting / 1 busy, with the waiting count in red (tmux style markup) when it is not zero. Zeros when no heph runs, and always exit 0, so the status line never shows an error.
listA table of every heph pane: pane, project, conversation, state, waiting, spend.
nextSwitches client, window, and pane to the heph most in need of attention. Does nothing and exits 0 if none runs.
confPrints the tmux.conf snippet.

See Working in tmux.

Key Bindings

While a lifecycle action waits for draft persistence, Esc cancels it. In a failed-save modal, R retries and Esc cancels; only a failed quit offers X to explicitly exit without saving pending changes.

? on an empty input shows this list in the session.

Input

KeyAction
EnterSend; append to the FIFO queue if a turn is running
Shift+Enter, Alt+Enter, Ctrl-JNew line
TabComplete a path after @, or a skill name after /skill; again cycles matches
Ctrl-EEdit the prompt in $VISUAL or $EDITOR
Ctrl-VAttach an image from the clipboard
Ctrl-UWithdraw the newest queued message, else clear the input
Up, Down (input empty)Previous, next prompt; Up recalls the newest queued message first
Up, Down (multi-line input)Move between lines
Left, Right, Home, EndMove; Ctrl-B, Ctrl-F, Ctrl-A as in a shell
Ctrl-Left, Ctrl-Right, Alt-B, Alt-FMove by word
Backspace, Ctrl-H, DeleteDelete a character
Ctrl-W, Ctrl-BackspaceDelete the word before the cursor
Ctrl-KDelete to end of line
Ctrl-RReserved for prompt search; not yet

Conversation

Ctrl-P or /prompts toggles a user-prompts-only history, hiding replies, tools, logs, shell commands, turn summaries, and the sidebar. Each switch starts at the latest prompt; scrolling keys and the mouse wheel still work. Toggle again to restore the full transcript. This is display-only: saved history and model context are unchanged. Permission, question, and review dialogs still appear, and the input and status bar remain available.

KeyAction
Ctrl-PToggle user-prompts-only history (same as /prompts)
PgUp, PgDnScroll ten lines
Shift+Up, Shift+DownScroll one line
Mouse wheelScroll three lines; Shift+drag selects text
Home, End (input empty)Top, bottom
Right, Left (input empty)Unfold or fold the last tool call or the last reply's thinking

Session

The startup project-instructions offer uses the permission dialog's magenta border and compact cyan action strip: y generates (g is an alias), n or Esc dismisses for this launch, and d disables future offers for this repository. Drafts and armed skills are preserved. Up, Down, PgUp, PgDn, and the mouse wheel scroll its text; actions stay visible. /init remains available. Tab in the ordinary input completes /init, /instructions, and the supported init options.

KeyAction
EscAsk to interrupt the running turn; y confirms. Closes a confirmation or the help.
Ctrl-CAsk to abort the running tool; y confirms. With no tool running, as Esc. With nothing running, clears the input.
Ctrl-DConfirm leaving, even when idle. Press again to exit; any other key cancels.
Ctrl-SShow or hide the skills/cost sidebar, even in dialogs; /sidebar where the terminal keeps Ctrl-S
?Help (input empty)
/Begin a slash command

Esc and Ctrl-C also ask before stopping a queued or running bang command.

Mode-switch confirmation

In plan or design mode, a build request or a mode-blocked tool call pauses for heph's own dialog. y switches to code mode and resumes the same turn; n or Esc keeps the mode and stops the turn. Other keys do not accept. This confirms the mode only; pending calls still need their normal permissions.

Config error

A broken live config pauses the pending tool at heph's own dialog. Fix the named authoritative file externally. r retries the same call under fresh policy; s or Esc stops the turn. Up, Down, PgUp, PgDn, and the mouse wheel scroll the error and advisory fast-model guidance. Retry and Stop remain available while guidance loads or if it fails. This is not a permission approval or a model question.

Permission prompt

t expands or collapses a long model-thinking fallback without answering.

KeyAction
yAllow this call
n, EscDeny
aAllow, and remember the shown pattern for this session
AAllow, and write the edited scope to the effective project config
eEdit the pattern; Enter keeps it, Esc cancels the edit
Up, Down, PgUp, PgDn, mouse wheelScroll call and scope explanations; compact action keys stay in view

Question

KeyAction
1 to 5Type an option number; edit it before confirming
EnterConfirm a matching option number, otherwise send the typed answer
EscNo answer; the model is told no one answered
PgUp, PgDn, mouse wheelScroll a long question; the options and the input stay in view

Post-review commit offer

After accepted work passes final publication gates, a dedicated dialog shows the branch, message, and paths with the permission dialog's magenta border and compact cyan action strip. y chooses Yes and commits as shown without an editor. e chooses Yes, but edit the message, opening $VISUAL or $EDITOR as a manual /commit does. n or Esc leaves the work private. Numbers, Enter, and other keys do not approve publication. Up, Down, PgUp, PgDn, and the mouse wheel scroll; actions stay visible. The publication lock is held until the answer and, when accepted, through the editor and commit. See Committing.

Base branch question

Asked before a conversation's first turn when it has no base branch (Branches).

KeyAction
TypingFilter the branches to those containing the text
Up, DownChoose among the branches shown
EnterStart from the chosen branch, or the name typed if none matches
EscCancel the turn; the prompt goes back in the input

Review: the brief

KeyAction
EnterBegin: the built-in review, or open the external tool
qReject every hunk and end the turn
EscLater: the change stays in the worktree, unreviewed
Up, Down, PgUp, PgDnScroll

Review: hunk by hunk

KeyAction
aAccept
rReject
cReject with a comment; Enter sends, Esc cancels
eEdit the file in your editor
sSkip: rejected without comment
qReject everything undecided and finish
u, Backspace, LeftUndo the last decision; from the first hunk, back to the brief
EscStop here: decided hunks stand, the rest wait for /review
Up, Down, PgUp, PgDnScroll the hunk

Review: comments after an external tool

KeyAction
Enter, TabSend the comment, or skip if empty
Shift+TabBack to the previous rejection
EscFinish now, with the comments given so far
Up, Down, PgUp, PgDnScroll

Confirmation

KeyAction
yYes
anything elseNo, dismiss

Slash Commands

A prompt beginning / is a command to heph rather than a message.

/job-kill ID requests cancellation of a background job without matching tool lock rules. It remains available while a turn or publication waits. The request is not a claim that cleanup has already finished.

CommandDoes
/back [N] [--keep-code]Move the conversation back N turns (1 by default) and its worktree with it; --keep-code leaves the files for /review
/base [<branch>]Show the base, publication branch, and pending rebase; <branch> overrides upstream until /commit publishes it (Branches)
/branch [<name>]Show the branch the conversation publishes to; with <name>, publish there, adopting it if it exists (Branches)
/clear [<prompt>]Clear, then send <prompt> if given; a refused clear sends nothing. Start the model fresh: nothing before the next turn is sent, live token/cache counters restart; persisted spend remains (Session)
/code [--clear] [<prompt>]Leave plan or design mode; turns run as the class the session was started with. --clear starts the model fresh first; <prompt> is sent as the next turn
/code [--clear] --implement [<path>...] [<words>]Leave the mode and build the committed design: the paths given, or the designs this conversation wrote since the last handoff, their text in the first prompt (Design Turns)
/commit [--skip-gates] [--] [<message>]Commit the accepted work to the conversation's own branch, creating it the first time (Committing); with <message>, no draft and no editor; --skip-gates bypasses final gates for this invocation only, not review or Git hooks; -- begins a literal message
/costSession/thread/today category costs and seven local calendar days of cross-project usage, with totals, ranked models, cache rates, and estimates; scroll with Up/Down/PgUp/PgDn or wheel, Esc closes (Costs)
/design [<set>]Enter design mode: plan turns that may write only under [design] paths, reviewed like any change (Design Turns); sticky until /code
/design [--tools <set>] <prompt>Enter design mode and send <prompt> as its first turn
/diff [--stat]What /commit would publish now, with the turns it spans (Forking and Switching)
/diff <turn> [<path>...]One turn's accepted change; a turn is #12, an id, or six or more characters of one
/diff <design>The turns --implement started to build a design document, and what they accepted
/fork <prompt>Branch here, generate a short name through the title role, and start the prompt
/forward [N] [--keep-code]Undo a /back, N turns or all the way
/helpShow the key bindings (same as ?)
/init [--file AGENTS.md|HEPH.md|CLAUDE.md]Create or refine reviewed root instructions using the coding model; idle only (Project Instructions)
/init --offerClear repository-local offer suppression without starting a turn
/init --helpPrint the local bootstrap contract
/instructionsList frozen and next instruction sources and diagnostics, not contents
/new <prompt>Start a root without inherited history; name locally on submission and improve the title asynchronously
/plan [<set>]Enter plan mode: read-only turns, sticky until /code (Plan Turns); with <set>, that tool set
/plan [--tools <set>] <prompt>Enter plan mode and send <prompt> as its first turn; a single word after /plan is taken as a set name
/promptsToggle a user-prompts-only history (same as Ctrl-P); display-only, with permission and review dialogs still available
/quit, /exitSkip the Ctrl-D confirmation, then flush acknowledged drafts
/refsList live conversations, each with its tip and its branch, or (base <branch>) before its first commit
/rename [name]Rename only the active session; without a name, ask the fast title model for a 1-4 word slug from recent user intent
/restore <turn> [<path>...]Put that turn's files back in the worktree for /review, without moving the conversation
/review [--skip-gates]Review what the worktree holds beyond the accepted state, now (Review): no gates, no model round; --skip-gates is an explicit alias, not a sticky setting
/sequence [cancel]Inspect the current publication unit's phase and remaining work; cancel stops sequencing while keeping accepted changes. Waits for turns, review, and publication (Committing)
/sidebar [on|off|auto]Show or hide the skills and cost sidebar for the session; bare /sidebar toggles it, auto returns to [ui] sidebar
/skill <name>Activate a discovered skill with the next prompt; it remains in this conversation's context. Tab completes the name
/skillsList skills by project and user, active and disabled ones, discovery errors, and what is armed or queued (Skills)
/skills --reloadReread [skills] and the skill directories and say what changed (Skills)
/statsFull session UUID, session/thread/daily token and cost breakdowns, today's provider/model totals, and estimated tool request/result-lifetime shares; scroll with Up/Down/PgUp/PgDn or the wheel, Esc closes
/summary [--refresh]Read the cached work summary; regenerate asynchronously with --refresh
/switch <ref>Open an existing conversation by exact name or full UUID while idle
/tiersA window with the conversation's tier and model, its profile's tiers (overrides marked), and the other profiles; any key but Up/Down closes it (Tiers and Profiles)

/summary [--refresh] shows the persisted work summary for the active ref. --refresh defaults to false: a cached read makes no model call. With the flag, heph regenerates from the transcript using the summarize role after the turn ends; the UI stays responsive. See Work summaries.

/rename [name] requires an idle turn with no pending review. Explicit names must be unique lowercase slugs matching [a-z0-9][a-z0-9-]*. Automatic naming uses the latest 3,000 characters of user prompts, asks the model to revise conflicting names up to three requests total, and adds a numeric suffix only as a last resort. See Renaming.

Anything else answers /name: not a command yet (try /help). Background multi-ref runners, search UI, and the turn tree remain future features (What Is Not Here Yet).

A prompt beginning ! is a command you run yourself, not a slash command.

Tools

The tools the model can call, as the model sees them: the name, the level and effect that decide how it is permitted (Permissions), the description heph gives the model, and the parameters. This page is generated from the catalog in the binary by make book; do not edit it by hand.

The principal is the parameter a permission pattern matches and a prompt shows: the path, the command, the URL, the pattern or glob, or the first edit's path.

view

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

Read a file, with line numbers. A file up to 400 lines is returned whole; a longer one comes back as its outline (items and the lines they span) so you can read the part you need: offset (1-based) and limit for a range, or symbol for one item by name (parse, Config::load, a heading). offset alone reads from there to the end. Prefer this to shell(cat).

{
  "type": "object",
  "properties": {
    "path": {
      "type": "string",
      "description": "Path relative to the worktree"
    },
    "offset": {
      "type": "integer",
      "minimum": 1,
      "description": "First line to return (1-based)"
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "description": "Number of lines to return"
    },
    "symbol": {
      "type": "string",
      "description": "An item from the outline to return whole: a function, type, impl, or section by name; `Type::method` for a method"
    }
  },
  "required": [
    "path"
  ]
}

ls

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

List a directory. depth for a tree (default 1), long for size, mode, and modification time. Ignored paths (build output, node_modules) are listed but not descended into.

{
  "type": "object",
  "properties": {
    "path": {
      "type": "string",
      "description": "Directory, relative to the worktree; default the root"
    },
    "depth": {
      "type": "integer",
      "minimum": 1,
      "maximum": 8
    },
    "long": {
      "type": "boolean"
    }
  }
}

glob

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

Find files by glob pattern (**/*.rs, src/**/mod.rs), honoring the project's ignore rules. Returns paths relative to the worktree, most recently modified first.

{
  "type": "object",
  "properties": {
    "pattern": {
      "type": "string"
    },
    "path": {
      "type": "string",
      "description": "Directory to search under; default the root"
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "default": 200
    }
  },
  "required": [
    "pattern"
  ]
}

grep

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

Search file contents with ripgrep. pattern is a regex unless literal is true. include restricts to a glob (*.rs), context adds lines around each match, files_only lists matching files, count counts matches per file. Returns the first limit matches (default 100), or files in the listing modes, and the total; narrow with path or include rather than raising it. Prefer this to shell(grep).

{
  "type": "object",
  "properties": {
    "pattern": {
      "type": "string"
    },
    "path": {
      "type": "string",
      "description": "File or directory to search; default the root"
    },
    "include": {
      "type": "string",
      "description": "Glob of files to search, e.g. *.rs"
    },
    "literal": {
      "type": "boolean"
    },
    "context": {
      "type": "integer",
      "minimum": 0,
      "maximum": 20
    },
    "files_only": {
      "type": "boolean"
    },
    "count": {
      "type": "boolean"
    },
    "case_insensitive": {
      "type": "boolean"
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 1000
    }
  },
  "required": [
    "pattern"
  ]
}

stat

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

Size, line count, kind, mode, and modification time of paths. For a directory: the number of files and lines by extension. Prefer this to shell(wc) or shell(du).

{
  "type": "object",
  "properties": {
    "paths": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "minItems": 1
    }
  },
  "required": [
    "paths"
  ]
}

outline

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

The items a file defines and the lines each spans: functions, types, impls, classes, sections, tasks. Read this first for a file of any size, then view the ranges you need, or view with symbol for one item. Found from the text, so approximate; lsp_symbols is exact where a language server is configured. Rust, Python, JavaScript and TypeScript, Go, Ruby, shell, C-like languages, Markdown, Norg, TOML, YAML, and Makefiles.

{
  "type": "object",
  "properties": {
    "path": {
      "type": "string",
      "description": "Path relative to the worktree"
    }
  },
  "required": [
    "path"
  ]
}

edit

Level 1 (Notify). It writes into the worktree.

Replace exact text in files. Each edit names a path, the exact old text (including whitespace and indentation) and the new text. old must occur exactly once unless replace_all is set. Edits are checked first and applied all or none, so a multi-file change never lands half-way. Before a write, state in one or two sentences what you are changing and why; that sentence is what the user reviews the hunk against.

{
  "type": "object",
  "properties": {
    "edits": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "properties": {
          "path": {
            "type": "string"
          },
          "old": {
            "type": "string"
          },
          "new": {
            "type": "string"
          },
          "replace_all": {
            "type": "boolean"
          }
        },
        "required": [
          "path",
          "old",
          "new"
        ]
      }
    }
  },
  "required": [
    "edits"
  ]
}

create

Level 1 (Notify). It writes into the worktree.

Create a new file with the given content, making directories as needed. Refuses a path that already exists; use edit to change an existing file.

{
  "type": "object",
  "properties": {
    "path": {
      "type": "string"
    },
    "content": {
      "type": "string"
    },
    "executable": {
      "type": "boolean",
      "description": "Set the executable bit"
    }
  },
  "required": [
    "path",
    "content"
  ]
}

search_replace

Level 1 (Notify). It writes into the worktree.

Find and replace across every file matching a glob. pattern is a regex (with $1 capture references in replacement) unless literal is true. Reports the files and counts changed. For a rename that must follow references semantically, prefer lsp_rename when it is available.

{
  "type": "object",
  "properties": {
    "pattern": {
      "type": "string"
    },
    "replacement": {
      "type": "string"
    },
    "glob": {
      "type": "string",
      "description": "Files to change, e.g. src/**/*.rs"
    },
    "literal": {
      "type": "boolean"
    }
  },
  "required": [
    "pattern",
    "replacement",
    "glob"
  ]
}

markdown_format

Level 1 (Notify). It writes into the worktree.

Format named .md or .markdown files with yucky at 80 columns, in process. paths is a nonempty list of worktree-relative files; no globs, directories, or symlinks. check (default false) reports files needing formatting without writing, returning an error if any differ. All files are checked and formatted in memory before any write. Reports formatted/unchanged files and warnings, not their contents. Does not generate HTML. Before a write, state what you are changing and why; the changes enter normal review.

{
  "type": "object",
  "properties": {
    "paths": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "string"
      }
    },
    "check": {
      "type": "boolean",
      "default": false
    }
  },
  "required": [
    "paths"
  ],
  "additionalProperties": false
}

config_view

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

Read heph's authoritative project or user config, including path overrides. This reads the original file, not a shadow copy. A missing file is empty. Use config_edit for changes, never generic file edits.

{
  "type": "object",
  "properties": {
    "target": {
      "type": "string",
      "enum": [
        "project",
        "user"
      ],
      "default": "project"
    }
  }
}

config_edit

Level 3 (Escalate). It runs a command in the worktree.

Replace heph's authoritative project or user config immediately, outside source review. Read config_view first. Supply the complete current file as old and the complete proposed file as new, preserving comments and unrelated settings. A fresh user approval is required every time, even with --yes or remembered patterns. A concurrent change or invalid config refuses the write. Permission and network policy refresh on the next tool call in each session; other settings require restarting heph (skills can use /skills --reload). Before calling, explain the change and why.

{
  "type": "object",
  "properties": {
    "target": {
      "type": "string",
      "enum": [
        "project",
        "user"
      ],
      "default": "project"
    },
    "old": {
      "type": "string",
      "description": "Complete current contents, empty for a missing file"
    },
    "new": {
      "type": "string",
      "description": "Complete proposed contents"
    }
  },
  "required": [
    "old",
    "new"
  ]
}

log

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

The project's commit history: hash, date, author, subject. Filter with paths, author, since (a date or 2 weeks ago), grep (subject and body), revision (a branch, tag, or range), and limit (default 30). Prefer this to shell(git log).

{
  "type": "object",
  "properties": {
    "paths": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "author": {
      "type": "string"
    },
    "since": {
      "type": "string"
    },
    "grep": {
      "type": "string"
    },
    "revision": {
      "type": "string"
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 500
    }
  }
}

show

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

One commit: message, author, date, and the files it changed. patch includes the diff; paths restricts it. Prefer this to shell(git show).

{
  "type": "object",
  "properties": {
    "revision": {
      "type": "string",
      "description": "A hash, branch, tag, or HEAD~n"
    },
    "patch": {
      "type": "boolean"
    },
    "paths": {
      "type": "array",
      "items": {
        "type": "string"
      }
    }
  },
  "required": [
    "revision"
  ]
}

diff

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

A diff. With no arguments: exactly what you have changed this turn, which is what the user will review. from and to (revisions) and paths for anything else, e.g. from: HEAD~3. stat for a summary only.

{
  "type": "object",
  "properties": {
    "from": {
      "type": "string"
    },
    "to": {
      "type": "string"
    },
    "paths": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "stat": {
      "type": "boolean"
    }
  }
}

status

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

What differs from the project's last commit, in two groups: what this conversation carried into the turn (earlier turns' accepted work, not yet published with /commit), and your changes this turn. Prefer this to shell(git status).

{
  "type": "object",
  "properties": {}
}

branches

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

The project's branches, remote-tracking branches, and tags with their tip commits, and which commit this worktree is built on.

{
  "type": "object",
  "properties": {}
}

blame

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

Who wrote each line of a file and why: for lines written this turn, the tool call and the rationale you gave; for everything else, the project's commit. lines as start-end narrows it.

{
  "type": "object",
  "properties": {
    "path": {
      "type": "string"
    },
    "lines": {
      "type": "string",
      "description": "A range like 40-60"
    }
  },
  "required": [
    "path"
  ]
}

search_history

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

Full-text search over this project's past conversations: what was asked, decided, and done in earlier turns. Returns turn ids with snippets; use read_turn on one for the whole exchange.

{
  "type": "object",
  "properties": {
    "query": {
      "type": "string"
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 50
    }
  },
  "required": [
    "query"
  ]
}

read_turn

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

One past turn in full: the prompt, what was said, each tool call with its parameters and result, how it ended, and what it cost.

{
  "type": "object",
  "properties": {
    "turn_id": {
      "type": "string"
    }
  },
  "required": [
    "turn_id"
  ]
}

read_output

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

Recover a session-owned command log without rerunning the command. Select stdout or stderr, a 1-based byte offset, and a byte limit (capped at 16 KiB). Text rendering replaces invalid or split UTF-8; use encoding=hex for exact bytes. Only logs in this conversation's ancestry are accessible.

{
  "type": "object",
  "properties": {
    "log_id": {
      "type": "string"
    },
    "stream": {
      "type": "string",
      "enum": [
        "stdout",
        "stderr"
      ]
    },
    "offset": {
      "type": "integer",
      "minimum": 1
    },
    "limit": {
      "type": "integer",
      "minimum": 1,
      "maximum": 16384
    },
    "encoding": {
      "type": "string",
      "enum": [
        "text",
        "hex"
      ]
    }
  },
  "required": [
    "log_id"
  ],
  "additionalProperties": false
}

which

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

Whether a command is installed, and where. Reports absent rather than failing; never runs the command.

{
  "type": "object",
  "properties": {
    "commands": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "minItems": 1
    }
  },
  "required": [
    "commands"
  ]
}

man

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

Read a manual page as plain text. section narrows it (e.g. 2 for system calls).

{
  "type": "object",
  "properties": {
    "page": {
      "type": "string"
    },
    "section": {
      "type": "string"
    }
  },
  "required": [
    "page"
  ]
}

shell

Level 2 (Approve). It runs a command in the worktree.

Run a command in the worktree with sh -c, for genuine shell features: pipes, redirection, shell globbing, command chaining. To run a single program, prefer exec (with its env map for environment variables), which spawns it directly and can be approved once for many calls; a shell approval is one exact command, because a wildcard could cover an injected ; rm -rf /. Not for looking at things: view, ls, glob, grep, stat, log, show, diff, status, blame, which, and man are silent and cover every read; a shell command asks the user for approval. git commit here moves a private pointer only; publishing to the user's repository happens through /commit after review and final gates. Returns combined stdout and stderr with the exit code, the middle cut past 16 KB.

{
  "type": "object",
  "properties": {
    "command": {
      "type": "string"
    },
    "timeout_seconds": {
      "type": "integer",
      "minimum": 1,
      "maximum": 3600
    }
  },
  "required": [
    "command"
  ]
}

exec

Level 2 (Approve). It runs a command in the worktree.

Run one program directly, with its arguments passed to it verbatim -- no shell, so no ;, |, >, $VAR, or glob is interpreted. This is the way to run a build, a test, a formatter, a linter, or any single command: prefer it to shell, because one approval (exec(cargo) args(*)) can cover a whole family of calls safely, where a shell approval is one exact command. program is the executable, found on PATH; args is a list of arguments, each a separate string (not one string with spaces). env optionally maps environment variable names to string values for this child only; use it instead of running env or a shell environment prefix. Values are literal, not expanded. Heph's command environment and git-redirection protections take precedence. Reach for shell only for a pipe, a redirect, shell globbing, or chaining. Returns combined stdout and stderr with the exit code, the middle cut past 16 KB. With opt-in command-output compaction, eligible successful status commands may return a fixed terse result and a read_output log ID. Set keep_output=true when inspecting diagnostics or requested data.

{
  "type": "object",
  "properties": {
    "program": {
      "type": "string"
    },
    "args": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "env": {
      "type": "object",
      "additionalProperties": {
        "type": "string"
      },
      "description": "Child-only environment overrides; values are literal strings."
    },
    "timeout_seconds": {
      "type": "integer",
      "minimum": 1,
      "maximum": 3600
    },
    "keep_output": {
      "type": "boolean",
      "description": "Keep normal output for diagnostic/data inspection, even when successful-command compaction is enabled."
    }
  },
  "required": [
    "program"
  ]
}

background

Level 2 (Approve). It runs a command in the worktree.

Start a command that should keep running while you work: a dev server, a watcher, a long build. It runs in the worktree and you get a job number; read its output with job_status and stop it with job_kill. Every job ends when the session does. For a command that finishes on its own in a minute or two, use shell and wait.

{
  "type": "object",
  "properties": {
    "command": {
      "type": "string",
      "description": "The command, run with sh -c in the worktree"
    }
  },
  "required": [
    "command"
  ]
}

job_status

Level 0 (Transparent). It reads the worktree, the shadow history, or the conversation.

A background job's state and the last lines of its output (lines, default 50). With no id, every job this session, one line each.

{
  "type": "object",
  "properties": {
    "id": {
      "type": "integer",
      "minimum": 0,
      "description": "The job number from `background`"
    },
    "lines": {
      "type": "integer",
      "minimum": 1,
      "maximum": 2000,
      "description": "How many lines of output, from the end"
    }
  }
}

job_kill

Level 2 (Approve). It runs a command in the worktree.

Stop a background job and everything it started. Its output stays readable with job_status.

{
  "type": "object",
  "properties": {
    "id": {
      "type": "integer",
      "minimum": 0,
      "description": "The job number from `background`"
    }
  },
  "required": [
    "id"
  ]
}

web_fetch

Level 2 (Approve). It reads over the network; Level 1 to a host on the allowlist (Network).

Fetch a URL with an HTTP GET and return its content as text (HTML is reduced to readable text). Documentation, changelogs, issues. Well-known documentation hosts fetch silently; any other host asks the user once. A redirect to another host is reported, not followed: fetch the new URL if you want it. Nothing is ever posted.

{
  "type": "object",
  "properties": {
    "url": {
      "type": "string",
      "format": "uri"
    }
  },
  "required": [
    "url"
  ]
}

todos

Level 0 (Transparent). It keeps the model's notes for the session; it touches nothing else.

Your task list for the work at hand, shown to the user. Each call replaces the whole list. Use it for work with several steps: write the steps before starting, mark one in_progress while you are on it, mark it completed as soon as it is done, and add steps you discover on the way. Skip it for a single step. Keep items short and in the imperative; twenty is the most a list holds.

{
  "type": "object",
  "properties": {
    "todos": {
      "type": "array",
      "maxItems": 20,
      "items": {
        "type": "object",
        "properties": {
          "content": {
            "type": "string",
            "description": "The step, in the imperative"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "in_progress",
              "completed"
            ]
          }
        },
        "required": [
          "content",
          "status"
        ]
      },
      "description": "The whole list, in order"
    }
  },
  "required": [
    "todos"
  ]
}

ask_user

Level 0 (Transparent). It asks you a question.

Ask the user one question and wait for the answer. Use it when a decision materially changes the work and neither the codebase nor the conversation settles it; never ask in prose, which ends the turn. Read before asking. options may hold two to five short choices, rendered as a picker; five is what a person holds in view. More than five means the list has not been narrowed: read more, or ask a narrower question first and a second with the survivors. The user can always type an answer instead of picking.

{
  "type": "object",
  "properties": {
    "question": {
      "type": "string",
      "description": "One question, as the user will read it"
    },
    "options": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "minItems": 2,
      "maxItems": 5,
      "description": "Two to five short choices"
    },
    "context": {
      "type": "string",
      "description": "One sentence on what the answer changes"
    }
  },
  "required": [
    "question"
  ]
}

submit_for_review

Level 0 (Transparent). It asks you a question.

End the working segment and hand one complete publication unit to heph's gates and review. Call this by itself after finishing the unit's implementation, tests, and documentation. This does not authorize publication or create a Git commit. Heph offers publication through the normal approval UI and starts the next remaining unit only after verified successful publication. Declines and failures pause the sequence; review and gate repairs stay in the current unit. Use an empty remaining_work list for the final unit. Set include_unpublished only after the developer explicitly agrees to combine earlier unpublished work into this unit. No further tool calls in this response will run after a successful submission.

{
  "type": "object",
  "properties": {
    "title": {
      "type": "string",
      "description": "Short title of this publication unit"
    },
    "summary": {
      "type": "string",
      "description": "What is ready for review"
    },
    "commit_message": {
      "type": "string",
      "description": "Proposed commit message; the developer may edit it"
    },
    "remaining_work": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "maxItems": 20,
      "description": "Remaining publication units in order; empty for the final unit"
    },
    "include_unpublished": {
      "type": "boolean",
      "description": "True only after explicit developer consent to include earlier unpublished work"
    }
  },
  "required": [
    "title",
    "summary",
    "commit_message",
    "remaining_work"
  ],
  "additionalProperties": false
}

What the Model Is Told

The system prompt is the standing instruction the model receives on every request. Knowing it tells you what heph has already asked of the model, so you do not repeat it, and what the model believes about its situation. It is the same for every project and conversation except the last lines, which name yours.

The prompt

You are heph, a coding agent working in a terminal alongside a developer. You work in a private git worktree based on the developer's committed repository plus accepted changes from this conversation. Uncommitted work in the developer's checkout is not imported. Nothing you write reaches their files until they publish it with /commit; every edit is safe to make and every edit will be reviewed.

Tools:
- Use the named tool for anything it covers: view, outline, ls, glob, grep, stat for files; log, show, diff, status, blame for history; which and man for the environment. They are silent. Use markdown_format for named Markdown files: it formats with yucky in process, or checks without writing with check=true. To run other programs (build, test, formatter, linter), use exec: it runs one program directly, with its arguments, and one approval (exec(cargo) args(*)) can cover a family of calls. Set environment variables with exec's env map, not by running env or a shell prefix. Use shell only when you need a shell feature -- a pipe, a redirect, shell globbing, or chaining -- because a shell approval is one exact command. Both ask the developer for approval; do not use them to look at things. A process that must keep running while you work (a dev server, a watcher) is a background job: start it with background, read it with job_status, stop it with job_kill.
- When available and applicable, use `markdown_format` to satisfy instructions in skills or project guidance to format Markdown with yucky. Equivalent `yucky --in-place` examples describe the formatting requirement, not an additional command to run. After successful formatting, do not format those files again unless their contents change. This covers formatting only: required HTML generation or unsupported options remain separate operations, using permitted tools. Required checks and acceptance gates still run.
- Before any tool that changes files, state in one or two sentences what you are changing and why. That statement is shown beside the hunk in review and is what the developer accepts or refuses. A write without one still runs, and you will be reminded.
- To ask the developer something, call ask_user. A question in prose ends the turn. Ask only when a decision materially changes the work and neither the codebase nor the conversation settles it; read before asking.
- For work with several steps, keep a task list with todos: write the steps first, mark one in_progress at a time, mark each completed as you finish it. The developer watches it. Skip it for a single step.
- `git commit` in the worktree moves only a private pointer; accepted file state remains private across turns. You cannot publish to the developer's repository yourself: after review, /commit synchronizes committed upstream, runs the final gates, and publishes to this conversation's own branch. A request to commit means preparing a unit for review and publication, not creating private Git history. Do not use `git commit` to fulfill it.
- Requests for multiple commits, separate commits, or one commit per step mean separate review-and-publication cycles. Choose independently reviewable, gate-passing units; keep each unit's tests and documentation with it. Implement only the next unit, then stop for gates, review, and the publication offer. State what is ready and what remains; do not implement later units in the same turn or call a completed todo a published commit.
- When submit_for_review is available, call it by itself to hand off each complete publication unit, with a title, summary, proposed commit message, and ordered remaining_work list (empty for the final unit). It ends generation, not approval: heph runs gates and review, then offers publication. After verified successful publication, heph starts only the next remaining unit with a host-authored receipt. Re-submit after repairs; never treat the tool's success as publication. If earlier unpublished work is explicitly approved for inclusion, record that decision with include_unpublished.
- Do not start the next publication unit until heph or the developer confirms that the previous unit was published successfully. Review acceptance alone is not publication. A declined, cancelled, or failed publication pauses the sequence; gate repairs and review feedback belong to the current unit, not the next one. Do not silently combine units when publication is postponed.
- Before starting a multi-commit sequence, check status. If earlier unpublished work would be included in its first commit, use ask_user to settle how to handle it before proceeding: /commit includes all accepted work since the last publication, not only the latest unit.

Working:
- Read before editing, and read the part you need: outline a file first, then view the items or ranges that matter, rather than whole files. What you read stays in your context for the rest of the turn and costs on every round; a grep with context is often enough. Match the surrounding code's style and the project's conventions.
- Tool results from earlier turns are elided to a one-line stub. Run the tool again for file content, or read_turn for the recorded turn. If a command result includes a retained log ID, use read_output instead of rerunning it. Use exec keep_output=true when inspecting command diagnostics or requested data.
- Be concise. State conclusions, not the process of reaching them. Do not narrate tool calls. Do not restate the request.
- When the work is done, stop: acceptance gates run on what you changed, then the developer reviews it. If a gate fails you will be shown its output and asked to fix it.

Project: /home/you/project
Conversation: main
Your work will be published to a new branch from main

The last three lines vary: the project directory, the conversation, and where the conversation's work goes, read from the conversation and never from your checkout: Your work will be published to a new branch from main before it has a branch of its own, You are on branch heph/wrap-lines (from main) once it does (Branches). The line is omitted when the project is not a git repository or has no commits.

Plan turns

A plan turn inserts this after the static text:

This is a plan turn. Your tool catalog has no write tools and no execution
tools: read and reason, then answer the question, offer a plan concrete enough
to approve, or ask a clarifying question. Do not attempt or announce an edit
you cannot make.

Design turns

A design turn inserts this instead, with your [design] paths:

This is a design turn. Your writes are confined to `docs/designs/**/*.md`, and
you have no execution tools: read and reason, answer questions, clarify scope,
and when the developer asks, write the design there in the project's own form.
Do not attempt or announce an edit outside those paths.

Shared mode guidance

Both plan and design turns append this after their mode-specific text. It does not appear in ordinary coding turns:

General questions, explanations, and hypothetical examples do not request
implementation or live inspection. Treat concrete identifiers, including real
session IDs, as context unless the user explicitly asks to inspect that live
state. Answer from source and available read tools. Do not open live databases,
run programs, or attempt tools outside this mode; do not probe a blocked tool
to trigger a mode switch.

When clarification would materially change the work, use ask_user to clarify
scope, including whether the user wants a general answer or a specific
investigation using code-mode tools. Optional deeper investigation is not a
request to switch. Only when the user explicitly requests work requiring
code-mode tools or writes outside this mode, call request_code immediately,
before other tools or text. Heph owns the mode-switch confirmation; do not
duplicate it in prose or ask_user.

What follows the prompt

After the system prompt, the model receives the conversation: every prompt you sent on this conversation, every reply, every tool call, from the first turn on, and every tool result of the current turn. A tool result from an earlier turn is sent as a one-line stub, [view src/auth.rs: 412 lines, elided], unless it is short, an answer you gave to ask_user, or an activated skill (Context). Two kinds of message are heph's rather than yours, and are labeled so in the record:

  • Gate failures: Your changes failed the acceptance gate name. Fix this. followed by the gate's output.

  • Review results: the verdict with your comments and the model's own rationale beside each hunk, and the constraint list of everything rejected in the turn (Rejections, Comments, and the Next Round).

Nothing in either is a paraphrase of you. Your words go to the model as you typed them.

What the model is not told

There is no memory across projects. Project context files are appended after the static prefix, and Agent Skill names and descriptions follow them, when the turn has the skill tool: a plan turn whose tool set leaves it out is not told about skills it could not load. Disabled skills are never listed. Skill bodies enter the conversation when the model calls the skill tool or the user arms one with /skill; supporting files enter only when the model loads one through the tool.

Where Heph Keeps Things

Heph writes to three places, and only the first is yours.

Your project

Heph writes into the project directory only what you accept in review, and one config edit: A on a permission prompt appends to .heph/config.toml. It never runs git commit, git add, or anything else against your repository; it reads your repository's refs and your working tree, and nothing more.

Your config

~/.config/heph/config.toml (or under $XDG_CONFIG_HOME), written once on the first run if it did not exist, and never rewritten.

The data directory

Everything else is under ~/.local/share/heph/ (or $XDG_DATA_HOME/heph, or --data-root DIR, or $HEPH_DATA_ROOT), keyed by a hash of the project's canonical path so two checkouts of the same repository in different places are two projects.

PathContents
db/<hash>.sqliteThe project's conversations: every turn, message, tool call with its parameters and result, API call with its token counts and cost, and the conversation names and drafts. SQLite in WAL mode; -shm and -wal files sit beside it.
git/<hash>/The shadow repository, a bare git repository heph owns. Snapshots of your tree under refs/heph/user; every turn's per-call commits under refs/heph/turns/<turn>/; a mirror of your repository's branches and tags fetched at each turn; pending applies under refs/heph/pending/ until they finish.
wt/<hash>/<conversation-id>/One worktree per conversation, checked out from the shadow repository. A .heph-ref file inside names the conversation. This is where the model's tools run and where an external review tool opens.
target/<hash>/The shared CARGO_TARGET_DIR for the project's worktrees, when [worktree] shared_target is on.
log/heph.logThe interactive session's log, appended to by every session on every project.

Pending adoption is recorded in adoptions/<conversation-id>.json under the effective data root. It contains the frozen source/destination and private-state manifest and blocks ordinary resume until transfer finishes. Retry heph --adopt FULL-UUID from the same destination; do not delete the journal or either project's storage while adoption is pending. Source shared history, request audits, ignored files, and private resources remain after transfer.

The process's control socket is elsewhere: $XDG_RUNTIME_DIR/heph/<pid>.sock, removed on exit.

Database upgrades

Heph applies pending schema migrations when it opens a project database. Each migration is tracked separately, so switching to a merged branch build applies older pending migrations even if newer ones have already run. A failed upgrade rolls back its pending batch rather than recording a partial success.

Opening with the timestamp-ledger runner converts older databases. After that, old sequential-version builds refuse the database. A newer build also refuses opening if it lacks any migration already applied by another branch. Use a merged build containing all applied migrations; do not delete the database or edit its migration records to work around the refusal. There is no automatic schema downgrade.

Deleting

Deleting ~/.local/share/heph/ forgets every conversation on every project and every worktree; your repositories and files are untouched. Deleting one project's four entries (the hash is the same across db, git, wt, and target) forgets that project. Deleting only target/<hash>/ costs a rebuild and nothing else.

Do not delete a project's git/<hash>/ while an apply might be in flight: the journal that finishes an interrupted apply lives there.

Looking inside

The shadow repository is ordinary git. git -C ~/.local/share/heph/git/<hash> log refs/heph/user shows the snapshots of your tree; git ... log refs/heph/turns/<turn>/chain shows a turn's per-call commits with the model's rationale as each message. The database is ordinary SQLite; sqlite3 ~/.local/share/heph/db/<hash>.sqlite '.tables' lists turn, message, tool_call, api_call, ref, process, tool_catalog, and the full-text index over messages.

Logs

Heph logs at WARN by default; -v raises it, and RUST_LOG overrides it. Subcommands such as heph exec log to stderr. The interactive session owns the terminal, so it appends its log to log/heph.log under the data directory instead, and shows each warning and error in the conversation as a -- warning: or -- error: line, except those it already reports another way, such as a failed turn.

Exit Codes and Errors

Exit codes

CommandCodeMeaning
heph0You left with Ctrl-D or /quit.
heph1Heph could not start: config, provider, database, git, or the terminal. The reason is printed.
heph exec0The turn ended with nothing left to decide.
heph exec2The turn ended short: a call no flag covered, an interrupt, the model's output limit, or changes waiting for review. The reason and the conversation to continue on are the last lines on stderr.
heph exec1Heph failed before or outside the turn.
anything1An error, printed as Error: <what>.

Errors you may see

Each error names what it is about; these are the ones a working setup can produce, and what they mean.

CHECK constraint failed: purpose IN (...) during a repeated-call check An older database layout reported schema version 10 but did not allow progress-check API calls. This prevents the automatic progress assessment and falls back to asking you. Upgrade heph and restart the session: startup repairs this layout automatically, preserving conversation history and audit data. Do not delete the database or change the permission limits to fix it.

database has migration ... unknown to this heph Another branch build has already applied a migration absent from this binary. Use a merged build containing all applied migrations. Migration timestamps do not make an older or incomplete build safe to use with the upgraded schema. Do not delete the database or edit its migration records.

database schema is version 9223372036854775807 This database has been converted to timestamp-based migration tracking, but the running binary still uses sequential versions. Upgrade heph to a build with timestamp-ledger support and all migrations already applied to the database.

config: no provider is configured and none was found in the environment No [providers] in either config file, and neither ANTHROPIC_API_KEY nor AWS credentials in the environment. Set one, or write [providers].

config: no profile to use: 2 providers are configured (a, b), the catalog has defaults for a and b, and nothing says which to use Several providers and no default profile. Pass --profile, set default_profile, or write [profiles]; heph profiles lists them (Tiers and Profiles).

config: no profile named "x"; there are: ... --profile, default_profile, an extends, or the profile a conversation kept names a profile config does not have. A conversation that kept one you removed can be moved with --profile.

config: profile p: role title is claimed by tiers fast and extra Each role goes to one tier of a profile; take it out of one tier's roles.

config: profile p: tier x is not in the profile it extends, so it needs both models and role A tier the base profile lacks is new, and must say both.

config: profiles extend each other in a loop: a -> b -> a Break the loop with an extends that ends at a built-in profile.

config: no tier has role coding; add it to a tier's role in config Your [tiers] cover some roles but not this one. Naming any tier replaces all the defaults, so cover coding, review, and quick at least.

config: tier X: "p/m" is not provider/model-id, or the provider is unknown, or the model is not in the catalog or the provider's config Check the id against heph models, and that the provider is in [providers].

config: tier X: entries name different models A tier is one model on several providers; different models are different tiers.

config: tier X: entries are on providers of different protocols (messages, responses) A tier's providers must all speak the Messages API (Claude) or all speak the Responses API (GPT), since a turn keeps one protocol (Providers). Split them into two tiers.

config: provider X: kind "openai-compat" is not one heph speaks The catalog lists the provider under a protocol heph has no codec for. If the endpoint speaks the Responses API, add kind = "responses" to its [providers] table. The same message names vertex, which arrives in build phase 4.

config: provider X: not in the catalog, so config must give base_url and models A custom provider needs both.

config: "x": not a tier (...) or a model on a configured provider (...) A --model or --tier model matched nothing; the message lists what it could have been. config: --tier x: profile p has no such tier (...) names a tier the profile does not have.

config: PATH: parsing config: ... unknown field A misspelled key or a key unsupported by the running heph version. Sections heph does not know are ignored; keys inside known sections must be right.

cannot reload config; fix it before continuing: ... A live policy refresh could not read, parse, or merge config. The interactive session pauses the pending call with the filename and diagnostic. Edit that authoritative file externally and press r to retry, or s/Esc to stop. Advisory fast-model help appears separately when available; recovery does not wait for it. Neither the diagnostic nor guidance is sent to the working model. Non-interactive execution fails with this error. See Configuration Files.

provider X: API key: environment variable Y is not set The provider's key source is empty. Also "cmd" exited N for api_key_command, and no api_key_env or api_key_command configured for a custom provider without either.

provider bedrock: no AWS region: set AWS_REGION, region in config, or a region in the AWS profile Bedrock needs a region and found none.

provider bedrock: AWS credentials expired at ...: they were exported into the environment as fixed values, which nothing can renew The credentials heph was started with had a fixed expiry, now past. Start heph again under fresh ones, or use an arrangement that renews itself; Bedrock Credentials sets up each of the three.

provider X: HTTP 400: Could not process image The provider rejected an attached image. Bedrock accepts PNG, JPEG, GIF, and WebP of ordinary dimensions; a malformed or unusual file fails this way.

provider X: HTTP 429, 5xx, 529, or API error server_error Rate limited, overloaded, or failing on the provider's side. heph sends the same request again up to three times, waiting 2, 4, then 8 seconds (or what the provider's retry-after asks, up to a minute), with a status line for each wait. It does not retry once any of the answer has been shown, since a second send would repeat it. When the retries run out, the turn fails with this message; try again. Failover to another provider is not implemented yet.

shadow: shadow lock held by pid N for 0ns Another live heph owns this conversation's worktree lease. Close that session before opening the same conversation here. Shadow observation and publication locks wait for live holders without a timeout; a lock held by a dead process is broken automatically.

shadow: git [...]: exit N: ... A git command in the shadow repository failed; the message carries git's own output. Usually git is missing or too old (merge-tree --write-tree needs 2.40), or the data directory is not writable.

terminal: ... The interactive session could not take the terminal: stdout is not a tty, or raw mode failed.

the interactive session needs a terminal; use heph exec with a prompt for a turn without one heph with stdout piped.

--continue belongs to the interactive session; heph exec takes its own flags after the subcommand A session flag was given before a subcommand. Move it after.

A turn that fails

A turn that fails midway, because the provider returned an error or a git step failed, ends as turn failed: <reason> in the conversation. Its rounds so far are recorded; the turn is closed as interrupted, and any work it left in the worktree is offered for review the next time the conversation opens, as for any interrupted turn.

What Is Not Here Yet

This book describes heph as it is. The design it is built toward is larger, and some of its vocabulary leaks through: config keys that are accepted but unused, roles no tier is asked for, a socket command that only acknowledges. This page lists what those are waiting for, so you are not left looking.

Several conversations

/refs, /switch, /fork <initial prompt>, /new <initial prompt>, and /rename [name] are in place. Generated refs become active and run in the foreground. What remains is running several ref turns in one TUI, forking at an arbitrary earlier turn, archive, and /tree. Conversations are already stored as a parent-linked turn tree, so recorded work is ready for those slices.

Background turns. Running a turn on one conversation while you work on another, with budgets and limits for a turn no one is watching. The [notify] switches background_paused, background_review, background_done, and budget_warning, the paused state, and the bg figure the status bar has room for are all for this.

/search over every conversation, Ctrl-R over your prompts, and /recall. The full-text index is already built as messages are stored; the commands that query it are not.

Context management

A context-state inspector and regeneration command are not built. Pressure compression does fold older finished turns at turn boundaries, preserving the current/recent window and active skills; see Context. /summary shows a separate work cache, not that state document. If the protected window itself cannot fit, shorten the prompt, use /clear, or choose a larger context model. Mid-turn compression is intentionally not used to trade away the model's immediate working evidence.

Opt-in successful command output is built, including original-log recovery and choice-question chat fallback. Chunked clearance of oversized logs is not built: complete evidence must fit the selected decision budget, otherwise ordinary capped output is retained. Native cloud decision adapters and broader decision hooks remain pending.

--quick is meant to cap a turn at one round; today it only selects the quick tier.

Models

Switching models mid-turn (/model), automatic failover to a tier's second provider on a failed request, and the Vertex AI provider. A tier's second models entry is accepted and ignored. The research role has no work yet; summarize generates work summaries and pressure-triggered context state updates. Of the OpenAI-shaped endpoints, heph speaks the Responses API only: the Chat Completions API that most openai-compat providers stop at, Azure's own headers, and Bedrock's bedrock-mantle endpoint are not spoken.

Committing from a prompt

/commit is yours to type after a turn, and publishes to the conversation's own branch. The git_commit tool, which lets the model queue that commit during the turn so "add the feature and commit it" is one prompt, is not built, nor is heph exec --commit for committing without a terminal. Heph does not push: /push and the forge tools for pull requests, issues, and CI are not built, nor is /rebase for bringing a stale branch up to date from inside heph.

Gates

A gate can only fail and hand the failure to the model. The hint key, advice appended to that failure, and the fix key, a command heph runs itself once before asking the model, are designed and not built; a [[gates]] entry that sets either is rejected today.

Editing

The LSP tools (lsp_rename, lsp_references, lsp_symbols, and the rest) and the [review] lsp_edits key that governs them.

Images in the terminal

An attached image reaches the model, and shows in the conversation as a chip. Drawing it, and rendering Mermaid diagrams the model writes, by the Kitty or iTerm2 graphics protocol, is not done; the [ui] keys are read for when it is.

Selecting text with the mouse

Heph takes the mouse so the wheel can scroll the conversation, and that leaves dragging to heph too. Today a drag does nothing; hold Shift while you drag and the terminal selects as it does at the shell. What is coming is heph's own selection: drag to highlight, release to copy through the terminal to your clipboard, with the copy being the text as written rather than the screen's cells, so a command or a code block pastes cleanly. A [ui] clipboard_copy key for terminals that cannot take the copy arrives with it.

Background jobs in tmux

A background job runs as a child of heph, and its output is read through job_status. The design has it run in a tmux window of its own as well, where you can watch a dev server's log scroll; that window is not opened yet.

Security hardening

shell runs with no sandbox. Sensitive content detection, turn tainting, hooks ([[hooks]]), and user-defined tools (.heph/tools/) are the last phase. The permission engine and review are the whole defense today: every command asks, and every fetch to a host outside the allowlist asks. What tainting adds is asking again about a promoted or session-approved host once a turn has read a secret; until then those hosts fetch like any allowlisted one. web_search, which the model provider runs, is not offered; the [network] search_max_uses and search_domains keys parse and wait for it.