Skip to main content

supercode_harness/
profiles.rs

1//! ORCH-10 — the `profile` noun at the OBSERVED tier: one uniform row for
2//! every named, routable config home supercode can see, read from each
3//! harness's own files and never written.
4//!
5//! Four sources, four kinds:
6//!
7//! * `preset` — supercode's own [`crate::presets::RESERVED_PRESET_NAMES`];
8//!   the analog of a Codex profile for supercode itself (no home directory).
9//! * `codex_profile` — `[profiles.<name>]` tables in `$CODEX_HOME/config.toml`,
10//!   with the top-level `profile = "<name>"` naming the default.
11//! * `hermes_profile` — `HERMES_HOME/profiles/<name>/` directories plus the
12//!   implicit `default` profile (HERMES_HOME itself), routed by
13//!   `gateway.profile_routes` in `HERMES_HOME/config.yaml` and partitioned in
14//!   `state.db` by the `profile_name` column.
15//! * `openclaw_agent` — `<openclaw home>/agents/<id>/` directories plus the
16//!   `agents.entries` map in `openclaw.json`, routed by `bindings[]`.
17//!
18//! Everything here is read-only: no harness home is created, written, or
19//! migrated. A harness with no profile concept is refused with
20//! [`ProfileError::UnsupportedHarness`], never a silent empty list.
21
22use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25use rusqlite::Connection;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29use crate::{HarnessHomes, HarnessId};
30
31/// Stable row schema shared by Rust, JSON-RPC, the SDKs, and the CLI.
32pub const PROFILES_SCHEMA: &str = "supercode.profiles.v1";
33
34/// Harnesses that have a profile concept supercode reads, in product order.
35/// Every other harness id is [`ProfileError::UnsupportedHarness`].
36pub const PROFILE_HARNESSES: &[&str] = &[
37    HarnessId::SUPERCODE,
38    HarnessId::CODEX,
39    HarnessId::HERMES,
40    HarnessId::OPENCLAW,
41    HarnessId::ORCHESTRATOR,
42];
43
44/// Hermes's implicit profile: HERMES_HOME itself, the `profile_name IS NULL`
45/// partition of `state.db` and the target when no route matches.
46pub const HERMES_DEFAULT_PROFILE: &str = "default";
47
48/// OpenClaw's conventional default agent — the id behind the
49/// `agent:<id>:main` session key and the `agents/main` config home. Used only
50/// when no entry declares `default: true`.
51pub const OPENCLAW_DEFAULT_AGENT: &str = "main";
52
53/// Which harness concept a row came from.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ProfileKind {
57    /// A supercode built-in preset.
58    Preset,
59    /// A `[profiles.<name>]` table in `$CODEX_HOME/config.toml`.
60    CodexProfile,
61    /// A `HERMES_HOME/profiles/<name>` config home.
62    HermesProfile,
63    /// An `<openclaw home>/agents/<id>` config home.
64    OpenclawAgent,
65    /// An orchestrator profile FOLDER: the root of
66    /// `SUPERCODE_ORCHESTRATOR_HOME` for `default`, `profiles/<name>/`
67    /// otherwise (`docs/ORCHESTRATOR-IR.md` §6).
68    OrchestratorProfile,
69}
70
71impl ProfileKind {
72    /// Stable wire spelling, identical to the serde representation.
73    pub const fn as_str(self) -> &'static str {
74        match self {
75            Self::Preset => "preset",
76            Self::CodexProfile => "codex_profile",
77            Self::HermesProfile => "hermes_profile",
78            Self::OpenclawAgent => "openclaw_agent",
79            Self::OrchestratorProfile => "orchestrator_profile",
80        }
81    }
82}
83
84/// One named config home, uniform across harnesses.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct ProfileRow {
87    /// Profile / agent / preset name, unique within its harness.
88    pub name: String,
89    /// Owning harness id.
90    pub harness: String,
91    /// Which harness concept this row came from.
92    pub kind: ProfileKind,
93    /// The profile's own directory, when it has one.
94    pub home: Option<PathBuf>,
95    /// Whether the harness routes here when nothing more specific matches.
96    pub default: bool,
97    /// Routing entries that target this profile; `None` when the harness's
98    /// routing table could not be read (no config file), not zero.
99    pub routes: Option<u64>,
100    /// Sessions this profile owns; `None` when the store could not be read.
101    pub sessions: Option<u64>,
102    /// Model the profile pins, when it pins one.
103    pub model: Option<String>,
104    /// The worker harness this profile runs its conversations on, when the
105    /// harness's profile concept has one. Only the orchestrator does: its
106    /// `worker:` block names any registry id (`docs/ORCHESTRATOR-IR.md`
107    /// §2.2). Additive on the wire, like every other optional row field.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub worker: Option<String>,
110}
111
112/// Read-only profile failures.
113#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
114pub enum ProfileError {
115    /// The harness has no profile / agent / preset concept supercode reads.
116    #[error("harness `{harness}` has no profile concept (profiles exist for: {})", PROFILE_HARNESSES.join(", "))]
117    UnsupportedHarness {
118        /// The harness id that was asked for.
119        harness: String,
120    },
121    /// The harness has profiles, but not this one.
122    #[error("`{harness}` has no profile `{name}`")]
123    NotFound {
124        /// Harness that was searched.
125        harness: String,
126        /// Profile name that was not found.
127        name: String,
128    },
129}
130
131/// List every profile supercode can see, optionally restricted to one
132/// harness. Rows are ordered by harness (as in [`PROFILE_HARNESSES`]) then
133/// by name.
134pub fn list_profiles(
135    homes: &HarnessHomes,
136    harness: Option<&str>,
137) -> Result<Vec<ProfileRow>, ProfileError> {
138    if let Some(harness) = harness {
139        if !PROFILE_HARNESSES.contains(&harness) {
140            return Err(ProfileError::UnsupportedHarness {
141                harness: harness.to_string(),
142            });
143        }
144    }
145    let mut rows = Vec::new();
146    for id in PROFILE_HARNESSES {
147        if harness.is_some_and(|requested| requested != *id) {
148            continue;
149        }
150        match *id {
151            HarnessId::SUPERCODE => rows.extend(preset_rows()),
152            HarnessId::CODEX => rows.extend(codex_rows(&homes.codex)),
153            HarnessId::HERMES => rows.extend(hermes_rows(&homes.hermes)),
154            HarnessId::OPENCLAW => rows.extend(openclaw_rows(&homes.openclaw)),
155            HarnessId::ORCHESTRATOR => rows.extend(orchestrator_rows(&homes.orchestrator)),
156            _ => {}
157        }
158    }
159    Ok(rows)
160}
161
162/// Read one profile by harness and name.
163pub fn get_profile(
164    homes: &HarnessHomes,
165    harness: &str,
166    name: &str,
167) -> Result<ProfileRow, ProfileError> {
168    list_profiles(homes, Some(harness))?
169        .into_iter()
170        .find(|row| row.name == name)
171        .ok_or_else(|| ProfileError::NotFound {
172            harness: harness.to_string(),
173            name: name.to_string(),
174        })
175}
176
177// ---------------------------------------------------------------------------
178// supercode presets
179// ---------------------------------------------------------------------------
180
181/// supercode's own analog of a named profile. A preset is compiled in, so it
182/// has no home directory and no store to count sessions from; `default` is
183/// the preset the CLI extends when a config names none.
184fn preset_rows() -> Vec<ProfileRow> {
185    let mut rows: Vec<ProfileRow> = crate::presets::RESERVED_PRESET_NAMES
186        .iter()
187        .map(|name| ProfileRow {
188            name: (*name).to_string(),
189            harness: HarnessId::SUPERCODE.to_string(),
190            kind: ProfileKind::Preset,
191            home: None,
192            default: *name == "supercode-default",
193            routes: None,
194            sessions: None,
195            model: crate::presets::lookup(name)
196                .and_then(|text| toml::from_str::<toml::Value>(text).ok())
197                .and_then(|doc| {
198                    doc.get("core")
199                        .and_then(|core| core.get("model"))
200                        .and_then(toml::Value::as_str)
201                        .map(str::to_string)
202                }),
203            worker: None,
204        })
205        .collect();
206    rows.sort_by(|left, right| left.name.cmp(&right.name));
207    rows
208}
209
210// ---------------------------------------------------------------------------
211// Codex
212// ---------------------------------------------------------------------------
213
214/// Codex profiles are `[profiles.<name>]` tables in `$CODEX_HOME/config.toml`
215/// (`inventory/codex.md` §6), selected at launch with `-p/--profile`. They
216/// are tables in ONE file, not directories, so `home` is null; the top-level
217/// `profile = "<name>"` key names the one Codex uses by default.
218///
219/// `sessions_root` is `HarnessHomes::codex` (`$CODEX_HOME/sessions`).
220fn codex_rows(sessions_root: &Path) -> Vec<ProfileRow> {
221    let Some(codex_home) = sessions_root.parent() else {
222        return Vec::new();
223    };
224    let Ok(text) = std::fs::read_to_string(codex_home.join("config.toml")) else {
225        return Vec::new();
226    };
227    let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
228        return Vec::new();
229    };
230    let selected = doc.get("profile").and_then(toml::Value::as_str);
231    let Some(profiles) = doc.get("profiles").and_then(toml::Value::as_table) else {
232        return Vec::new();
233    };
234    profiles
235        .iter()
236        .map(|(name, table)| ProfileRow {
237            name: name.clone(),
238            harness: HarnessId::CODEX.to_string(),
239            kind: ProfileKind::CodexProfile,
240            home: None,
241            default: selected == Some(name.as_str()),
242            routes: None,
243            sessions: None,
244            model: table
245                .get("model")
246                .and_then(toml::Value::as_str)
247                .map(str::to_string),
248            worker: None,
249        })
250        .collect()
251}
252
253// ---------------------------------------------------------------------------
254// Hermes
255// ---------------------------------------------------------------------------
256
257/// Hermes profiles are config HOMES under `HERMES_HOME/profiles/<name>`, plus
258/// the implicit `default` profile which is HERMES_HOME itself. All profiles
259/// share one `state.db`, partitioned by the `profile_name` column (NULL for
260/// the default profile), and routing lives in `gateway.profile_routes`.
261///
262/// `state_db` is `HarnessHomes::hermes` (`HERMES_HOME/state.db`).
263fn hermes_rows(state_db: &Path) -> Vec<ProfileRow> {
264    let Some(home) = state_db.parent() else {
265        return Vec::new();
266    };
267    if !home.is_dir() {
268        return Vec::new();
269    }
270    let config = std::fs::read_to_string(home.join("config.yaml")).ok();
271    let gateway = yaml_child(config.as_deref().unwrap_or_default(), "gateway");
272    let profile_routes = yaml_child(&gateway, "profile_routes");
273    let routes = config
274        .as_ref()
275        .map(|_| count_yaml_route_targets(&profile_routes));
276
277    let mut names = vec![HERMES_DEFAULT_PROFILE.to_string()];
278    if let Ok(entries) = std::fs::read_dir(home.join("profiles")) {
279        let mut found: Vec<String> = entries
280            .flatten()
281            .filter(|entry| entry.path().is_dir())
282            .filter_map(|entry| entry.file_name().into_string().ok())
283            .collect();
284        found.sort();
285        names.extend(found);
286    }
287    names
288        .into_iter()
289        .map(|name| {
290            let is_default = name == HERMES_DEFAULT_PROFILE;
291            let profile_home = if is_default {
292                home.to_path_buf()
293            } else {
294                home.join("profiles").join(&name)
295            };
296            let model = if is_default {
297                config.as_deref().and_then(hermes_model)
298            } else {
299                std::fs::read_to_string(profile_home.join("config.yaml"))
300                    .ok()
301                    .as_deref()
302                    .and_then(hermes_model)
303            };
304            ProfileRow {
305                name: name.clone(),
306                harness: HarnessId::HERMES.to_string(),
307                kind: ProfileKind::HermesProfile,
308                home: Some(profile_home),
309                default: is_default,
310                routes: routes
311                    .as_ref()
312                    .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
313                sessions: hermes_session_count(state_db, (!is_default).then_some(name.as_str())),
314                model,
315                worker: None,
316            }
317        })
318        .collect()
319}
320
321// ---------------------------------------------------------------------------
322// The orchestrator
323// ---------------------------------------------------------------------------
324
325/// The orchestrator's profiles are FOLDERS (`docs/ORCHESTRATOR-IR.md` §6):
326/// the root of `SUPERCODE_ORCHESTRATOR_HOME` is the implicit `default`
327/// profile and each `profiles/<name>/` is a named one. Every folder is a
328/// complete home — its own `config.yaml`, `cron/`, and `state.db` — so unlike
329/// Hermes there is no shared store to partition.
330///
331/// Two columns differ from Hermes's and are read from the folder's own
332/// `worker:` block: `worker` (the harness this profile's conversations run
333/// on) and `model` (the model that worker is started with).
334fn orchestrator_rows(root: &Path) -> Vec<ProfileRow> {
335    let dirs = crate::orchestrator_profile_dirs(root);
336    // Routes are declared per folder and target profile NAMES, so the count
337    // for a profile is over every folder's table.
338    let mut routes: Option<BTreeMap<String, u64>> = None;
339    for (_, dir) in &dirs {
340        let Ok(config) = std::fs::read_to_string(dir.join("config.yaml")) else {
341            continue;
342        };
343        let block = yaml_child(&yaml_child(&config, "gateway"), "profile_routes");
344        let counts = routes.get_or_insert_with(BTreeMap::new);
345        for (target, found) in count_yaml_route_targets(&block) {
346            *counts.entry(target).or_default() += found;
347        }
348    }
349    dirs.into_iter()
350        .map(|(name, dir)| {
351            let config = std::fs::read_to_string(dir.join("config.yaml")).ok();
352            let worker = config.as_deref().map(|text| yaml_child(text, "worker"));
353            ProfileRow {
354                name: name.clone(),
355                harness: HarnessId::ORCHESTRATOR.to_string(),
356                kind: ProfileKind::OrchestratorProfile,
357                default: name == HERMES_DEFAULT_PROFILE,
358                routes: routes
359                    .as_ref()
360                    .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
361                sessions: orchestrator_binding_count(&dir.join("state.db")),
362                model: worker
363                    .as_deref()
364                    .and_then(|block| yaml_scalar(block, "model")),
365                worker: worker
366                    .as_deref()
367                    .and_then(|block| yaml_scalar(block, "harness")),
368                home: Some(dir),
369            }
370        })
371        .collect()
372}
373
374/// Conversations one orchestrator profile holds: rows in its own `bindings`
375/// table. An unreadable or absent store answers `None` — unknown, never zero.
376fn orchestrator_binding_count(state_db: &Path) -> Option<u64> {
377    let connection = Connection::open_with_flags(
378        state_db,
379        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
380    )
381    .ok()?;
382    let count: i64 = connection
383        .query_row("SELECT COUNT(*) FROM bindings", [], |row| row.get(0))
384        .ok()?;
385    Some(count.max(0) as u64)
386}
387
388/// The model a Hermes `config.yaml` pins.
389///
390/// Hermes writes it as a `model:` BLOCK whose `default:` key holds the id —
391/// and its own shipped config says of that block: "Both `default` and `model`
392/// work as the key name here", so both are read. A bare top-level
393/// `model: <id>` scalar is accepted as well.
394fn hermes_model(config: &str) -> Option<String> {
395    if let Some(pinned) = yaml_scalar(config, "model") {
396        return Some(pinned);
397    }
398    let block = yaml_child(config, "model");
399    yaml_scalar(&block, "default").or_else(|| yaml_scalar(&block, "model"))
400}
401
402/// Count the sessions one Hermes profile owns. `None` names the implicit
403/// default profile, whose rows carry `profile_name IS NULL`. An unreadable
404/// store answers `None` — unknown, never zero.
405fn hermes_session_count(state_db: &Path, profile: Option<&str>) -> Option<u64> {
406    let connection = Connection::open_with_flags(
407        state_db,
408        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
409    )
410    .ok()?;
411    let count: i64 = match profile {
412        Some(name) => connection
413            .query_row(
414                "SELECT COUNT(*) FROM sessions WHERE profile_name = ?1",
415                [name],
416                |row| row.get(0),
417            )
418            .ok()?,
419        None => connection
420            .query_row(
421                "SELECT COUNT(*) FROM sessions WHERE profile_name IS NULL",
422                [],
423                |row| row.get(0),
424            )
425            .ok()?,
426    };
427    Some(count.max(0) as u64)
428}
429
430// ---------------------------------------------------------------------------
431// OpenClaw
432// ---------------------------------------------------------------------------
433
434/// OpenClaw agents are config homes under `<openclaw home>/agents/<id>`, each
435/// with its own store, declared in `openclaw.json` under `agents.list` and
436/// routed by `bindings[]` (`inventory/orchestration.md` rows 2 and 4). The
437/// union of the directories and the declared entries is the row set: an
438/// entry with no directory yet is still a routable agent, and a directory
439/// with no entry is still a config home holding sessions.
440///
441/// One directory is NOT an agent: the empty shell OpenClaw's own
442/// `agents delete` leaves behind. Measured at the pin (receipt
443/// `orch21-openclaw-profiles-receipt-2026-09-03.json`), that verb prunes the
444/// config entry, `agents/<id>/agent` and `agents/<id>/sessions` but leaves
445/// `agents/<id>` itself in place; `openclaw agents list` reports it gone, so a
446/// row for it would be supercode contradicting the harness about its own
447/// store.
448///
449/// `agents.list` is the key a real install writes — verified against
450/// `~/.openclaw/openclaw.json` on the build box, an array of
451/// `{ id, name, workspace, agentDir, tools }` — with `agents.entries` read as
452/// the object-keyed alternative.
453fn openclaw_rows(home: &Path) -> Vec<ProfileRow> {
454    let config_path = home.join("openclaw.json");
455    let config = read_json5(&config_path);
456    let entries = config.pointer("/agents/list").map(entry_map);
457    let bindings = config.pointer("/bindings").and_then(Value::as_array);
458    let entries = entries.or_else(|| config.pointer("/agents/entries").map(entry_map));
459    // An unreadable config means the routing table is UNKNOWN; a readable one
460    // with no `bindings[]` array declares no routes, which is zero.
461    let route_counts = (!config.is_null()).then(|| {
462        bindings
463            .map(|list| count_binding_targets(list))
464            .unwrap_or_default()
465    });
466
467    let declared_ids: Vec<&str> = entries
468        .iter()
469        .flatten()
470        .map(|(id, _)| id.as_str())
471        .collect();
472    let mut names: Vec<String> = Vec::new();
473    if let Ok(dirs) = std::fs::read_dir(home.join("agents")) {
474        names.extend(
475            dirs.flatten()
476                .filter(|entry| entry.path().is_dir())
477                .filter(|entry| {
478                    // An undeclared, EMPTY directory is the shell
479                    // `openclaw agents delete` leaves behind, not an agent.
480                    declared_ids.contains(&entry.file_name().to_string_lossy().as_ref())
481                        || !directory_is_empty(&entry.path())
482                })
483                .filter_map(|entry| entry.file_name().into_string().ok()),
484        );
485    }
486    if let Some(declared) = &entries {
487        names.extend(declared.iter().map(|(id, _)| id.clone()));
488    }
489    names.sort();
490    names.dedup();
491
492    // `default` is whichever entry declares it. Real configs declare none, so
493    // the fallbacks are OpenClaw's own conventions: the agent literally named
494    // `main` (the id behind the `agent:<id>:main` session key and the
495    // `agents/main` home), then the first declared entry.
496    let declared_default = entries.as_ref().and_then(|entries| {
497        entries
498            .iter()
499            .find(|(_, entry)| entry.get("default").and_then(Value::as_bool) == Some(true))
500            .map(|(id, _)| id.clone())
501            .or_else(|| {
502                entries
503                    .iter()
504                    .find(|(id, _)| id == OPENCLAW_DEFAULT_AGENT)
505                    .map(|(id, _)| id.clone())
506            })
507            .or_else(|| entries.first().map(|(id, _)| id.clone()))
508    });
509
510    names
511        .into_iter()
512        .map(|name| {
513            let agent_home = home.join("agents").join(&name);
514            // The same filter OpenClaw discovery applies, so the count a row
515            // reports is the count `sessions list` would show for the agent.
516            let sessions = std::fs::read_dir(agent_home.join("sessions"))
517                .ok()
518                .map(|dir| {
519                    dir.flatten()
520                        .filter(|entry| {
521                            let name = entry.file_name();
522                            let name = name.to_string_lossy();
523                            name.ends_with(".jsonl") && !name.ends_with(".trajectory.jsonl")
524                        })
525                        .count() as u64
526                });
527            let entry = entries.as_ref().and_then(|entries| {
528                entries
529                    .iter()
530                    .find(|(id, _)| *id == name)
531                    .map(|(_, entry)| entry)
532            });
533            ProfileRow {
534                name: name.clone(),
535                harness: HarnessId::OPENCLAW.to_string(),
536                kind: ProfileKind::OpenclawAgent,
537                home: Some(agent_home),
538                default: declared_default.as_deref() == Some(name.as_str()),
539                routes: route_counts
540                    .as_ref()
541                    .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
542                sessions,
543                // An entry pins a model either as a plain id or as the
544                // `{ "primary": … }` object `agents.defaults.model` uses. A
545                // row reports only what the ENTRY pins: the install-wide
546                // `agents.defaults` is not this agent's pin.
547                model: entry.and_then(|entry| match entry.get("model") {
548                    Some(Value::String(id)) => Some(id.clone()),
549                    Some(object) => object
550                        .get("primary")
551                        .and_then(Value::as_str)
552                        .map(str::to_string),
553                    None => None,
554                }),
555                worker: None,
556            }
557        })
558        .collect()
559}
560
561/// Read a JSON5 config file, or [`Value::Null`] when it does not exist or
562/// does not parse — the "unknown" answer the route counts distinguish from
563/// an explicit empty table.
564pub(crate) fn read_json5(path: &Path) -> Value {
565    std::fs::read_to_string(path)
566        .ok()
567        .and_then(|text| serde_json::from_str::<Value>(&strip_json5(&text)).ok())
568        .unwrap_or(Value::Null)
569}
570
571/// Whether a directory holds nothing at all. An unreadable directory is not
572/// claimed to be empty.
573fn directory_is_empty(path: &Path) -> bool {
574    std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_none())
575}
576
577/// Routing entries per target agent: one `bindings[]` element counts once
578/// for the `agentId` it names.
579fn count_binding_targets(list: &[Value]) -> BTreeMap<String, u64> {
580    let mut counts: BTreeMap<String, u64> = BTreeMap::new();
581    for binding in list {
582        if let Some(agent) = binding.get("agentId").and_then(Value::as_str) {
583            *counts.entry(agent.to_string()).or_default() += 1;
584        }
585    }
586    counts
587}
588
589/// The declared agents as `(id, entry)` pairs IN DECLARATION ORDER, which is
590/// what "the first entry" means when nothing declares itself the default.
591///
592/// `agents.list` is an array of `{ "id": … }` objects (the real shape).
593/// `agents.entries` — an object keyed by agent id — is read too; its key
594/// order is not preserved by the JSON parser, so an object-keyed config falls
595/// back on the `main` convention rather than on declaration order.
596fn entry_map(value: &Value) -> Vec<(String, Value)> {
597    match value {
598        Value::Array(list) => list
599            .iter()
600            .filter_map(|entry| {
601                entry
602                    .get("id")
603                    .or_else(|| entry.get("agentId"))
604                    .and_then(Value::as_str)
605                    .map(|id| (id.to_string(), entry.clone()))
606            })
607            .collect(),
608        Value::Object(map) => map
609            .iter()
610            .map(|(name, entry)| (name.clone(), entry.clone()))
611            .collect(),
612        _ => Vec::new(),
613    }
614}
615
616/// Reduce JSON5 to JSON: `openclaw.json` is read by a JSON5 parser, so a
617/// hand-edited config may carry `//` and `/* */` comments and trailing
618/// commas. String literals are scanned so a `//` inside a value survives.
619fn strip_json5(text: &str) -> String {
620    let mut out = String::with_capacity(text.len());
621    let mut chars = text.chars().peekable();
622    let mut in_string = false;
623    let mut escaped = false;
624    while let Some(ch) = chars.next() {
625        if in_string {
626            out.push(ch);
627            if escaped {
628                escaped = false;
629            } else if ch == '\\' {
630                escaped = true;
631            } else if ch == '"' {
632                in_string = false;
633            }
634            continue;
635        }
636        match ch {
637            '"' => {
638                in_string = true;
639                out.push(ch);
640            }
641            '/' if chars.peek() == Some(&'/') => {
642                for next in chars.by_ref() {
643                    if next == '\n' {
644                        out.push('\n');
645                        break;
646                    }
647                }
648            }
649            '/' if chars.peek() == Some(&'*') => {
650                chars.next();
651                let mut previous = '\0';
652                for next in chars.by_ref() {
653                    if previous == '*' && next == '/' {
654                        break;
655                    }
656                    previous = next;
657                }
658                out.push(' ');
659            }
660            _ => out.push(ch),
661        }
662    }
663    // Trailing commas: `,` followed only by whitespace before `}` or `]`.
664    let bytes: Vec<char> = out.chars().collect();
665    let mut cleaned = String::with_capacity(out.len());
666    let mut index = 0usize;
667    let mut in_string = false;
668    let mut escaped = false;
669    while index < bytes.len() {
670        let ch = bytes[index];
671        if in_string {
672            cleaned.push(ch);
673            if escaped {
674                escaped = false;
675            } else if ch == '\\' {
676                escaped = true;
677            } else if ch == '"' {
678                in_string = false;
679            }
680            index += 1;
681            continue;
682        }
683        if ch == '"' {
684            in_string = true;
685            cleaned.push(ch);
686            index += 1;
687            continue;
688        }
689        if ch == ',' {
690            let mut lookahead = index + 1;
691            while lookahead < bytes.len() && bytes[lookahead].is_whitespace() {
692                lookahead += 1;
693            }
694            if lookahead < bytes.len() && (bytes[lookahead] == '}' || bytes[lookahead] == ']') {
695                index += 1;
696                continue;
697            }
698        }
699        cleaned.push(ch);
700        index += 1;
701    }
702    cleaned
703}
704
705// ---------------------------------------------------------------------------
706// Minimal YAML reads
707// ---------------------------------------------------------------------------
708//
709// Hermes's `config.yaml` is read here for exactly two things: a top-level
710// `model:` pin and the `gateway.profile_routes` table. That is a nested block
711// of plain scalars, so an indentation scanner reads it without taking a YAML
712// dependency for two keys. Anchors, flow collections, and multi-line scalars
713// are NOT supported: a config using them reports `routes: null` (unknown)
714// rather than a wrong count.
715
716/// The block nested under `key`, with its own indentation preserved.
717pub(crate) fn yaml_child(text: &str, key: &str) -> String {
718    let mut out = String::new();
719    let mut parent_indent: Option<usize> = None;
720    for line in text.lines() {
721        let trimmed = line.trim_start();
722        if trimmed.is_empty() || trimmed.starts_with('#') {
723            continue;
724        }
725        let indent = line.len() - trimmed.len();
726        match parent_indent {
727            None => {
728                if yaml_key(trimmed).is_some_and(|found| found == key) {
729                    parent_indent = Some(indent);
730                }
731            }
732            Some(parent) => {
733                if indent <= parent {
734                    break;
735                }
736                out.push_str(line);
737                out.push('\n');
738            }
739        }
740    }
741    out
742}
743
744/// A top-level (outermost-indent) scalar value for `key`.
745pub(crate) fn yaml_scalar(text: &str, key: &str) -> Option<String> {
746    let root = text
747        .lines()
748        .filter(|line| !line.trim_start().is_empty() && !line.trim_start().starts_with('#'))
749        .map(|line| line.len() - line.trim_start().len())
750        .min()?;
751    for line in text.lines() {
752        let trimmed = line.trim_start();
753        if trimmed.is_empty() || trimmed.starts_with('#') {
754            continue;
755        }
756        if line.len() - trimmed.len() != root {
757            continue;
758        }
759        if yaml_key(trimmed) != Some(key) {
760            continue;
761        }
762        let value = yaml_value(trimmed)?;
763        if !value.is_empty() {
764            return Some(value);
765        }
766    }
767    None
768}
769
770/// Count routing entries per target profile inside a `profile_routes` block.
771///
772/// Two shapes are counted, both ending at a profile name:
773/// * an entry with an explicit `profile: <name>` field (the documented shape:
774///   a match spec — `platform`, `guild_id`, `chat_id`, `thread_id` — plus its
775///   target), whether written as a list item or a nested mapping;
776/// * a flat `<match>: <profile>` mapping at the block's own level.
777fn count_yaml_route_targets(block: &str) -> BTreeMap<String, u64> {
778    let mut counts: BTreeMap<String, u64> = BTreeMap::new();
779    let entry_indent = block
780        .lines()
781        .filter(|line| !line.trim_start().is_empty() && !line.trim_start().starts_with('#'))
782        .map(|line| line.len() - line.trim_start().len())
783        .min();
784    for line in block.lines() {
785        let trimmed = line.trim_start();
786        if trimmed.is_empty() || trimmed.starts_with('#') {
787            continue;
788        }
789        let indent = line.len() - trimmed.len();
790        let body = trimmed.strip_prefix("- ").unwrap_or(trimmed);
791        let (Some(key), Some(value)) = (yaml_key(body), yaml_value(body)) else {
792            continue;
793        };
794        if key == "profile" && !value.is_empty() {
795            *counts.entry(value).or_default() += 1;
796        } else if Some(indent) == entry_indent && !trimmed.starts_with("- ") && !value.is_empty() {
797            *counts.entry(value).or_default() += 1;
798        }
799    }
800    counts
801}
802
803pub(crate) fn yaml_key(line: &str) -> Option<&str> {
804    let (head, _) = line.split_once(':')?;
805    let head = head.trim();
806    (!head.is_empty() && !head.contains(char::is_whitespace)).then_some(head)
807}
808
809fn yaml_value(line: &str) -> Option<String> {
810    let (_, tail) = line.split_once(':')?;
811    let tail = tail.trim();
812    let tail = tail.split_once(" #").map(|(head, _)| head).unwrap_or(tail);
813    Some(
814        tail.trim()
815            .trim_matches(|ch| ch == '"' || ch == '\'')
816            .to_string(),
817    )
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823
824    #[test]
825    fn presets_are_supercodes_profiles_with_the_default_flagged() {
826        let rows = preset_rows();
827        assert_eq!(rows.len(), crate::presets::RESERVED_PRESET_NAMES.len());
828        let default: Vec<&str> = rows
829            .iter()
830            .filter(|row| row.default)
831            .map(|row| row.name.as_str())
832            .collect();
833        assert_eq!(default, ["supercode-default"]);
834        let cc = rows.iter().find(|row| row.name == "cc-parity").unwrap();
835        assert_eq!(cc.kind, ProfileKind::Preset);
836        assert_eq!(cc.model.as_deref(), Some("anthropic/claude-opus-4-8"));
837        assert!(cc.home.is_none());
838    }
839
840    /// The shell `openclaw agents delete` leaves behind is not an agent —
841    /// `openclaw agents list` does not report it, so neither does this — but a
842    /// directory that still holds state is one even with no config entry.
843    #[test]
844    fn an_emptied_agent_directory_is_not_an_agent() {
845        let root = std::env::temp_dir().join(format!(
846            "supercode-profiles-shell-{}-{}",
847            std::process::id(),
848            std::time::SystemTime::now()
849                .duration_since(std::time::UNIX_EPOCH)
850                .unwrap()
851                .as_nanos()
852        ));
853        std::fs::create_dir_all(root.join("agents/deleted")).unwrap();
854        std::fs::create_dir_all(root.join("agents/undeclared/sessions")).unwrap();
855        std::fs::create_dir_all(root.join("agents/main")).unwrap();
856        std::fs::write(
857            root.join("openclaw.json"),
858            r#"{"agents": {"list": [{"id": "main"}]}}"#,
859        )
860        .unwrap();
861        let names: Vec<String> = openclaw_rows(&root)
862            .into_iter()
863            .map(|row| row.name)
864            .collect();
865        assert_eq!(names, ["main", "undeclared"], "{names:?}");
866        std::fs::remove_dir_all(&root).ok();
867    }
868
869    /// ORC-7: the root folder IS the `default` profile and `profiles/<name>/`
870    /// are the named ones, each with its own `worker:` block and its own
871    /// bindings store.
872    #[test]
873    fn orchestrator_profiles_are_folders_carrying_their_own_worker() {
874        let root = std::env::temp_dir().join(format!(
875            "supercode-profiles-orchestrator-{}-{}",
876            std::process::id(),
877            std::time::SystemTime::now()
878                .duration_since(std::time::UNIX_EPOCH)
879                .unwrap()
880                .as_nanos()
881        ));
882        std::fs::create_dir_all(root.join("profiles/ops")).unwrap();
883        std::fs::write(
884            root.join("config.yaml"),
885            "worker:\n  harness: claude-code\n  model: claude-opus-4-8\ngateway:\n  profile_routes:\n    - platform: slack\n      profile: ops\n",
886        )
887        .unwrap();
888        std::fs::write(
889            root.join("profiles/ops/config.yaml"),
890            "worker:\n  harness: codex\n",
891        )
892        .unwrap();
893        let rows = orchestrator_rows(&root);
894        let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
895        assert_eq!(names, ["default", "ops"], "{names:?}");
896        assert!(rows[0].default && !rows[1].default);
897        assert_eq!(rows[0].kind, ProfileKind::OrchestratorProfile);
898        assert_eq!(rows[0].worker.as_deref(), Some("claude-code"));
899        assert_eq!(rows[0].model.as_deref(), Some("claude-opus-4-8"));
900        assert_eq!(rows[1].worker.as_deref(), Some("codex"));
901        assert_eq!(rows[1].model, None);
902        // The route in the ROOT config targets `ops`, and it is counted there.
903        assert_eq!(rows[1].routes, Some(1));
904        assert_eq!(rows[0].routes, Some(0));
905        // No store yet is UNKNOWN, never zero.
906        assert_eq!(rows[0].sessions, None);
907        assert_eq!(rows[0].home.as_deref(), Some(root.as_path()));
908        std::fs::remove_dir_all(&root).ok();
909    }
910
911    #[test]
912    fn unsupported_harness_is_refused_not_silently_empty() {
913        let error = list_profiles(&HarnessHomes::default(), Some(HarnessId::CLAUDE_CODE))
914            .expect_err("claude-code has no profile concept");
915        assert_eq!(
916            error,
917            ProfileError::UnsupportedHarness {
918                harness: HarnessId::CLAUDE_CODE.to_string()
919            }
920        );
921    }
922
923    #[test]
924    fn yaml_reader_counts_both_documented_route_shapes() {
925        let listed = "gateway:\n  profile_routes:\n    - platform: slack\n      chat_id: C1\n      profile: coder\n    - platform: discord\n      profile: coder\n";
926        let block = yaml_child(&yaml_child(listed, "gateway"), "profile_routes");
927        assert_eq!(count_yaml_route_targets(&block).get("coder"), Some(&2));
928
929        let flat = "gateway:\n  profile_routes:\n    slack: coder\n    discord: main\n";
930        let block = yaml_child(&yaml_child(flat, "gateway"), "profile_routes");
931        let counts = count_yaml_route_targets(&block);
932        assert_eq!(counts.get("coder"), Some(&1));
933        assert_eq!(counts.get("main"), Some(&1));
934
935        let nested = "gateway:\n  profile_routes:\n    slack:\n      profile: coder\n";
936        let block = yaml_child(&yaml_child(nested, "gateway"), "profile_routes");
937        assert_eq!(count_yaml_route_targets(&block).get("coder"), Some(&1));
938    }
939
940    /// Receipt-driven (hermes-agent 0.21.0 on the build box): the real
941    /// `config.yaml` pins the model in a `model:` BLOCK under `default:`,
942    /// not as a top-level scalar. All three spellings the shipped config
943    /// admits must read.
944    #[test]
945    fn hermes_model_reads_the_block_form_the_real_config_writes() {
946        let real = "model:\n  # Default model to use\n  default: \"anthropic/claude-opus-4.6\"\n\n  # provider: auto\ntools:\n  enabled: true\n";
947        assert_eq!(
948            hermes_model(real).as_deref(),
949            Some("anthropic/claude-opus-4.6")
950        );
951        let alias = "model:\n  model: anthropic/claude-opus-4.6\n";
952        assert_eq!(
953            hermes_model(alias).as_deref(),
954            Some("anthropic/claude-opus-4.6")
955        );
956        let flat = "model: anthropic/claude-opus-4.6\n";
957        assert_eq!(
958            hermes_model(flat).as_deref(),
959            Some("anthropic/claude-opus-4.6")
960        );
961        assert_eq!(hermes_model("gateway:\n  port: 1\n"), None);
962    }
963
964    #[test]
965    fn yaml_child_stops_at_the_next_sibling_key() {
966        let text = "gateway:\n  profile_routes:\n    - profile: coder\nmodel: sonnet\n";
967        assert_eq!(yaml_scalar(text, "model").as_deref(), Some("sonnet"));
968        let block = yaml_child(&yaml_child(text, "gateway"), "profile_routes");
969        assert!(!block.contains("model"), "{block}");
970    }
971
972    #[test]
973    fn json5_comments_and_trailing_commas_are_tolerated() {
974        let text = "{\n  // the default agent\n  \"agents\": { \"entries\": { \"main\": { \"default\": true, } } },\n  /* routes */\n  \"bindings\": [ { \"agentId\": \"main\" }, ],\n  \"note\": \"https://example.test/x\",\n}\n";
975        let value: Value = serde_json::from_str(&strip_json5(text)).unwrap();
976        assert_eq!(value["note"], "https://example.test/x");
977        assert_eq!(value["bindings"].as_array().unwrap().len(), 1);
978        assert_eq!(value["agents"]["entries"]["main"]["default"], true);
979    }
980}