Skip to content

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 a Source (root, agents_md, skills_dir).
  • harness.pycompile_claude / compile_kimi / compile_opencode / compile_cline / compile_reasonix / compile_aider / compile_pi / compile_codex render a Source pair into a Plan: a list of absolute (compiled-file → mount-target) binds, plus extra_args to append to the command, plus safe_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.pyassert_safe, build_bwrap_argv, and run_virtualfs validate, compose the bwrap argv, run the command, and clean up.
  • proxy.py — two per-session localhost reverse proxies the CLI interposes for a launch (the child reaches them at 127.0.0.1, shared into the bwrap namespace): FoldProxy folds system-role messages into the top-level system for strict Anthropic endpoints (claude foldSystemMessages), and ResponsesToChatProxy translates the codex Responses API ↔ Chat Completions for chat-only providers like DeepSeek (codex chatCompletions).

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:

bwrap --dev-bind / / --ro-bind <compiled> <target> … -- <command> <extra_args>

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.