Internals¶
agedum's launch is deliberately simple: compile the source to a throwaway directory,
then run the command under bubblewrap so
the compiled files appear at their expected paths for that process — and only that
process. The real working tree and $HOME are never written to.
The launch pipeline¶
flowchart TD
a["load_source() + load_global_source()"] --> b["compile_claude / compile_kimi / compile_opencode / compile_cline / compile_reasonix / compile_aider / compile_pi / compile_codex<br/>→ Plan(binds, extra_args, safe_overrides)"]
b --> c["assert_safe(): refuse git-tracked targets"]
c --> d["bwrap --dev-bind / / --tmpfs shadow … --ro-bind src target … -- command extra_args"]
d --> e["child runs, sees injected files"]
e --> f["sweep stub mountpoints bwrap left behind"]
Internally this is three modules:
sources.py— locates the project root and the project/global source files into aSource(root,agents_md,skills_dir).harness.py—compile_claude/compile_kimi/compile_opencode/compile_cline/compile_reasonix/compile_aider/compile_pi/compile_codexrender aSourcepair into aPlan: a list of absolute(compiled-file → mount-target)binds, plusextra_argsto append to the command, plussafe_overrides— targets to shadow with an empty tmpfs instead of binding content (pi uses one to hide the raw.agents/skills/so it does not collide with the compiled.pi/skills/copies).launcher.py—assert_safe,build_bwrap_argv, andrun_virtualfsvalidate, compose thebwrapargv, run the command, and clean up.proxy.py— two per-session localhost reverse proxies the CLI interposes for a launch (the child reaches them at127.0.0.1, shared into the bwrap namespace):FoldProxyfoldssystem-role messages into the top-levelsystemfor strict Anthropic endpoints (claudefoldSystemMessages), andResponsesToChatProxytranslates the codex Responses API ↔ Chat Completions for chat-only providers like DeepSeek (codexchatCompletions).
Provider mode adds one more injection channel on top of the same pipeline:
Launch.config_files — agedum-generated config files a harness needs on disk
(reasonix's project-root reasonix.toml; pi's user-scope models.json /
settings.json, deep-merged onto any existing file). The CLI writes each into the
throwaway dir and appends it to plan.binds, so generated configs go through the same
git-safety check and stub sweep as every other bind. A config file flagged writable
(cline's providers.json) is instead seeded straight into its real target — which the
harness has already put in plan.writable_dirs — with no --ro-bind, so a harness that
rewrites its own config (Cline persisting its provider selection) doesn't hit EROFS.
The compiled tree lives under a tempfile.mkdtemp() directory that is removed when the
command exits.
To see the Plan for a given source without launching — the exact mount targets the
harness will read — run --dry-run: agedum <name> --dry-run (provider mode) or
agedum --wrapper <harness> --dry-run -- <cmd> (wrapper mode). It compiles the source,
prints the bind targets and any appended args, and stops before the bwrap step.
The mount namespace¶
The bwrap argv starts by mirroring the whole real filesystem read-write into the
namespace, then read-only-binds each compiled file over its target:
Because the binds use absolute targets, the same mechanism places project-scope
files inside the tree (./CLAUDE.md) and global-scope files under the user config dir
(~/.claude/...). A --ro-bind masks any pre-existing file or directory at the target
for the duration of the run, and the mask is visible only inside this namespace — other
processes, and your shell after the command exits, see the original tree.
Directory binds are overlaid one level deep, not masked wholesale. The only directory
binds agedum emits are skill trees, and binding the whole compiled dir over
~/.config/opencode/skills/ (or ~/.claude/skills/, ~/.kimi-code/skills/) would hide any
hand-authored skill already living there. So build_bwrap_argv expands a directory bind
into one --ro-bind per child — <compiled>/<name> over <target>/<name> — leaving
unrelated siblings in the real target dir visible. A shipped skill still wins over a
same-named on-disk one (its child bind overlays that subdir); only folders agedum does
not ship survive. File binds (CLAUDE.md / AGENTS.md) pass through unchanged.
This is why agedum is harness-agnostic at the launch layer: every agent CLI ultimately just reads files, and the namespace makes the compiled files be those files.
Safety¶
Two rules, both validated empirically and not to be regressed:
No git-tracked targets¶
The namespace shares the project's real, shared .git directory — it is not
masked. So a git add / git commit run inside the namespace writes to the real
repository. If agedum overlaid a git-tracked file (say a real CLAUDE.md you keep in
the repo), injected content could be committed by accident.
assert_safe therefore refuses to inject over any git-tracked path. Targets must
be untracked and gitignored. Targets outside the project repo (e.g. ~/.claude/...)
are never tracked by this repo, so they are allowed. In practice: list CLAUDE.md,
.claude/, .kimi-code/, .opencode/, .cline/, .reasonix/, .pi/, and .codex/ in your .gitignore.
The check runs over the effective, per-child binds — the exact paths the namespace
will mount. A tracked but unrelated sibling inside a skills target dir (say a
hand-authored skill you deliberately version under .claude/skills/) does not block the
launch, because the per-child overlay never masks it; only a path agedum would actually
bind over must be untracked. safe_overrides are not subject to the check: a tmpfs
shadow is read-only masking, never injectable content.
Stub sweeping¶
To bind a file at a path that does not yet exist, bwrap creates the mountpoint on the
real filesystem first. After the namespace exits, those mountpoints remain as empty
stubs (a 0-byte file, or an empty directory) — the injected content never leaks,
but the empty placeholder can.
run_virtualfs records which candidate paths existed before the run, and after the
command sweeps the ones it created — deepest first, and only if still empty. Candidates
are each target and its immediate parent, taken over the dir-level binds (e.g.
the .claude dir created to hold .claude/skills), the per-child overlay targets
(e.g. .claude/skills/<name> — the empty stub left when agedum ships a skill the target
dir did not already have), and the safe_overrides tmpfs shadows (bwrap creates their
mountpoints the same way). Anything that pre-existed — including a user's
~/.config/opencode/skills/ that already held skills — is left alone. The net effect: a
clean working tree after the command, with the real repo untouched.
Adding a harness¶
A new harness is a single compiler function compile_<harness>(project, global_, dest)
-> Plan. It renders the source however that harness expects and returns binds and/or
extra_args. Register it in the CLI's _COMPILERS table under its --wrapper <harness>
name. The harness need not fit the pure path-discovery shape: the aider harness (the
most recent example) injects instructions as --read flags rather than binds, and skips
skills entirely — both expressed through the same Plan(binds, extra_args) return. The
launcher and safety rules are shared, so a new harness inherits the namespace, git-safety,
and cleanup for free.