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`).
263/// Hermes's profiles as the orchestration codec reads the home: the root
264/// and every `profiles/<name>` folder, its model from the config residue,
265/// its route count from the routes that name it.
266fn hermes_rows(state_db: &Path) -> Vec<ProfileRow> {
267    let Some(home) = state_db.parent() else {
268        return Vec::new();
269    };
270    let Ok(loaded) = supercode_interchange::orchestration::codec::from_hermes(home) else {
271        return Vec::new();
272    };
273    let routes = loaded
274        .io
275        .get(HERMES_DEFAULT_PROFILE)
276        .is_some_and(|io| io.raw.contains_key("config.yaml"))
277        .then(|| route_counts(&loaded.orchestration));
278    let mut names: Vec<&String> = loaded.orchestration.profiles.keys().collect();
279    names.sort_by_key(|name| (name.as_str() != HERMES_DEFAULT_PROFILE, name.as_str()));
280    names
281        .into_iter()
282        .map(|name| {
283            let profile = &loaded.orchestration.profiles[name];
284            let is_default = name == HERMES_DEFAULT_PROFILE;
285            ProfileRow {
286                name: name.clone(),
287                harness: HarnessId::HERMES.to_string(),
288                kind: ProfileKind::HermesProfile,
289                home: Some(profile.dir.clone()),
290                default: is_default,
291                routes: routes
292                    .as_ref()
293                    .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
294                sessions: hermes_session_count(state_db, (!is_default).then_some(name.as_str())),
295                model: hermes_model(profile),
296                worker: None,
297            }
298        })
299        .collect()
300}
301
302/// Routes by the profile they name, across the whole orchestration.
303fn route_counts(
304    orchestration: &supercode_interchange::orchestration::Orchestration,
305) -> BTreeMap<String, u64> {
306    let mut counts: BTreeMap<String, u64> = BTreeMap::new();
307    for profile in orchestration.profiles.values() {
308        for route in &profile.routes {
309            *counts.entry(route.profile.clone()).or_default() += 1;
310        }
311    }
312    counts
313}
314
315/// Our own folder's profiles as the orchestration codec reads it: the
316/// worker's harness and model, the bindings the profile's store holds.
317fn orchestrator_rows(root: &Path) -> Vec<ProfileRow> {
318    use supercode_interchange::orchestration::codec::{load_home, Flavor};
319    let Ok(loaded) = load_home(root, Flavor::Orchestrator) else {
320        return Vec::new();
321    };
322    let routes = loaded
323        .io
324        .values()
325        .any(|io| io.raw.contains_key("config.yaml"))
326        .then(|| route_counts(&loaded.orchestration));
327    let mut names: Vec<&String> = loaded.orchestration.profiles.keys().collect();
328    names.sort_by_key(|name| (name.as_str() != HERMES_DEFAULT_PROFILE, name.as_str()));
329    names
330        .into_iter()
331        .map(|name| {
332            let profile = &loaded.orchestration.profiles[name];
333            ProfileRow {
334                name: name.clone(),
335                harness: HarnessId::ORCHESTRATOR.to_string(),
336                kind: ProfileKind::OrchestratorProfile,
337                default: name == HERMES_DEFAULT_PROFILE,
338                routes: routes
339                    .as_ref()
340                    .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
341                sessions: profile
342                    .dir
343                    .join("state.db")
344                    .is_file()
345                    .then(|| profile.bindings.len() as u64),
346                model: profile.worker.as_ref().and_then(|w| w.model.clone()),
347                worker: profile
348                    .worker
349                    .as_ref()
350                    .map(|w| w.harness.as_str().to_string()),
351                home: Some(profile.dir.clone()),
352            }
353        })
354        .collect()
355}
356
357/// The model a Hermes config pins: a top-level `model` scalar, else the
358/// `model` block's `default` / `model`.
359fn hermes_model(profile: &supercode_interchange::orchestration::Profile) -> Option<String> {
360    match profile.residue.config.get("model")? {
361        Value::String(pinned) => Some(pinned.clone()),
362        Value::Object(block) => block
363            .get("default")
364            .or_else(|| block.get("model"))
365            .and_then(Value::as_str)
366            .map(str::to_string),
367        _ => None,
368    }
369}
370
371/// Count the sessions one Hermes profile owns. `None` names the implicit
372/// default profile, whose rows carry `profile_name IS NULL`. An unreadable
373/// store answers `None` — unknown, never zero.
374fn hermes_session_count(state_db: &Path, profile: Option<&str>) -> Option<u64> {
375    let connection = Connection::open_with_flags(
376        state_db,
377        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
378    )
379    .ok()?;
380    let count: i64 = match profile {
381        Some(name) => connection
382            .query_row(
383                "SELECT COUNT(*) FROM sessions WHERE profile_name = ?1",
384                [name],
385                |row| row.get(0),
386            )
387            .ok()?,
388        None => connection
389            .query_row(
390                "SELECT COUNT(*) FROM sessions WHERE profile_name IS NULL",
391                [],
392                |row| row.get(0),
393            )
394            .ok()?,
395    };
396    Some(count.max(0) as u64)
397}
398
399// ---------------------------------------------------------------------------
400// OpenClaw
401// ---------------------------------------------------------------------------
402
403/// OpenClaw agents are config homes under `<openclaw home>/agents/<id>`, each
404/// with its own store, declared in `openclaw.json` under `agents.list` and
405/// routed by `bindings[]` (`inventory/orchestration.md` rows 2 and 4). The
406/// union of the directories and the declared entries is the row set: an
407/// entry with no directory yet is still a routable agent, and a directory
408/// with no entry is still a config home holding sessions.
409///
410/// One directory is NOT an agent: the empty shell OpenClaw's own
411/// `agents delete` leaves behind. Measured at the pin (receipt
412/// `orch21-openclaw-profiles-receipt-2026-09-03.json`), that verb prunes the
413/// config entry, `agents/<id>/agent` and `agents/<id>/sessions` but leaves
414/// `agents/<id>` itself in place; `openclaw agents list` reports it gone, so a
415/// row for it would be supercode contradicting the harness about its own
416/// store.
417///
418/// `agents.list` is the key a real install writes — verified against
419/// `~/.openclaw/openclaw.json` on the build box, an array of
420/// `{ id, name, workspace, agentDir, tools }` — with `agents.entries` read as
421/// the object-keyed alternative.
422/// OpenClaw's agents as the orchestration codec reads the state directory:
423/// every declared agent (a profile; the default agent is the root profile),
424/// plus any agent folder on disk that holds something, its model from the
425/// agent entry the codec kept, its route count from the bindings naming it.
426fn openclaw_rows(home: &Path) -> Vec<ProfileRow> {
427    let Ok(loaded) = supercode_interchange::orchestration::codec::from_openclaw(home) else {
428        return Vec::new();
429    };
430    let by_agent: BTreeMap<&str, &str> = loaded
431        .profiles
432        .iter()
433        .map(|(name, io)| (io.agent_id.as_str(), name.as_str()))
434        .collect();
435    let mut names: Vec<String> = by_agent.keys().map(|id| (*id).to_string()).collect();
436    if let Ok(dirs) = std::fs::read_dir(home.join("agents")) {
437        names.extend(
438            dirs.flatten()
439                .filter(|entry| entry.path().is_dir())
440                .filter(|entry| !directory_is_empty(&entry.path()))
441                .filter_map(|entry| entry.file_name().into_string().ok()),
442        );
443    }
444    names.sort();
445    names.dedup();
446    let route_counts = loaded.root.config_present.then(|| {
447        let mut counts: BTreeMap<String, u64> = BTreeMap::new();
448        for route in loaded
449            .orchestration
450            .profiles
451            .values()
452            .flat_map(|profile| profile.routes.iter())
453        {
454            if let Some(agent) = route.residue.0.get("agent_id").and_then(Value::as_str) {
455                *counts.entry(agent.to_string()).or_default() += 1;
456            }
457        }
458        counts
459    });
460    names
461        .into_iter()
462        .map(|name| {
463            let agent_home = home.join("agents").join(&name);
464            let sessions = std::fs::read_dir(agent_home.join("sessions"))
465                .ok()
466                .map(|dir| {
467                    dir.flatten()
468                        .filter(|entry| {
469                            let name = entry.file_name();
470                            let name = name.to_string_lossy();
471                            name.ends_with(".jsonl") && !name.ends_with(".trajectory.jsonl")
472                        })
473                        .count() as u64
474                });
475            let entry = by_agent
476                .get(name.as_str())
477                .and_then(|profile| loaded.orchestration.profiles.get(*profile))
478                .and_then(|profile| profile.residue.config.get("openclaw_agent"));
479            ProfileRow {
480                name: name.clone(),
481                harness: HarnessId::OPENCLAW.to_string(),
482                kind: ProfileKind::OpenclawAgent,
483                home: Some(agent_home),
484                default: loaded.root.default_agent == name,
485                routes: route_counts
486                    .as_ref()
487                    .map(|counts| counts.get(name.as_str()).copied().unwrap_or(0)),
488                sessions,
489                model: entry.and_then(|entry| match entry.get("model") {
490                    Some(Value::String(id)) => Some(id.clone()),
491                    Some(object) => object
492                        .get("primary")
493                        .and_then(Value::as_str)
494                        .map(str::to_string),
495                    None => None,
496                }),
497                worker: None,
498            }
499        })
500        .collect()
501}
502
503pub(crate) fn read_json5(path: &Path) -> Value {
504    std::fs::read_to_string(path)
505        .ok()
506        .and_then(|text| serde_json::from_str::<Value>(&strip_json5(&text)).ok())
507        .unwrap_or(Value::Null)
508}
509
510/// Whether a directory holds nothing at all. An unreadable directory is not
511/// claimed to be empty.
512fn directory_is_empty(path: &Path) -> bool {
513    std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_none())
514}
515
516/// Reduce JSON5 to JSON: `openclaw.json` is read by a JSON5 parser, so a
517/// hand-edited config may carry `//` and `/* */` comments and trailing
518/// commas. String literals are scanned so a `//` inside a value survives.
519fn strip_json5(text: &str) -> String {
520    let mut out = String::with_capacity(text.len());
521    let mut chars = text.chars().peekable();
522    let mut in_string = false;
523    let mut escaped = false;
524    while let Some(ch) = chars.next() {
525        if in_string {
526            out.push(ch);
527            if escaped {
528                escaped = false;
529            } else if ch == '\\' {
530                escaped = true;
531            } else if ch == '"' {
532                in_string = false;
533            }
534            continue;
535        }
536        match ch {
537            '"' => {
538                in_string = true;
539                out.push(ch);
540            }
541            '/' if chars.peek() == Some(&'/') => {
542                for next in chars.by_ref() {
543                    if next == '\n' {
544                        out.push('\n');
545                        break;
546                    }
547                }
548            }
549            '/' if chars.peek() == Some(&'*') => {
550                chars.next();
551                let mut previous = '\0';
552                for next in chars.by_ref() {
553                    if previous == '*' && next == '/' {
554                        break;
555                    }
556                    previous = next;
557                }
558                out.push(' ');
559            }
560            _ => out.push(ch),
561        }
562    }
563    // Trailing commas: `,` followed only by whitespace before `}` or `]`.
564    let bytes: Vec<char> = out.chars().collect();
565    let mut cleaned = String::with_capacity(out.len());
566    let mut index = 0usize;
567    let mut in_string = false;
568    let mut escaped = false;
569    while index < bytes.len() {
570        let ch = bytes[index];
571        if in_string {
572            cleaned.push(ch);
573            if escaped {
574                escaped = false;
575            } else if ch == '\\' {
576                escaped = true;
577            } else if ch == '"' {
578                in_string = false;
579            }
580            index += 1;
581            continue;
582        }
583        if ch == '"' {
584            in_string = true;
585            cleaned.push(ch);
586            index += 1;
587            continue;
588        }
589        if ch == ',' {
590            let mut lookahead = index + 1;
591            while lookahead < bytes.len() && bytes[lookahead].is_whitespace() {
592                lookahead += 1;
593            }
594            if lookahead < bytes.len() && (bytes[lookahead] == '}' || bytes[lookahead] == ']') {
595                index += 1;
596                continue;
597            }
598        }
599        cleaned.push(ch);
600        index += 1;
601    }
602    cleaned
603}
604
605// ---------------------------------------------------------------------------
606// Minimal YAML reads
607// ---------------------------------------------------------------------------
608//
609// Hermes's `config.yaml` is read here for exactly two things: a top-level
610// `model:` pin and the `gateway.profile_routes` table. That is a nested block
611// of plain scalars, so an indentation scanner reads it without taking a YAML
612// dependency for two keys. Anchors, flow collections, and multi-line scalars
613// are NOT supported: a config using them reports `routes: null` (unknown)
614// rather than a wrong count.
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn presets_are_supercodes_profiles_with_the_default_flagged() {
622        let rows = preset_rows();
623        assert_eq!(rows.len(), crate::presets::RESERVED_PRESET_NAMES.len());
624        let default: Vec<&str> = rows
625            .iter()
626            .filter(|row| row.default)
627            .map(|row| row.name.as_str())
628            .collect();
629        assert_eq!(default, ["supercode-default"]);
630        let cc = rows.iter().find(|row| row.name == "cc-parity").unwrap();
631        assert_eq!(cc.kind, ProfileKind::Preset);
632        assert_eq!(cc.model.as_deref(), Some("anthropic/claude-opus-4-8"));
633        assert!(cc.home.is_none());
634    }
635
636    /// The shell `openclaw agents delete` leaves behind is not an agent —
637    /// `openclaw agents list` does not report it, so neither does this — but a
638    /// directory that still holds state is one even with no config entry.
639    #[test]
640    fn an_emptied_agent_directory_is_not_an_agent() {
641        let root = std::env::temp_dir().join(format!(
642            "supercode-profiles-shell-{}-{}",
643            std::process::id(),
644            std::time::SystemTime::now()
645                .duration_since(std::time::UNIX_EPOCH)
646                .unwrap()
647                .as_nanos()
648        ));
649        std::fs::create_dir_all(root.join("agents/deleted")).unwrap();
650        std::fs::create_dir_all(root.join("agents/undeclared/sessions")).unwrap();
651        std::fs::create_dir_all(root.join("agents/main")).unwrap();
652        std::fs::write(
653            root.join("openclaw.json"),
654            r#"{"agents": {"list": [{"id": "main"}]}}"#,
655        )
656        .unwrap();
657        let names: Vec<String> = openclaw_rows(&root)
658            .into_iter()
659            .map(|row| row.name)
660            .collect();
661        assert_eq!(names, ["main", "undeclared"], "{names:?}");
662        std::fs::remove_dir_all(&root).ok();
663    }
664
665    /// ORC-7: the root folder IS the `default` profile and `profiles/<name>/`
666    /// are the named ones, each with its own `worker:` block and its own
667    /// bindings store.
668    #[test]
669    fn orchestrator_profiles_are_folders_carrying_their_own_worker() {
670        let root = std::env::temp_dir().join(format!(
671            "supercode-profiles-orchestrator-{}-{}",
672            std::process::id(),
673            std::time::SystemTime::now()
674                .duration_since(std::time::UNIX_EPOCH)
675                .unwrap()
676                .as_nanos()
677        ));
678        std::fs::create_dir_all(root.join("profiles/ops")).unwrap();
679        std::fs::write(
680            root.join("config.yaml"),
681            "worker:\n  harness: claude-code\n  model: claude-opus-4-8\ngateway:\n  profile_routes:\n    - platform: slack\n      profile: ops\n",
682        )
683        .unwrap();
684        std::fs::write(
685            root.join("profiles/ops/config.yaml"),
686            "worker:\n  harness: codex\n",
687        )
688        .unwrap();
689        let rows = orchestrator_rows(&root);
690        let names: Vec<&str> = rows.iter().map(|row| row.name.as_str()).collect();
691        assert_eq!(names, ["default", "ops"], "{names:?}");
692        assert!(rows[0].default && !rows[1].default);
693        assert_eq!(rows[0].kind, ProfileKind::OrchestratorProfile);
694        assert_eq!(rows[0].worker.as_deref(), Some("claude-code"));
695        assert_eq!(rows[0].model.as_deref(), Some("claude-opus-4-8"));
696        assert_eq!(rows[1].worker.as_deref(), Some("codex"));
697        assert_eq!(rows[1].model, None);
698        // The route in the ROOT config targets `ops`, and it is counted there.
699        assert_eq!(rows[1].routes, Some(1));
700        assert_eq!(rows[0].routes, Some(0));
701        // No store yet is UNKNOWN, never zero.
702        assert_eq!(rows[0].sessions, None);
703        assert_eq!(rows[0].home.as_deref(), Some(root.as_path()));
704        std::fs::remove_dir_all(&root).ok();
705    }
706
707    #[test]
708    fn unsupported_harness_is_refused_not_silently_empty() {
709        let error = list_profiles(&HarnessHomes::default(), Some(HarnessId::CLAUDE_CODE))
710            .expect_err("claude-code has no profile concept");
711        assert_eq!(
712            error,
713            ProfileError::UnsupportedHarness {
714                harness: HarnessId::CLAUDE_CODE.to_string()
715            }
716        );
717    }
718
719    /// Receipt-driven (hermes-agent 0.21.0 on the build box): the real
720    /// `config.yaml` pins the model in a `model:` BLOCK under `default:`,
721    /// not as a top-level scalar. All three spellings the shipped config
722    /// admits must read.
723    #[test]
724    fn json5_comments_and_trailing_commas_are_tolerated() {
725        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";
726        let value: Value = serde_json::from_str(&strip_json5(text)).unwrap();
727        assert_eq!(value["note"], "https://example.test/x");
728        assert_eq!(value["bindings"].as_array().unwrap().len(), 1);
729        assert_eq!(value["agents"]["entries"]["main"]["default"], true);
730    }
731}