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
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
/// Rhei settings loaded from `~/.config/rhei/settings.json` or `.agents/rhei/settings.json`.
#[derive(Debug, Default, Deserialize, Clone)]
struct RheiSettings {
#[serde(default)]
agent: Option<AgentConfig>,
#[serde(default)]
agent_mode: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
agent_timeout: Option<String>,
#[serde(default)]
program_timeout: Option<String>,
/// Spec-aligned nested defaults. The `defaults.{model, agent,
/// agent_mode, agent_timeout, program_timeout, mcp_servers, skills}` keys
/// are the canonical settings shape. The top-level `agent` / `model` /
/// `agent_timeout` / `program_timeout` / `agent_mode` fields above remain
/// readable for backward compatibility.
// §FS-rhei-agents.1.1.1: Nested settings defaults.
#[serde(default)]
defaults: SettingsDefaults,
/// Registry of agent transport profiles keyed by agent id.
#[serde(default)]
agents: BTreeMap<String, CustomAgentProfile>,
/// §FS-rhei-agents.1.1.3: Registry of model profiles keyed by model id.
#[serde(default)]
models: BTreeMap<String, ModelProfile>,
/// Registry of MCP server profiles keyed by server id.
#[serde(default)]
mcp_servers: BTreeMap<String, McpServerProfile>,
/// Registry of skill profiles keyed by skill id.
#[serde(default)]
skills: BTreeMap<String, SkillProfile>,
/// Top-level snapshots block. The field is retained verbatim from
/// settings so the snapshot subsystem (impl-rhei-snapshots) can read its
/// configured `cache_dir`, `redactor`, and adapter gates without
/// reparsing the file.
// §FS-rhei-agents.1.1.6 §FS-rhei-snapshot-operations.4: Snapshot settings.
#[serde(default)]
snapshots: Option<SnapshotSettings>,
}
/// Top-level `snapshots` settings block.
///
/// `cache_dir` defaults to `.rhei/cache/snapshots` under the plan workspace;
/// fields omitted here inherit from global settings before defaults are
/// applied.
// §FS-rhei-snapshot-operations.4.1 §FS-rhei-snapshot-operations.4.2: Settings block.
#[derive(Debug, Default, Deserialize, Clone)]
struct SnapshotSettings {
#[serde(default)]
cache_dir: Option<PathBuf>,
#[serde(default)]
experimental: Option<serde_json::Value>,
#[serde(default)]
provider_cache_ttl: BTreeMap<String, String>,
#[serde(default)]
redactor: Option<PathBuf>,
/// Optional allow-list for redactor environment forwarding. The v1 hook
/// keeps the parent environment closed by default per §4.2.
#[serde(default)]
redactor_env: Vec<String>,
}
fn merge_snapshot_settings(
global: Option<SnapshotSettings>,
project: Option<SnapshotSettings>,
) -> Option<SnapshotSettings> {
match (global, project) {
(None, None) => None,
(Some(settings), None) | (None, Some(settings)) => Some(settings),
(Some(mut global), Some(project)) => {
if project.cache_dir.is_some() {
global.cache_dir = project.cache_dir;
}
if project.experimental.is_some() {
global.experimental = project.experimental;
}
for (provider, ttl) in project.provider_cache_ttl {
global.provider_cache_ttl.insert(provider, ttl);
}
if project.redactor.is_some() {
global.redactor = project.redactor;
}
if !project.redactor_env.is_empty() {
global.redactor_env = project.redactor_env;
}
Some(global)
}
}
}
fn snapshot_cache_dir(settings: &RheiSettings, workspace_root: &Path) -> PathBuf {
let configured = settings
.snapshots
.as_ref()
.and_then(|snapshots| snapshots.cache_dir.clone())
.unwrap_or_else(|| PathBuf::from(".rhei/cache/snapshots"));
if configured.is_absolute() {
configured
} else {
workspace_root.join(configured)
}
}
/// §FS-rhei-agents.1.1.3: One entry in the merged `models` registry.
#[derive(Debug, Default, Deserialize, Clone)]
struct ModelProfile {
/// Provider identifier such as `anthropic` or `openai`.
#[serde(default)]
provider: Option<String>,
/// Concrete provider model name (`claude-sonnet-4-6`, `o3`, ...). Passed
/// to the agent's `model_flag` when present.
#[serde(default)]
model: Option<String>,
/// Preferred agent id when `rhei run` needs to spawn this model
/// autonomously and no other level configured one.
#[serde(default)]
default_agent: Option<String>,
/// Per-agent launch overrides for this model, keyed by agent id.
#[serde(default)]
agents: BTreeMap<String, ModelAgentBinding>,
}
/// One `models.<id>.agents.<agent>` binding. Only `timeout` is consumed by
/// `rhei run` today; `args` and `autonomous_args` are accepted by the parser
/// for forward compatibility.
#[derive(Debug, Default, Deserialize, Clone)]
struct ModelAgentBinding {
#[serde(default)]
#[allow(dead_code)]
args: Vec<String>,
#[serde(default)]
#[allow(dead_code)]
autonomous_args: Vec<String>,
#[serde(default)]
timeout: Option<String>,
}
/// Nested `defaults` section in settings.
///
/// `mcp_servers` and `skills` use `Option<Vec<_>>` so the merge layer can
/// distinguish "unset" (inherit) from "empty" (explicitly clear inherited).
#[derive(Debug, Default, Deserialize, Clone)]
struct SettingsDefaults {
/// §FS-rhei-agents.1.1.1: Default model profile id.
#[serde(default)]
model: Option<String>,
/// Default agent id resolved against the `agents` registry. The spec
/// requires a bare string id — inline agent objects are rejected by
/// `AgentConfig`'s transparent deserialisation, which surfaces a JSON
/// type error.
#[serde(default)]
agent: Option<AgentConfig>,
/// Default agent mode applied when a state does not set `agent_mode`.
/// `null` explicitly clears an inherited default.
#[serde(default)]
agent_mode: Option<String>,
#[serde(default)]
agent_timeout: Option<String>,
/// §FS-rhei-agents.1.1.1: Default program timeout.
#[serde(default)]
program_timeout: Option<String>,
#[serde(default)]
mcp_servers: Option<Vec<StateMcpEntry>>,
#[serde(default)]
skills: Option<Vec<StateSkillEntry>>,
}
/// Built-in agent registry.
///
/// Each entry is a ready-to-use `CustomAgentProfile` for one of the agents
/// that Rhei supports out of the box. The per-agent "autonomous" flag set
/// that was hard-coded as `default_args` is now exposed as a named `yolo`
/// mode so states and defaults can select it explicitly via `agent_mode`.
///
/// A user-written entry with the same id in global or project settings
/// replaces the built-in entry wholesale (see `load_merged_settings`).
fn built_in_agents() -> BTreeMap<String, CustomAgentProfile> {
fn flags(items: &[&str]) -> Vec<String> {
items.iter().map(|s| (*s).to_string()).collect()
}
let modes_yolo_only = |yolo: Vec<String>| {
let mut modes = IndexMap::new();
modes.insert("yolo".to_string(), yolo);
modes
};
let mut agents = BTreeMap::new();
agents.insert(
"claude-code".to_string(),
CustomAgentProfile {
command: flags(&["claude"]),
prompt_flag: Some("-p".to_string()),
model_flag: Some("--model".to_string()),
stdin_prompt: false,
mcp_config_flag: Some("--mcp-config".to_string()),
skill_flag: Some("--skill".to_string()),
modes: modes_yolo_only(flags(&["--permission-mode", "bypassPermissions"])),
..Default::default()
},
);
// codex: `codex exec` is non-interactive. The `yolo` mode mirrors the
// known-agent profile table: `--sandbox danger-full-access --skip-git-repo-check
// -c approval_policy="never"`. `-c approval_policy="never"` replaced the
// older `-a never` short flag, which codex-cli no longer accepts.
// §FS-rhei-agents.2: Built-in codex profile.
agents.insert(
"codex".to_string(),
CustomAgentProfile {
command: flags(&["codex", "exec"]),
prompt_flag: None,
model_flag: Some("--model".to_string()),
stdin_prompt: true,
mcp_flag: Some("--mcp".to_string()),
modes: modes_yolo_only(flags(&[
"--sandbox",
"danger-full-access",
"--skip-git-repo-check",
"-c",
"approval_policy=\"never\"",
])),
..Default::default()
},
);
// gemini: `--approval-mode yolo` is the autonomous posture
// (`auto_edit` still prompts on shell tool calls).
agents.insert(
"gemini".to_string(),
CustomAgentProfile {
command: flags(&["gemini"]),
prompt_flag: Some("--prompt".to_string()),
model_flag: Some("--model".to_string()),
stdin_prompt: false,
modes: modes_yolo_only(flags(&["--approval-mode", "yolo"])),
..Default::default()
},
);
// kilocode: `kilo --auto "<prompt>"` is the documented CI invocation;
// `--yolo` auto-approves tool permissions. `--auto` takes the prompt
// as its argument, so it maps onto `prompt_flag`.
agents.insert(
"kilocode".to_string(),
CustomAgentProfile {
command: flags(&["kilo"]),
prompt_flag: Some("--auto".to_string()),
model_flag: Some("--model".to_string()),
stdin_prompt: false,
modes: modes_yolo_only(flags(&["--yolo"])),
..Default::default()
},
);
// cursor: the headless binary is `cursor-agent` (distinct from the
// `cursor` IDE launcher). `-p`/`--print` is the non-interactive flag;
// `--force` is the auto-approve posture.
agents.insert(
"cursor".to_string(),
CustomAgentProfile {
command: flags(&["cursor-agent"]),
prompt_flag: Some("--print".to_string()),
model_flag: Some("--model".to_string()),
stdin_prompt: false,
modes: modes_yolo_only(flags(&["--force"])),
..Default::default()
},
);
// pi (openclaw / badlogic pi-coding-agent). Headless mode exits
// deterministically after one turn. pi has no permission layer — modes
// are intentionally empty; isolation is the caller's responsibility
// (e.g. sandbox/container).
// §FS-rhei-agents.2: Built-in pi profile.
agents.insert(
"pi".to_string(),
CustomAgentProfile {
command: flags(&["pi"]),
prompt_flag: Some("-p".to_string()),
model_flag: Some("--model".to_string()),
stdin_prompt: false,
skill_flag: Some("--skill".to_string()),
session: Some(serde_json::json!({
"resume": {"flag": "--continue"},
"fork": {"flag": "--fork"},
"interactive": {},
"session_dir_flag": "--session-dir",
"no_session_flag": "--no-session",
"layout": {"kind": "FlatById", "ext": "jsonl"}
})),
..Default::default()
},
);
agents
}
#[derive(Debug, Clone)]
struct SettingsDocument {
raw: serde_json::Value,
typed: RheiSettings,
}
fn empty_settings_document() -> SettingsDocument {
SettingsDocument {
raw: serde_json::Value::Object(serde_json::Map::new()),
typed: RheiSettings::default(),
}
}
fn load_settings_document(path: &Path) -> MietteResult<SettingsDocument> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(empty_settings_document());
}
Err(err) => {
return Err(miette!(
help = settings_help(),
"failed to read settings '{}': {err}", path.display()
));
}
};
let raw: serde_json::Value = serde_json::from_str(&contents)
.map_err(|err| miette!(
help = settings_help(),
"failed to parse settings '{}': {err}", path.display()
))?;
let typed: RheiSettings = serde_json::from_value(raw.clone())
.map_err(|err| miette!(
help = settings_help(),
"failed to decode settings '{}': {err}", path.display()
))?;
Ok(SettingsDocument { raw, typed })
}