1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
//! Profile resolution. Three layers, later winning:
//!
//! 1. `~/.omh/profile` personal, every project
//! 2. `<repo>/.omh/profile` project, committed, shared with the team
//! 3. `<repo>/.omh/local` project, gitignored, yours alone
//!
//! Layer 2 is committed, so it must never hold a secret; that is what layer 3
//! and `carry_in` are for.
//!
//! Nothing here is ever copied into your home directory. Layers resolve to a
//! list of paths and the launcher bind-mounts them, which is why there is no
//! drift to fight and no daemon to run.
use crate::adapter::Capability;
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
pub struct Paths {
pub root: PathBuf,
pub repo: PathBuf,
}
impl Paths {
pub fn discover(cwd: &Path) -> Result<Self> {
let home = dirs::home_dir().context("no home directory")?;
Ok(Self {
root: home.join(".omh"),
repo: repo_root(cwd)?,
})
}
pub fn adapters(&self) -> PathBuf {
self.root.join("adapters")
}
pub fn editors(&self) -> PathBuf {
self.root.join("editors")
}
/// The base set as shipped: what `init` seeds and what `omh why` explains.
/// Versioned files, oldest kept, so an upgrade can eventually diff two.
pub fn base(&self) -> PathBuf {
self.root.join("base")
}
pub fn creds(&self, harness: &str) -> PathBuf {
self.root.join("creds").join(harness)
}
/// Outside the repo on purpose: nested worktrees make your IDE index every
/// session's full copy of the codebase.
pub fn worktrees(&self) -> PathBuf {
self.root.join("worktrees").join(self.repo_id())
}
/// Per-launch staging. Keyed by repo as well as session and harness: two
/// checkouts both on `s01` must not share a rendered profile.
pub fn staging(&self, session: &str, harness: &str) -> PathBuf {
self.runs().join(session).join(harness)
}
/// Per-repo run state: staged profiles, and the marker recording when each
/// session was last used.
pub fn runs(&self) -> PathBuf {
self.root.join("run").join(self.repo_id())
}
/// A throwaway working directory, deliberately outside `worktrees/` so a
/// login never appears in `omh s ls` as a session you could resume.
pub fn scratch(&self, name: &str) -> PathBuf {
self.root.join("scratch").join(self.repo_id()).join(name)
}
pub fn keys(&self) -> PathBuf {
self.root.join("keys").join(self.repo_id())
}
/// The local note store — keyed by repo, and outside the checkout so it
/// outlives the worktree that produced it. A session is a git worktree
/// holding tracked files only, and `omh s rm` removes it with `--force`,
/// so a gitignored store inside the repo would be both invisible to the
/// sandbox and destroyed by session removal.
///
/// The committed half of the store is not here: it is tracked, so it
/// belongs in the repo, and it arrives in every worktree by itself.
pub fn notes(&self) -> PathBuf {
self.root.join("notes").join(self.repo_id())
}
/// Cache volume — keyed by repo, deliberately not by harness. This is what
/// lets memory survive a harness switch.
pub fn cache_volume(&self) -> String {
format!("omh-cache-{}", self.repo_id())
}
pub fn network(&self) -> String {
format!("omh-{}", self.repo_id())
}
pub fn container(&self, session: &str) -> String {
format!("omh-{}-{session}", self.repo_id())
}
pub fn repo_name(&self) -> String {
self.repo_id()
}
fn repo_id(&self) -> String {
self.repo
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "repo".into())
}
}
pub struct Profile {
/// Existing layers, in application order.
pub layers: Vec<PathBuf>,
}
impl Profile {
pub fn resolve(paths: &Paths) -> Self {
let layers = [
paths.root.join("profile"),
paths.repo.join(".omh/profile"),
paths.repo.join(".omh/local"),
]
.into_iter()
.filter(|p| p.exists())
.collect();
Self { layers }
}
/// Every layer's copy of `cap`'s source, in application order. Missing
/// layers are skipped, so an empty result means "nothing declared".
pub fn sources(&self, cap: Capability) -> Vec<PathBuf> {
self.layers
.iter()
.map(|l| l.join(cap.source()))
.filter(|p| p.exists())
.collect()
}
/// Capabilities the profile actually carries.
///
/// Not currently called outside tests: the launcher reports dropped
/// capabilities from the adapter side instead. Kept because it is the
/// profile-side half of that answer and `omh eject` will need it.
#[allow(dead_code)]
pub fn declared(&self) -> Vec<Capability> {
Capability::ALL
.into_iter()
.filter(|c| !self.sources(*c).is_empty())
.collect()
}
}
/// Walk up looking for `.git`. The worktree model needs a real repo, so a
/// missing one is a hard error rather than a silent fallback to `cwd`.
pub fn repo_root(start: &Path) -> Result<PathBuf> {
let mut cur = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
loop {
if cur.join(".git").exists() {
return Ok(cur);
}
if !cur.pop() {
anyhow::bail!(
"{} is not inside a git repository\n\
omh isolates the agent on a worktree branch, which needs one.\n\
run `git init` first.",
start.display()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixture {
_dir: tempfile::TempDir,
paths: Paths,
}
fn fixture(layers: &[(&str, &str, &str)]) -> Fixture {
let dir = tempfile::tempdir().unwrap();
let paths = Paths {
root: dir.path().join("home"),
repo: dir.path().join("repo"),
};
for (layer, name, body) in layers {
let base = match *layer {
"personal" => paths.root.join("profile"),
"shared" => paths.repo.join(".omh/profile"),
"local" => paths.repo.join(".omh/local"),
other => panic!("unknown layer {other}"),
};
let p = base.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, body).unwrap();
}
Fixture { _dir: dir, paths }
}
#[test]
fn layers_apply_personal_then_shared_then_local() {
let f = fixture(&[
("personal", "AGENTS.md", "one"),
("shared", "AGENTS.md", "two"),
("local", "AGENTS.md", "three"),
]);
let sources = Profile::resolve(&f.paths).sources(Capability::Rules);
let bodies: Vec<_> = sources
.iter()
.map(|p| std::fs::read_to_string(p).unwrap())
.collect();
assert_eq!(bodies, ["one", "two", "three"], "local must apply last");
}
#[test]
fn absent_layers_are_skipped_not_faked() {
let f = fixture(&[("local", "AGENTS.md", "only")]);
let profile = Profile::resolve(&f.paths);
assert_eq!(profile.layers.len(), 1);
assert_eq!(profile.sources(Capability::Rules).len(), 1);
assert!(profile.sources(Capability::Skills).is_empty());
}
#[test]
fn declared_reports_only_present_capabilities() {
let f = fixture(&[
("personal", "AGENTS.md", "r"),
("shared", "mcp.json", "{}"),
("shared", "skills/x/SKILL.md", "s"),
]);
let declared = Profile::resolve(&f.paths).declared();
assert_eq!(
declared,
vec![Capability::Rules, Capability::Skills, Capability::Mcp]
);
}
/// Worktrees live outside the repo so an IDE opened on the repo root does not
/// index every session's full copy of the codebase.
#[test]
fn worktrees_live_outside_the_repo() {
let f = fixture(&[]);
assert!(!f.paths.worktrees().starts_with(&f.paths.repo));
assert!(f.paths.worktrees().starts_with(&f.paths.root));
}
/// Keyed by repo, not harness — this is what lets memory survive a switch.
#[test]
fn cache_volume_is_harness_independent() {
let f = fixture(&[]);
assert_eq!(f.paths.cache_volume(), "omh-cache-repo");
}
#[test]
fn missing_git_repo_is_a_hard_error() {
let dir = tempfile::tempdir().unwrap();
let err = repo_root(dir.path()).unwrap_err();
assert!(err.to_string().contains("git init"), "got: {err}");
}
/// Regression: staging was keyed by session and harness only, so two repos
/// both using session `s01` shared one rendered profile — repo A's MCP
/// config could be mounted into repo B's sandbox.
#[test]
fn staging_is_keyed_by_repo() {
let dir = tempfile::tempdir().unwrap();
let a = Paths {
root: dir.path().into(),
repo: dir.path().join("alpha"),
};
let b = Paths {
root: dir.path().into(),
repo: dir.path().join("beta"),
};
assert_ne!(a.staging("s01", "claude"), b.staging("s01", "claude"));
}
#[test]
fn staging_still_separates_sessions_and_harnesses() {
let f = fixture(&[]);
let p = &f.paths;
assert_ne!(p.staging("s01", "claude"), p.staging("s02", "claude"));
assert_ne!(p.staging("s01", "claude"), p.staging("s01", "opencode"));
}
}