Sessions
A session is not a launch. It is a running container, a git worktree, and a branch, which many harnesses take turns inhabiting.
omh new claude ──┐ omh new opencode ┼── exec ──┐ omh s01 attach ──┘ (ssh) │ ▼ ┌──────────────────────────────────────────────────────┐ │ SESSION omh-<repo>-s01 detached, long-lived │ │ │ │ sshd 127.0.0.1:49201 ──── your editor attaches here │ │ codebase-memory-mcp ──── daemon: the index stays warm │ │ │ │ /work ← worktree (host dir, bind-mounted) │ │ staged profile, read-only │ │ /omh/cache ← volume keyed by REPO, not harness │ └──────────────────────────────────────────────────────┘Making the session the unit of work — rather than the launch — is what keeps the
index warm. omh new claude, then omh s01 resume opencode against that same
session, and everything the graph learned is still there: the graph lives in a volume keyed by
repo, and the worktree and branch are on the host, so neither belongs to the
container.
Switching harness is not free, though. An image is built per harness, so a
session started by omh new claude is running the claude image and does not contain
opencode at all. Asking for the other one restarts the sandbox:
$ omh s01 resume opencodeomh: restarting the sandbox for omh/s01 — image (omh/claude:b4ed… → omh/opencode:1e1a…), mounts (6 added, 9 removed)A few seconds, and nothing is lost. It used to be instant and wrong — omh execed
opencode into the claude image, which does not have it, and the harness’s whole
staged profile stayed unmounted. See
what a container is compared against.
A session with a harness still running in it is not restarted; see the same section.
What the agent can reach
Section titled “What the agent can reach”Only the worktree. Your checkout is never mounted, so the agent cannot see
your uncommitted work, cannot touch main, and cannot reach anything outside
the branch it was given.
Review is therefore just git:
$ omh s01 diff$ git log omh/s01That git runs on the host, and so does the way work leaves a session:
$ omh s01 commit -m "Fix the tap guard"$ omh s01 push fix/tap-guardNot a stylistic choice. A worktree’s .git is a file holding an absolute path
to an admin directory in your checkout, and omh mounts only the worktree — so
that pointer leads nowhere from inside a container, and pushing to your remote
is not something the sandbox is in a position to do.
The branch itself is in your checkout the whole time — worktrees share a ref
store — so once the work is committed, git log omh/s01 and git push work
from your own repo without ~/.omh entering into it.
What is writable is a short list, and it grew: the worktree, because that is the
work; credentials, because tokens refresh in place; the sandbox’s own repository
(below); each file carry_in hands over, so the agent can edit config it was
given; the directory the harness writes its transcript to, which is what
omh sNN reads to say what the agent did; and two caches outside /work — the
code graph and the local note store.
Nothing else is, and a test asserts the set by name rather than asserting a list of mount strings, so adding one is a decision somebody has to make on purpose.
Worktrees live outside the repo
Section titled “Worktrees live outside the repo”~/.omh/worktrees/<repo>/<session>/Nested inside the repo, your IDE would index every session’s full copy of the codebase — three sessions, four indexes, and a machine that sounds like it is about to take off.
The agent has git, and it is not yours
Section titled “The agent has git, and it is not yours”Until 2026.08 it had none: the dangling pointer meant every command failed with
fatal: not a git repository, so omh refused the call outright rather than let
an agent spend turns repairing what could not be repaired. That cost the agent
status, diff, log, stash and reset --hard — and cost an attached
editor its source control panel, since the editor runs inside the container —
for the editors that have one; omh s01 attach nvim never did.
So omh gives it a repository of its own. A gitdir under ~/.omh/shadow/, seeded
with a single commit of the tree the session started from — minus everything omh
put there, so your carried .env and omh’s own staged documents are not in it
either — mounted at /omh/shadow, and named by a .git file mounted over
/work/.git. That last
mount is the whole trick: it shadows the pointer for the container’s view only,
so your own file is never written and everything on this page still works.
What makes it safe is what is not in it. It starts at one commit on one
branch, gains whatever the agent does, and never has a remote or a commit from
your checkout — an agent reading its own history learns
nothing about yours, and there is no main in there to move. Shared blobs are
unavoidable, since a file whose content matches yours hashes the same; history
is the thing that must not cross.
It lives at ~/.omh/shadow/<repo>/<session>.git, on the branch
<session>-scratch — keyed by the checkout, like every other per-repo path omh
keeps.
Do not run git in that directory yourself. The agent commits through it,
so its hooks are the agent’s to write, and git --git-dir=… checkout on the
host runs them as you. omh s01 log numbers those commits and omh s01 diff 4 -p reads one, through a git that omh has told to run none of them. If you do
want it in your own hands, read a copy:
$ git clone --no-local ~/.omh/shadow/myrepo/s01.git /tmp/s01-copy$ git -C /tmp/s01-copy log --onelineGit is the design.
Trunk can still reach it, one direction only. omh s01 sync merges the
new base into the session on the host and writes the resulting files in, so
the sandbox receives a tree and never a commit of yours — the rule above holds
through a sync. The agent finds a commit saying base moved to <sha> and, if
anything conflicted, the files sitting there with their markers for it to
resolve — and, at its next start, one sentence saying so, delivered once and
then gone. That last part is Claude Code only; opencode and omp cannot speak at
a session’s start, and say so when they launch. It refuses while the sandbox is running, because what the agent
believes the tree holds is in its conversation and no file on disk reaches
that.
Its commits are not yours until you ask for them. omh s01 commit squashes the
files into one commit of your own and never looks at that repository;
omh s01 commit --keep replants the agent’s commits, messages and all — every one
since the last handover, or the ones you name by their number in omh s01 log:
--keep 1,3-4. Whichever you use, omh s01 rm takes the repository with the session
— so checkpoints you did not harvest go with it, and after a git reset --hard
in the sandbox those are the only copies there were.
Push is the one thing still walled, by git’s own pre-push hook inside that
repository. Be clear about what that is: a signpost, not a control. There is no
remote, so a push fails anyway; the hook is what meets the agent after git’s own
error suggests git remote add, and it is bypassable by --no-verify. Checked
against git 2.55.0 rather than assumed, both directions.
Nothing here contains a determined agent, and nothing ever did — the container
has curl and outbound network. See Risks. What it does
contain is an accident: the sandbox drops every Linux capability and takes back
the eight the entrypoint and sshd need, holds at most 2048 processes, and takes
the memory and CPU ceiling sandbox_memory and sandbox_cpus set. sudo still
works inside — that is a decision, recorded in a test — so this is a wall
against a runaway build, not against an agent that wants out.
Worth being precise about what this buys: a sandbox protects your host. What
protects your repo is the worktree branch. That is why omh s01 rm never
deletes a branch that has commits — only an empty one, which held nothing to
review.
What a container is compared against
Section titled “What a container is compared against”A launch plan is a pure description — image, mounts, network, environment — and a
session container is one plan materialized. The network is the session’s own,
named like its container; omh sNN down and rm remove it with the container. Everything in that list is fixed the
moment the container starts: no later exec adds a mount or changes an image.
So omh stamps the plan onto the container as labels at launch, and compares before reusing one. If they disagree the container is replaced, naming what moved. Two real failures came from never asking:
omh s01 resume opencodeon a session started byomh new claudeexeced a binary that image does not contain.- changing the account between launches went on quietly using the old one — the
exact thing
omh authrefuses to guess about elsewhere. It was--account workthen; the account isomh set account <name>now, and the mount it resolves to is part of the stamp either way.
The harness’s own arguments are deliberately not part of the comparison.
claude --resume x is the same session as claude, and a stamp that moved would
rebuild the sandbox on every flag.
It will not restart over running work
Section titled “It will not restart over running work”Replacing a container kills whatever is inside it, so a session with a live harness is reported instead:
$ omh s01 resume claudeError: session s01 is running opencode and cannot be reused for this launch (image (…)) stop it with omh s01 down or start a fresh one omh new claudeLiveness is read from the dtach sockets, which exist only while their harness
does — a socket is a harness name, where the process table needs one parsed
out of a command line.
It also used to be the only signal that worked. PID 1 in the sandbox was sleep infinity, which reaps nothing, so an exited dtach lingered as
[dtach] <defunct> and pgrep would have answered “still running” for the life
of the session. The container runs under a real init now, so those no longer
accumulate — but a session started by an older omh keeps its zombies until it is
next restarted.
Persistence
Section titled “Persistence”A long-lived container is not a long-lived session. Plain exec ties the
harness’s lifetime to your terminal: close the lid and the agent is hung up on
mid-task while the container keeps running around the corpse.
So every harness is wrapped:
dtach -A /omh/sock/<session>-<harness> <harness> [args…]Detaching is your terminal closing. Reattaching is omh sNN resume — -A
attaches to a live session or creates one, so a second invocation never starts a
second agent. The socket path is a pure function of session and
harness; anything variable in it would silently fork a duplicate.
Some harnesses ship their own resume. Relying on that would be exactly the
per-harness behaviour omh exists to abstract, so persistence is uniform and
lives in the distribution. Turn it off with persistence = "none".
Why not tmux
Section titled “Why not tmux”tmux is a multiplexer and a persistence tool, and omh needs only the second
half — omh s01 attach means SSH already gives you as many shells against a session
as you want.
Adopting tmux would buy one feature we need and one we already have, while importing costs that land exactly where this project is most fragile: a prefix key competing with harness TUI bindings, nested tmux for anyone already running it on the host, and another translation layer over mouse, resize and paste.
dtach does persistence in about a thousand lines with no prefix key, no config
file, and nothing sitting between you and the harness.
Open question. Watching several things in one session — agent, dev server,
shell — is a real want that dtach cannot serve. Whether that calls for tmux
inside the sandbox, host-side panes over SSH, or a supervisor model — a verb
that starts a service and another that shows its output, for the things you do
not watch — is unresolved. Recorded rather than answered.
Lifecycle
Section titled “Lifecycle”$ omh s # sessions, branches, state$ omh s01 down # stop the container, keep worktree and branch$ omh s01 rm # remove the session — the branch survives its workThe branch survives whenever dropping it could lose something: it goes only
when it holds no commits, or when omh can prove trunk already has their content
under another sha — a squash merge — in which case rm names the commit it
landed as. See omh sNN rm.
omh s reports state as English for you and as fields for a script: omh s --json gives each session’s running, behind and work.state without
anyone parsing the table — see what every command
prints.
Sessions idle longer than idle_timeout are stopped on the next launch — but only when their harness has exited. A session whose agent is still running in the container is left alone however old its marker, because stopping it would take the agent’s conversation with it, and a container omh cannot probe is left alone too.
N sessions means N containers, so this is not a nicety — see
risks.
Only the container stops; the worktree and branch survive, so
omh sNN resume puts you back exactly where you left off. The clock measures when you
last launched into or attached to a session, not the agent’s own writes — a
session left running after you walked away is what this exists to reap, and one
where an agent is working unattended is one you started recently.
Unset by default: nothing is stopped unless you ask for it. A session that has
no recorded use — from before this existed, or after clearing ~/.omh/run — is
left alone rather than stopped on a guess.
down and rm differ in what they leave behind, and both leave a branch that
holds commits. To actually discard agent work you delete the branch yourself,
deliberately, with git.
Several sessions at once
Section titled “Several sessions at once”Normal, and the reason sessions are numbered. Each gets its own container, worktree and branch.
One thing they share is the code graph store, keyed by repo. That is what keeps the index warm across a harness switch, and it means an agent in one session can query another session’s graph. Mitigated, not prevented — see Code graph.