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
| Requirement | Why |
|---|---|
| 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. |
rg | Ripgrep runs the grep and glob tools and lists files for search_replace. |
| A terminal | The interactive session takes the terminal over. heph exec works without one. |
| Credentials | An 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-Vcan 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
-
Heph copied your project into a worktree of its own and pointed the model at that.
-
The model read files silently and edited the copy; each edit was committed to heph's private history with the rationale as its message.
-
A shell command stopped to ask you, and your
abecame a session-wide approval. -
Gates ran on the model's changes.
-
You reviewed hunk by hunk; a comment sent the model another round inside the same turn.
-
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 afterEnter, 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).RightorLeftwith 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;okmeans it finished;!!means it failed. A finished call'sokis 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
todostool. 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;Leftfolds 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.
| Key | Does |
|---|---|
Enter | Send |
Shift+Enter, Alt+Enter, Ctrl-J | New line |
Left, Right, Home, End | Move; Ctrl-B, Ctrl-F, and Ctrl-A do the same as in a shell |
Ctrl-Left, Ctrl-Right, Alt-B, Alt-F | Move by word |
Up, Down | Move between lines of a multi-line prompt |
Backspace, Delete | Delete a character (Ctrl-H is backspace) |
Ctrl-W, Ctrl-Backspace | Delete the word before the cursor |
Ctrl-K | Delete to the end of the line |
Ctrl-U | Clear the input (or withdraw the newest queued message) |
Ctrl-C | Clear 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:
| Attached | Becomes |
|---|---|
| A text file | A document block, titled with the path |
| A PNG, JPEG, GIF, or WebP | An image block the model can see |
| A directory | A listing of its entries |
| A file over 64 KB | A 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
| Level | Name | What happens | Tools |
|---|---|---|---|
| 0 | Transparent | Runs silently | Everything 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 |
| 1 | Notify | Runs; you see it happen | The writers: edit, create, search_replace; web_fetch to a host on the allowlist |
| 2 | Approve | Stops and asks | shell, background, job_kill; web_fetch to any other host |
| 3 | Escalate | Asks, and --yes alone is not enough | config_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:
-
The tool and where it runs. For
execandshell, the worktree and which of your directories it mirrors. Forweb_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, aLocks if approved: build-output, test-dbline 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. -
The call, verbatim. The whole command or URL, not a summary.
-
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 saysNo 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. -
Why it is asking, when the level alone would not have: today, the repeated-call anomaly below.
-
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
| Key | Meaning |
|---|---|
y | This one call. |
n (or Esc) | Deny. The model is told the call was denied and continues. |
N | Open a reason input. Heph waits for Enter, then augments the denial returned to the model with your feedback. |
a | This call, and every call matching the pattern, until you leave the session. |
A | The same, and the pattern is written to the effective original project config under [permissions] promoted, so it holds in future sessions. |
e | Edit 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:
-
denyin config. A match refuses the call at any level, before anything else is considered. The model is told which pattern denied it. -
approveandpromotedin config, then approvals given withathis session, then the run's--allow-tools,--yes, and--yes-reallyflags. A match on a Level 2 call runs it without asking. -
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:
| Tool | Level | Does |
|---|---|---|
background | 2 | Starts a command in the worktree and returns a job number, at once |
job_status | 0 | The job's state and the last lines of its output; with no number, a list |
job_kill | 2 | Stops 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:
-
Enterstarts 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). -
qrejects 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. -
Escputs 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
| Key | Does |
|---|---|
a | Accept the hunk as it is. |
r | Reject it without comment. The model is told it was refused. |
c | Reject 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. |
e | Edit 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. |
s | Skip. The hunk is rejected without comment, exactly as r. |
q | Reject everything not yet decided and end the review. |
u, Backspace | Undo: back to the previous hunk, its decision taken back. From the first hunk, back to the brief. |
Esc | Stop 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 = | Runs | Notes |
|---|---|---|
"builtin" | heph's own screen | The default. The Built-in Review. |
"lazygit" | lazygit | Its staging view; Space toggles a hunk. |
"git add -p" | git reset -q && git add -p | Everything starts unstaged; answer y to the hunks you accept. |
"fugitive" | nvim -c Gstatus | vim-fugitive's status buffer; - toggles staging, dp for hunks. |
"$EDITOR" | $EDITOR . | Your editor on the worktree; stage with whatever git integration it has. |
| anything else | That command, via sh -c | Run 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}"]
| Key | Meaning |
|---|---|
name | Shown in the status line and to the model. |
paths | Globs. The gate runs when the turn changed a matching path. Empty or absent: every turn. |
command | Shell string or direct-execution array, run in the worktree. Exit 0 passes. {files}: see below. |
retry_limit | How 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,
/commitrefuses 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 iscommitted 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-commithook runs on the candidate and can refuse; -
prepare-commit-msgruns before the editor, andcommit-msgafter it, and can rewrite the message or refuse; -
commit.gpgSignsigns, and can ask for its passphrase; -
post-commitruns 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
-
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. -
Prompt, review, and
/commit. The conversation's branch now holds one commit per/commit, on top of the base. -
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. -
Keep going in the same conversation. Review feedback becomes the next prompt; the next
/commitadds 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
| Set | Contains |
|---|---|
research | Every tool that reads, web_fetch included, plus ask_user. |
local | research without the network: no web_fetch. |
history | search_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
mainto 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_userif 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] pathsthat a design turn of this conversation changed and that no earlier--implementnamed. 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_historycan find, to look once for a question the document leaves open, and not to reopen what the document decided. -
Where it runs.
/codereturns to the class the session started as, so the build runs on thecodingtier.
--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:
| Tier | Roles | Default model (Bedrock) |
|---|---|---|
frontier | coding, review | The catalog's large model, e.g. Claude Opus |
workhorse | research, summarize | The catalog's small model, e.g. Claude Haiku |
fast | quick, commit-message, title | The 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:
| Tokens | Rate |
|---|---|
| input | Prompt tokens sent fresh |
| output | What the model wrote, thinking included |
| cache write | Prompt tokens written to the provider's cache |
| cache read | Prompt 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;
/cleardoes 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
codingat Sonnet for everyday work and keep Opus for a--modeloverride 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
openaiandbedrock-openaiproviders carry OpenAI's models, the second under the same AWS credentials asbedrock. 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:
-
licenseandcompatibility(each 1-500 characters) are shown in/skills.compatibilityis for you; it is not sent to the model. -
metadata, a map of strings, is kept and not used. -
allowed-toolsis shown asrequests 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:
| Row | Meaning |
|---|---|
* name | Active; 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 |
- name | Available: 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:
/skillrefuses it and theskilltool answers that it is already active. -
An active skill that has changed on disk shows in
/skillsas[active] (changed on disk)./skill NAMEarms the new version, and theskilltool 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-refrejects 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-refalso 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
| Code | Meaning |
|---|---|
| 0 | The turn ended with nothing left to decide: the model finished, and any change was applied. |
| 2 | The 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. |
| 1 | Heph 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_userreturns 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 -
SIGINTends the turn as interrupted.
Three flags widen what runs unasked. They approve tool calls, and nothing else: none of them stands in for review.
| Flag | Approves |
|---|---|
--allow-tools PATTERN | Calls matching the pattern, e.g. --allow-tools 'exec(cargo) args(*)'. Repeatable. |
--yes | Every Level 2 call. |
--yes-really | Level 2 and Level 3. |
--accept-edits | Not 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
| Variable | Value |
|---|---|
@heph-state | asking, permission, review, paused, done, idle, or working |
@heph-ref | The conversation's human label (<unnamed> when absent) |
@heph-ref-id | Its full UUID, used for switching rather than the label |
@heph-waiting | 1 when the session is waiting on you, else 0 |
@heph-project | The project directory's name |
@heph-sock | The 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:
-
statusanswers with the session's state as JSON: the active conversation, its state, its spend, whether it is waiting. -
switch NAMEasks 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:
| Channel | When |
|---|---|
osc9 | The terminal is iTerm2, WezTerm, Ghostty, Kitty, or foot (by $TERM_PROGRAM and $TERM): a desktop notification through the terminal, no daemon. |
osc777 | The terminal is rxvt-unicode. |
tmux | Under 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. |
bell | Anything 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.
| File | Scope |
|---|---|
~/.config/heph/config.toml | You, on every project. $XDG_CONFIG_HOME/heph/config.toml if that variable is set. |
.heph/config.toml | This 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
| Section | What it configures | Chapter |
|---|---|---|
[providers] | How to reach a model endpoint | Providers |
[profiles] | Named sets of tiers: which model does which work | Tiers |
[tiers] | The profile default, written whole | Tiers |
[permissions] | What runs without asking, what never runs | Permissions |
[permissions.repeat] | Soft and hard repeated-call checkpoints | Permissions |
[locks] | Exclusive named execution resources | Execution Locks |
[network] | Which hosts web_fetch reaches without asking | Network |
[[gates]] | Commands that must pass before review | Gates |
[review] | How you review | Review |
[worktree] | What is copied into the worktree, what is linked | Worktree |
[toolsets] | Named read-only tool lists for plan turns | Tool Sets |
[design] | Where design turns may write | Design Turns |
[notify] | Which events leave the pane, and how | Notifications |
[input] | Attachments, ! commands, the clipboard | Input |
[ui] | Image display | UI |
[context] | What of the conversation each round carries | Context |
[decisions] | Decision and command-output policy | Tiers |
[skills] | Which discovered skills the model may see | Skills |
[git] | Which branch conversations start from | Git |
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'sextendsand each tier field it writes replace yours, and the rest of yours is kept.default_profileis the project's when its file sets it. -
[permissions]: the lists concatenate. Projectdenypatterns 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-commandsreplaces 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;defaultsis 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]: thedisabledlists concatenate, like[permissions], so a project can disable more skills, never fewer.projectis 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] linklists 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
| Key | Applies to | Meaning |
|---|---|---|
kind | any | messages (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_env | messages, responses, ollama | Environment variable holding the API key. Default for anthropic: ANTHROPIC_API_KEY; for openai: OPENAI_API_KEY. |
api_key_command | messages, responses, ollama | A shell command whose stdout is the key, for password managers. Takes precedence over api_key_env. |
base_url | any but vertex | The 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). |
headers | messages, responses, ollama | Extra request headers, as a table. |
region | bedrock, bedrock-responses | AWS 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. |
profile | bedrock, bedrock-responses | AWS profile name. Default: the SDK's default credential chain, which is what aws-vault exec and SSO sessions feed. |
caching | any | Place cache breakpoints (default true). Set false for an endpoint that rejects them; every request then pays full input rate. |
cache_ttl | any | How 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> | any | A model declared in config; see below. |
num_ctx | ollama | Runtime context override; model context_window takes precedence |
keep_alive | ollama | Duration 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:
| Key | Meaning | Default |
|---|---|---|
context_window | Tokens the model accepts | Catalog, else 0 |
default_max_tokens | max_tokens when the tier gives none | Catalog, else 4096 |
cost_per_1m_in | USD per million uncached input tokens | 0 |
cost_per_1m_out | USD per million output tokens | 0 |
cost_per_1m_in_cached | USD per million cache-write tokens | 0 |
cost_per_1m_out_cached | USD per million cache-read tokens | 0 |
can_reason | Supports extended thinking | Catalog, else false |
supports_attachments | Accepts images and documents | Catalog, else false |
context_tier_basis | Threshold count: input, output, or total | input |
context_tiers | Whole-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 have | Use |
|---|---|
aws-vault, and you run everything through aws-vault exec | aws-vault's credential server: add --ecs-server to the command you already type |
| An IAM Identity Center (SSO) login and the AWS CLI | An SSO profile: no aws-vault needed |
aws-vault, and you want plain heph to work with no wrapper | A credential_process profile |
| A role to assume on top of any of these | Any of the three; see Assuming a role |
| An EC2 instance or container with a role | Nothing 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] profileorAWS_PROFILEordefault, 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-servercase); then instance metadata. The first that answers wins, so a[default]profile with keys in~/.aws/credentialstakes precedence over aws-vault's server. -
If the environment holds fixed credentials with
AWS_CREDENTIAL_EXPIRATIONin the past, heph refuses the request before signing it, with the message shown at the top of this chapter. A403from 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:
| Tier | Roles | Model |
|---|---|---|
frontier | coding, review, design | The provider's default large model |
workhorse | research, summarize | Its default small model |
fast | quick, commit-message, title, progress-check | Its 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
modelsandroles. -
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:
-
the profile
default_profilenames, at the top of the file:default_profile = "work" -
else the profile called
default; -
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
| Flag | Effect |
|---|---|
--profile NAME | Run on this profile |
--tier NAME=MODEL | Run one tier on another model: a tier name, provider/model-id, or a model id one configured provider offers. Repeatable |
--model MODEL | Short 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
| Key | Meaning | Default |
|---|---|---|
models | provider/model-id references, in order. All must name the same model; several entries mean several providers for it. | Required for a new tier |
roles | The roles this tier fills, as a list. | Required for a new tier |
max_tokens | Output 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" |
effort | With 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
| Role | Used by |
|---|---|
coding | An ordinary turn |
review | --review turns |
design | --plan and --design turns |
quick | --quick turns |
research | Nothing yet (subtasks) |
summarize | Work summaries and context state updates |
commit-message | /commit's drafted message |
title | Naming /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 *)",
]
| Key | Meaning |
|---|---|
approve | Patterns whose matching Level 2 calls run unasked. |
deny | Patterns whose matching calls are refused, at any level, before anything else is considered. |
promoted | Patterns 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).
-
toolis a tool name from the catalog: lowercase letters, digits, underscores. -
globmatches the call's principal parameter: the command forshell, the URL forweb_fetch, the path forview,ls,glob,grep,stat,edit,create,search_replace, andblame. Tools with no principal (status,branches,ask_user) match only as a bare name. -
A bare
toolmatches 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:
| Pattern | Matches |
|---|---|
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_fetch | Every 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:
| Pattern | Expansion |
|---|---|
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 |
shell | every 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:
-
Any
denymatch refuses the call, with every matching rule's explanation. Otherwise, a[network] denyhost refusesweb_fetch. -
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.
-
Level 0 and 1 calls run.
-
An
approveorpromotedmatch runs a Level 2 call. -
A pattern approved with
athis session runs it. -
For
web_fetch, a host on the allowlist runs it at Level 1, and a host approved withathis session runs it. -
--allow-tools,--yes, or--yes-reallyruns it (--yes-reallyalone covers Level 3). -
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"]
| Key | Meaning | Default |
|---|---|---|
defaults | Whether the curated hosts below are on the allowlist. | true |
allow | Hosts fetched at Level 1: no prompt, the call shown as it runs. | none |
deny | Hosts refused before anything else is considered, session approvals included. The model is told which host refused it. | none |
promoted | Hosts 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}"]
| Key | Meaning | Default |
|---|---|---|
name | Shown in status lines and to the model. | Required |
paths | Globs over the files the turn changed; the gate runs when any matches. * stops at /, ** does not. | Any path |
command | Shell string or executable-and-arguments array, run in the worktree. Exit 0 passes. {files}: see below. | Required |
retry_limit | Failures 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
| Key | Meaning | Default |
|---|---|---|
interactive | Where you accept and reject: "builtin", "lazygit", "git add -p", "fugitive", "$EDITOR", or any command, run in the worktree. | "builtin" |
pager | A 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_threshold | Fraction 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
| Key | Meaning | Default |
|---|---|---|
link | Paths 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_size | Files 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_target | Set 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
| Set | Contains |
|---|---|
research | Every read-only tool: the file, history, conversation, and environment tools, web_fetch, and ask_user. The default. |
local | research without web_fetch. |
history | search_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}'"
| Key | Meaning | Default |
|---|---|---|
channel | auto, 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" |
desktop | Persistent freedesktop notifications over the session D-Bus service, in addition to the channel and command. | false |
turn_done | Notify when a turn finishes. | true |
prompt | Notify when a permission prompt, a question, or a review opens. | true |
suppress_focused | Send nothing for a pane you are looking at: the active pane of an attached tmux client, or a terminal reporting focus. | true |
command | A 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"
| Key | Meaning | Default |
|---|---|---|
max_attach | A 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_in | Where !cmd runs: "worktree" (heph's copy, with the model's edits) or "tree" (yours). !!cmd runs in the other. | "worktree" |
clipboard_image | Command 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
| Key | Meaning | Default |
|---|---|---|
image_max_rows | Tallest an inline image is drawn, in rows. | 24 |
image_viewer | Command that opens an image full-size on Enter. Default: the system opener. | none |
images | auto, kitty, iterm2, or off: which terminal graphics protocol to use. | "auto" |
sidebar | auto, on, or off: when the skills/cost sidebar shows. | "auto" |
wrap_width | The 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
| Key | Meaning | Default |
|---|---|---|
recent_turns | Finished 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_water | History fraction that triggers a turn-boundary state update. | 0.8 |
low_water | See the threshold and cap rules below. | 0.5 |
summary_max_tokens | Approximate state-record cap, excluding verbatim active skills. | 4000 |
instruction_max_tokens | See 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
| Key | Default | Meaning |
|---|---|---|
disabled | none | Skills to keep from the model |
project | true | Whether 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"
| Key | Meaning | Default |
|---|---|---|
base_branch | The 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_prefix | Put 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/ |
attribution | A 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.
| Option | Meaning |
|---|---|
-C, --project DIR | The project directory. Default: the current directory. |
--user-config FILE | Read this user config instead of ~/.config/heph/config.toml. |
--project-config FILE | Read this project config instead of .heph/config.toml. |
--data-root DIR | Keep state under this directory instead of ~/.local/share/heph. Also HEPH_DATA_ROOT. |
-v, --verbose | More 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, --version | The 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:
| Flag | Meaning |
|---|---|
--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 BRANCH | Resume by exact current or recorded publication branch in this checkout. One match opens directly; several prompt with metadata and cached summaries. |
--fork REF | Create 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 UUID | Transfer this exact saved session from another project into this Git checkout, then resume; confirmation defaults to no. |
--profile NAME | Run the conversation on this profile instead of the default. Kept with it. |
--tier NAME=MODEL | Run one tier of the profile on another model. Repeatable; kept with the conversation. |
--model MODEL | Short for --tier on the tier doing the conversation's role: a tier name, provider/model-id, or a bare id one provider offers. |
--review | Every turn on the review tier, with the full catalog. |
--plan | Every turn a plan turn: read-only tools, no shell, on the design tier. |
--design | Every turn a design turn: a plan turn that may write only under [design] paths, on the design tier. |
--quick | Every turn on the quick tier. |
--tools NAME | With --plan or --design: which tool set the turns read with. Default research. |
--accept-edits | Apply changes without review. |
--allow-tools PATTERN | Pre-approve calls matching the pattern. Repeatable. |
-y, --yes | Auto-approve Level 2 calls and bypass adoption confirmation. Never a review. |
--yes-really | Auto-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:
| Flag | Meaning |
|---|---|
--stream | Echo the model's text to stderr as it streams. Stdout still gets the final text. |
--skill NAME | Activate 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 BRANCH | The 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 PATH | Build 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 skillsprints the/skillsreport.--ref REFmarks the skills active on that conversation. -
heph skills checkparses and validates every discovered skill, or theSKILL.mdfiles 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 NAMEprints 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.
| Flag | Meaning |
|---|---|
--name TEXT | Case-insensitive substring of the conversation name |
--branch TEXT | Case-insensitive substring of base, reserved, or historical branches |
--published | Only conversations with a recorded successful local /commit |
--limit N | Maximum results after filtering and sorting; unlimited by default |
[REGEX], --match REGEX | Rust regex over names and branch metadata; (?i) opts into case-insensitive matching |
--uncommitted, --unmerged, --merged, --unknown | Independent evidence filters, combined with AND |
--runtime STATE | running, waiting, failed, stopped, or unknown |
--inactive-for DURATION | Positive integer followed by s, m, h, d, or w |
--disk-usage | Worktree-only apparent bytes; symlink targets and shared stores excluded |
--summary | Include cached work summaries; off by default, no generation; conflicts with --ids |
--ids | Full UUIDs, one per line; conflicts with --json and --summary |
--json | Object 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.
| Command | Output |
|---|---|
status | One 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. |
list | A table of every heph pane: pane, project, conversation, state, waiting, spend. |
next | Switches client, window, and pane to the heph most in need of attention. Does nothing and exits 0 if none runs. |
conf | Prints 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
| Key | Action |
|---|---|
Enter | Send; append to the FIFO queue if a turn is running |
Shift+Enter, Alt+Enter, Ctrl-J | New line |
Tab | Complete a path after @, or a skill name after /skill; again cycles matches |
Ctrl-E | Edit the prompt in $VISUAL or $EDITOR |
Ctrl-V | Attach an image from the clipboard |
Ctrl-U | Withdraw 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, End | Move; Ctrl-B, Ctrl-F, Ctrl-A as in a shell |
Ctrl-Left, Ctrl-Right, Alt-B, Alt-F | Move by word |
Backspace, Ctrl-H, Delete | Delete a character |
Ctrl-W, Ctrl-Backspace | Delete the word before the cursor |
Ctrl-K | Delete to end of line |
Ctrl-R | Reserved 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.
| Key | Action |
|---|---|
Ctrl-P | Toggle user-prompts-only history (same as /prompts) |
PgUp, PgDn | Scroll ten lines |
Shift+Up, Shift+Down | Scroll one line |
| Mouse wheel | Scroll 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.
| Key | Action |
|---|---|
Esc | Ask to interrupt the running turn; y confirms. Closes a confirmation or the help. |
Ctrl-C | Ask to abort the running tool; y confirms. With no tool running, as Esc. With nothing running, clears the input. |
Ctrl-D | Confirm leaving, even when idle. Press again to exit; any other key cancels. |
Ctrl-S | Show 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.
| Key | Action |
|---|---|
y | Allow this call |
n, Esc | Deny |
a | Allow, and remember the shown pattern for this session |
A | Allow, and write the edited scope to the effective project config |
e | Edit the pattern; Enter keeps it, Esc cancels the edit |
Up, Down, PgUp, PgDn, mouse wheel | Scroll call and scope explanations; compact action keys stay in view |
Question
| Key | Action |
|---|---|
1 to 5 | Type an option number; edit it before confirming |
Enter | Confirm a matching option number, otherwise send the typed answer |
Esc | No answer; the model is told no one answered |
PgUp, PgDn, mouse wheel | Scroll 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).
| Key | Action |
|---|---|
| Typing | Filter the branches to those containing the text |
Up, Down | Choose among the branches shown |
Enter | Start from the chosen branch, or the name typed if none matches |
Esc | Cancel the turn; the prompt goes back in the input |
Review: the brief
| Key | Action |
|---|---|
Enter | Begin: the built-in review, or open the external tool |
q | Reject every hunk and end the turn |
Esc | Later: the change stays in the worktree, unreviewed |
Up, Down, PgUp, PgDn | Scroll |
Review: hunk by hunk
| Key | Action |
|---|---|
a | Accept |
r | Reject |
c | Reject with a comment; Enter sends, Esc cancels |
e | Edit the file in your editor |
s | Skip: rejected without comment |
q | Reject everything undecided and finish |
u, Backspace, Left | Undo the last decision; from the first hunk, back to the brief |
Esc | Stop here: decided hunks stand, the rest wait for /review |
Up, Down, PgUp, PgDn | Scroll the hunk |
Review: comments after an external tool
| Key | Action |
|---|---|
Enter, Tab | Send the comment, or skip if empty |
Shift+Tab | Back to the previous rejection |
Esc | Finish now, with the comments given so far |
Up, Down, PgUp, PgDn | Scroll |
Confirmation
| Key | Action |
|---|---|
y | Yes |
| anything else | No, 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.
| Command | Does |
|---|---|
/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 |
/cost | Session/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 |
/help | Show 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 --offer | Clear repository-local offer suppression without starting a turn |
/init --help | Print the local bootstrap contract |
/instructions | List 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 |
/prompts | Toggle a user-prompts-only history (same as Ctrl-P); display-only, with permission and review dialogs still available |
/quit, /exit | Skip the Ctrl-D confirmation, then flush acknowledged drafts |
/refs | List 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 |
/skills | List skills by project and user, active and disabled ones, discovery errors, and what is armed or queued (Skills) |
/skills --reload | Reread [skills] and the skill directories and say what changed (Skills) |
/stats | Full 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 |
/tiers | A 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 gatename. 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.
| Path | Contents |
|---|---|
db/<hash>.sqlite | The 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.log | The 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
| Command | Code | Meaning |
|---|---|---|
heph | 0 | You left with Ctrl-D or /quit. |
heph | 1 | Heph could not start: config, provider, database, git, or the terminal. The reason is printed. |
heph exec | 0 | The turn ended with nothing left to decide. |
heph exec | 2 | The 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 exec | 1 | Heph failed before or outside the turn. |
| anything | 1 | An 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
/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.