From 449654148ffd4a00f1123efc360a59a3b49b9fac Mon Sep 17 00:00:00 2001 From: agentik-os Date: Wed, 26 Aug 2026 01:45:57 +0200 Subject: [PATCH 1/2] feat: install Hermes as the Home stream and sync the full ~/.hermes tree A fresh install.sh now provisions the Hermes CLI non-interactively and omega sync always writes SOUL, AGENTS.md, curated skills, and the /omegaos bundle. Superpowers/gstack stay opt-in so default install stays lean. Co-authored-by: Cursor --- CHANGELOG.md | 9 + crates/omega-cli/src/main.rs | 53 +---- crates/omega-core/src/agents.rs | 32 ++- crates/omega-core/src/hermes_sync.rs | 315 ++++++++++++++++++++++++++ crates/omega-core/src/lib.rs | 1 + docs/ADR-lab-three-backends.md | 3 +- docs/GETTING-STARTED.md | 9 +- docs/PROVIDER-COMPATIBILITY.md | 9 +- docs/third-party-skills.md | 12 +- install.sh | 35 ++- scripts/install-third-party-skills.sh | 6 +- scripts/verify-install.sh | 18 +- 12 files changed, 427 insertions(+), 75 deletions(-) create mode 100644 crates/omega-core/src/hermes_sync.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c48c90..71e6134c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ for [semantic versioning](https://semver.org) once it reaches 1.0. Until then, ## [Unreleased] +- Hermes is a first-class Home stream: `install.sh` runs `omega install hermes` + (non-interactive: `--skip-setup --skip-browser --skip-computer-use`), and + `omega sync` always creates `~/.hermes` with a SOUL.md kernel pointer, + AGENTS.md link, curated skill links, `skills.external_dirs`, and the + `/omegaos` bundle. Home panes export `HERMES_HOME` and prepend Hermes bins + on PATH. Hermes stays Home-only — never a dispatch writer. +- Superpowers + gstack third-party packs are opt-in (`OMEGA_WITH_THIRD_PARTY=1`) + instead of always-on. `OMEGA_SKIP_THIRD_PARTY=1` still skips. + - Restored agent-pane colors when rmux inherits Cursor's `NO_COLOR`. - Separated Pi (standalone) from OpenRouter. AISB doctrine is 15 agents including Trinity, with named rules (`R-RUBRIC` / `R-VERIFY` / `R-CITE`) diff --git a/crates/omega-cli/src/main.rs b/crates/omega-cli/src/main.rs index 94a9f866..9378f4e8 100644 --- a/crates/omega-cli/src/main.rs +++ b/crates/omega-cli/src/main.rs @@ -17056,37 +17056,6 @@ fn link_policy_kernel(dest: &std::path::Path, src: &std::path::Path, label: &str Ok(()) } -fn upsert_marked_file(path: &std::path::Path, begin: &str, end: &str, body: &str) -> Result<()> { - let block = format!("{begin}\n{body}\n{end}"); - let existing = std::fs::read_to_string(path).unwrap_or_default(); - let updated = match (existing.find(begin), existing.find(end)) { - (Some(start), Some(finish)) if finish > start => { - let mut out = String::with_capacity(existing.len() + block.len()); - out.push_str(&existing[..start]); - out.push_str(&block); - out.push_str(&existing[finish + end.len()..]); - out - } - _ => { - let mut out = existing; - if !out.is_empty() && !out.ends_with('\n') { - out.push('\n'); - } - if !out.is_empty() { - out.push('\n'); - } - out.push_str(&block); - out.push('\n'); - out - } - }; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, updated)?; - Ok(()) -} - fn cmd_sync() -> Result<()> { let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp")); let omega_dir = omega_core::config::omega_dir(); @@ -17383,19 +17352,15 @@ fn cmd_sync() -> Result<()> { "OpenCode", )?; - // Hermes loads AGENTS.md from CWD, not ~/.hermes/. Stamp a pointer into - // SOUL.md (identity slot) so Home Hermes still sees OmegaOS doctrine. - let hermes_home = home.join(".hermes"); - if hermes_home.is_dir() { - upsert_marked_file( - &hermes_home.join("SOUL.md"), - "", - "", - "You run under OmegaOS. Follow `~/.omega/AGENTS.md` (Laws L0–L6 + named rules). \ - Durable state is `omega progress` / `omega done`. Use Hermes native tools — \ - do not invent Claude TaskCreate, `/goal`, or Codex `update_plan`.", - )?; - println!("[+] Hermes: OmegaOS kernel pointer in ~/.hermes/SOUL.md"); + // Hermes Home: create ~/.hermes if missing, stamp SOUL.md, link AGENTS.md, + // point skills.external_dirs at ~/.omega/skills, write /omegaos bundle. + match omega_core::hermes_sync::sync_hermes_home(&home, &omega_dir, &agents_full_dst) { + Ok(report) => println!( + "[+] Hermes: SOUL + AGENTS.md + {} core skills + /omegaos bundle → {}", + report.skills_linked, + report.home.display() + ), + Err(error) => println!("[!] Hermes sync skipped: {error}"), } // Pi / Kimi / OpenRouter Home panes pick up project AGENTS.md or the diff --git a/crates/omega-core/src/agents.rs b/crates/omega-core/src/agents.rs index 4720cca0..8d26aad4 100644 --- a/crates/omega-core/src/agents.rs +++ b/crates/omega-core/src/agents.rs @@ -280,7 +280,7 @@ impl Agent { "if command -v npm >/dev/null 2>&1; then mkdir -p \"$HOME/.npm-global\" && npm install -g --prefix \"$HOME/.npm-global\" @earendil-works/pi-coding-agent; elif [ -x \"$HOME/.bun/bin/bun\" ]; then \"$HOME/.bun/bin/bun\" add -g @earendil-works/pi-coding-agent; else echo 'Need Node.js or bun first (run: curl -fsSL https://bun.sh/install | bash)'; exit 1; fi", ), Agent::Hermes => Some( - "T=$(mktemp) || exit $?; curl -fsSL https://hermes-agent.nousresearch.com/install.sh -o \"$T\" && bash \"$T\"; R=$?; rm -f \"$T\"; exit $R", + "T=$(mktemp) || exit $?; curl -fsSL https://hermes-agent.nousresearch.com/install.sh -o \"$T\" && CI=1 bash \"$T\" --skip-setup --skip-browser --skip-computer-use --non-interactive; R=$?; rm -f \"$T\"; exit $R", ), Agent::Kimi => Some( "T=$(mktemp) && curl -fsSL https://code.kimi.com/kimi-code/install.sh -o \"$T\" && bash \"$T\"; R=$?; rm -f \"$T\"; exit $R", @@ -502,7 +502,9 @@ impl Agent { // `omega` in ~/.local/bin or ~/.bun/bin can be "command not found", and a // dispatched oracle drops to a bare shell instead of running its mission. // Prepend the user bin dirs so every launched agent + tool always resolves. - let path_prefix = format!("{home}/.local/bin:{home}/.bun/bin:{home}/.npm-global/bin"); + let path_prefix = format!( + "{home}/.local/bin:{home}/.hermes/bin:{home}/.hermes/hermes-agent/venv/bin:{home}/.bun/bin:{home}/.npm-global/bin" + ); // Cursor (and other agent hosts) start the rmux daemon with // NO_COLOR=1 FORCE_COLOR=0. Every pane inherits that, and Claude / // Codex / Hermes then emit dim/bold only — no 38;2. Measured @@ -858,13 +860,23 @@ impl Agent { } else { "" }; + let hermes_home = format!( + "export HERMES_HOME={}; ", + shell_quote(&format!("{home}/.hermes")) + ); // Hermes chat has no positional prompt (unrecognized arguments → // exit). `-q` is a one-shot that also exits. Home/TUI panes stay // on interactive `chat`; callers inject the first message after // the TUI is up. pane_bash(&format!( - "{}{}exec hermes chat{}{}{}{}", - env_prefix, yolo_env, provider_arg, hermes_args, yolo_arg, resume_arg + "{}{}{}exec hermes chat{}{}{}{}", + env_prefix, + hermes_home, + yolo_env, + provider_arg, + hermes_args, + yolo_arg, + resume_arg )) } Agent::Glm => { @@ -1296,6 +1308,8 @@ mod tests { hermes.contains("HERMES_YOLO_MODE=1 exec hermes chat"), "{hermes}" ); + assert!(hermes.contains(".hermes/bin"), "{hermes}"); + assert!(hermes.contains("HERMES_HOME="), "{hermes}"); let hermes_prompt = launch( Agent::Hermes, Some("inspect the repository"), @@ -1491,10 +1505,20 @@ mod tests { assert!(cmd.contains("openrouter"), "{cmd}"); assert!(cmd.contains("--yolo"), "{cmd}"); assert!(cmd.contains("HERMES_YOLO_MODE=1"), "{cmd}"); + assert!(cmd.contains("HERMES_HOME="), "{cmd}"); assert!(!cmd.contains(" -q "), "{cmd}"); assert!(!cmd.contains("; exec bash"), "{cmd}"); } + #[test] + fn hermes_install_is_non_interactive() { + let cmd = Agent::Hermes.install_command().expect("hermes installer"); + assert!(cmd.contains("--skip-setup"), "{cmd}"); + assert!(cmd.contains("--skip-browser"), "{cmd}"); + assert!(cmd.contains("--skip-computer-use"), "{cmd}"); + assert!(cmd.contains("--non-interactive"), "{cmd}"); + } + #[test] fn gemini_prompt_stays_in_an_interactive_session() { let cmd = launch( diff --git a/crates/omega-core/src/hermes_sync.rs b/crates/omega-core/src/hermes_sync.rs new file mode 100644 index 00000000..7e7f2061 --- /dev/null +++ b/crates/omega-core/src/hermes_sync.rs @@ -0,0 +1,315 @@ +//! Hermes Home sync — doctrine, skills, and stream-ready config. +//! +//! Hermes is not a dispatched writer. Home panes (`omega new --agent hermes`) +//! still need the same Laws plus native skills. This module is the single +//! writer for `~/.hermes/` so `omega sync` and `install.sh` cannot drift. + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; + +/// Marker pair around the OmegaOS paragraph in `SOUL.md`. Never wrap the +/// operator's identity — only this block is ours. +pub const SOUL_BEGIN: &str = ""; +pub const SOUL_END: &str = ""; + +/// Marker pair around the `skills.external_dirs` snippet in `config.yaml`. +pub const CONFIG_BEGIN: &str = "# OMEGAOS-SKILLS:START"; +pub const CONFIG_END: &str = "# OMEGAOS-SKILLS:END"; + +/// Curated skills always linked under `~/.hermes/skills/omegaos/`. +/// Hermes indexes these locally even if `external_dirs` is ignored. +pub const CORE_SKILLS: &[&str] = &[ + "agentic-engineering-lab", + "planner", + "new-project", + "acceptance", + "monitor", + "cleanup", + "brand-identity", + "vision", + "prd", + "product-development-system", +]; + +const SOUL_BODY: &str = + "You run under OmegaOS. Follow `~/.omega/AGENTS.md` (Laws L0–L6 + named rules). \ +Durable state is `omega progress` / `omega done`. Use Hermes native tools — \ +do not invent Claude TaskCreate, `/goal`, or Codex `update_plan`."; + +const BUNDLE_YAML: &str = "name: omegaos\n\ +description: OmegaOS Home loop — plan, build, verify, report.\n\ +skills:\n\ + - agentic-engineering-lab\n\ + - planner\n\ + - acceptance\n\ + - monitor\n\ +instruction: |\n\ + You run under OmegaOS. Durable state is omega progress / omega done.\n\ + Use THIS CLI's native plan/todo tool. Never invent Claude TaskCreate,\n\ + /goal, or Codex update_plan.\n"; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct HermesSyncReport { + pub home: PathBuf, + pub soul: bool, + pub agents_md: bool, + pub config: bool, + pub bundle: bool, + pub skills_linked: usize, +} + +pub fn hermes_home(user_home: &Path) -> PathBuf { + user_home.join(".hermes") +} + +/// Full Hermes Home sync. Creates `~/.hermes` when missing so install.sh +/// can wire doctrine before the first `hermes chat`. +pub fn sync_hermes_home( + user_home: &Path, + omega_dir: &Path, + agents_md: &Path, +) -> Result { + let home = hermes_home(user_home); + std::fs::create_dir_all(home.join("skills").join("omegaos")) + .with_context(|| format!("creating {}", home.display()))?; + std::fs::create_dir_all(home.join("skill-bundles"))?; + std::fs::create_dir_all(home.join("memories"))?; + + upsert_marked_file(&home.join("SOUL.md"), SOUL_BEGIN, SOUL_END, SOUL_BODY)?; + + link_if_needed(&home.join("AGENTS.md"), agents_md)?; + + let omega_skills = omega_dir.join("skills"); + let external = [ + omega_skills.to_string_lossy().into_owned(), + omega_skills.join("audits").to_string_lossy().into_owned(), + ]; + let config_path = home.join("config.yaml"); + let existing = std::fs::read_to_string(&config_path).unwrap_or_default(); + let updated = ensure_external_skill_dirs(&existing, &external); + if updated != existing { + std::fs::write(&config_path, updated)?; + } + + std::fs::write(home.join("skill-bundles").join("omegaos.yaml"), BUNDLE_YAML)?; + + let linked = link_core_skills(&home, &omega_skills)?; + + Ok(HermesSyncReport { + home, + soul: true, + agents_md: true, + config: true, + bundle: true, + skills_linked: linked, + }) +} + +fn external_dirs_inner(dirs: &[String]) -> String { + format!( + " external_dirs:\n{}", + dirs.iter() + .map(|d| format!(" - {d}")) + .collect::>() + .join("\n") + ) +} + +fn is_top_level_skills_key(line: &str) -> bool { + let bare = line.trim_end(); + (bare == "skills:" || bare.starts_with("skills:")) + && !bare.starts_with(' ') + && !bare.starts_with('\t') +} + +fn has_top_level_skills(yaml: &str) -> bool { + yaml.lines().any(is_top_level_skills_key) +} + +fn after_skills_line(yaml: &str) -> Option { + let mut offset = 0usize; + for line in yaml.split_inclusive('\n') { + if is_top_level_skills_key(line.trim_end_matches(['\n', '\r'])) { + return Some(offset + line.len()); + } + offset += line.len(); + } + None +} + +pub fn ensure_external_skill_dirs(existing: &str, dirs: &[String]) -> String { + let inner = external_dirs_inner(dirs); + let nested = format!("{CONFIG_BEGIN}\n{inner}\n{CONFIG_END}"); + let standalone = format!("{CONFIG_BEGIN}\nskills:\n{inner}\n{CONFIG_END}"); + match (existing.find(CONFIG_BEGIN), existing.find(CONFIG_END)) { + (Some(start), Some(finish)) if finish > start => { + let before = &existing[..start]; + let after = &existing[finish + CONFIG_END.len()..]; + let use_nested = has_top_level_skills(before) || has_top_level_skills(after); + let block = if use_nested { nested } else { standalone }; + format!("{before}{block}{after}") + } + _ => { + if dirs.iter().all(|d| existing.contains(d)) && existing.contains("external_dirs") { + return existing.to_string(); + } + if let Some(idx) = after_skills_line(existing) { + let mut out = String::with_capacity(existing.len() + nested.len() + 2); + out.push_str(&existing[..idx]); + if !out.ends_with('\n') { + out.push('\n'); + } + out.push_str(&nested); + out.push('\n'); + out.push_str(&existing[idx..]); + return out; + } + let mut out = existing.to_string(); + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&standalone); + out.push('\n'); + out + } + } +} + +pub fn upsert_marked_file(path: &Path, begin: &str, end: &str, body: &str) -> Result<()> { + let block = format!("{begin}\n{body}\n{end}"); + let existing = std::fs::read_to_string(path).unwrap_or_default(); + let updated = match (existing.find(begin), existing.find(end)) { + (Some(start), Some(finish)) if finish > start => { + let mut out = String::with_capacity(existing.len() + block.len()); + out.push_str(&existing[..start]); + out.push_str(&block); + out.push_str(&existing[finish + end.len()..]); + out + } + _ => { + let mut out = existing; + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&block); + out.push('\n'); + out + } + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, updated)?; + Ok(()) +} + +fn link_if_needed(dest: &Path, src: &Path) -> Result<()> { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let stale = std::fs::read_link(dest) + .map(|target| target != src) + .unwrap_or(false); + if stale { + let _ = std::fs::remove_file(dest); + } + if !dest.exists() { + #[cfg(unix)] + std::os::unix::fs::symlink(src, dest)?; + #[cfg(not(unix))] + std::fs::copy(src, dest)?; + } + Ok(()) +} + +fn link_core_skills(hermes_home: &Path, omega_skills: &Path) -> Result { + let dest_root = hermes_home.join("skills").join("omegaos"); + std::fs::create_dir_all(&dest_root)?; + let mut linked = 0usize; + for name in CORE_SKILLS { + let src = omega_skills.join(name); + if !src.join("SKILL.md").is_file() { + continue; + } + let dest = dest_root.join(name); + let stale = std::fs::read_link(&dest) + .map(|target| target != src) + .unwrap_or(false); + if stale { + let _ = std::fs::remove_file(&dest); + } + if dest.exists() { + linked += 1; + continue; + } + #[cfg(unix)] + std::os::unix::fs::symlink(&src, &dest)?; + #[cfg(not(unix))] + { + std::fs::create_dir_all(&dest)?; + std::fs::copy(src.join("SKILL.md"), dest.join("SKILL.md"))?; + } + linked += 1; + } + Ok(linked) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_merge_is_idempotent_and_keeps_operator_yaml() { + let first = ensure_external_skill_dirs("", &["/tmp/skills".into()]); + assert!(first.contains(CONFIG_BEGIN)); + assert!(first.contains("/tmp/skills")); + let again = ensure_external_skill_dirs(&first, &["/tmp/skills".into()]); + assert_eq!(first, again); + + let operator = "model: nous/hermes\n"; + let merged = ensure_external_skill_dirs(operator, &["/tmp/skills".into()]); + assert!(merged.starts_with("model: nous/hermes")); + assert!(merged.contains("external_dirs")); + + let existing_skills = "model: nous/hermes\nskills:\n creation:\n enabled: true\n"; + let nested = ensure_external_skill_dirs(existing_skills, &["/tmp/skills".into()]); + assert!(nested.contains("creation:\n enabled: true"), "{nested}"); + assert_eq!(nested.matches("skills:").count(), 1, "{nested}"); + let nested_again = ensure_external_skill_dirs(&nested, &["/tmp/skills".into()]); + assert_eq!(nested, nested_again); + } + + #[test] + fn sync_writes_soul_bundle_and_agents_link() { + let tmp = tempfile::TempDir::new().unwrap(); + let user = tmp.path(); + let omega = user.join(".omega"); + let agents = omega.join("AGENTS.md"); + std::fs::create_dir_all(omega.join("skills").join("planner")).unwrap(); + std::fs::write( + omega.join("skills").join("planner").join("SKILL.md"), + "# p\n", + ) + .unwrap(); + std::fs::write(&agents, "# kernel\n").unwrap(); + + let report = sync_hermes_home(user, &omega, &agents).unwrap(); + assert!(report.soul && report.bundle); + assert!(report.skills_linked >= 1); + let soul = std::fs::read_to_string(report.home.join("SOUL.md")).unwrap(); + assert!(soul.contains("omega progress")); + assert!(!soul.contains("claude --resume")); + let bundle = + std::fs::read_to_string(report.home.join("skill-bundles").join("omegaos.yaml")) + .unwrap(); + assert!(bundle.contains("agentic-engineering-lab")); + let linked = std::fs::read_link(report.home.join("AGENTS.md")).unwrap(); + assert_eq!(linked, agents); + } +} diff --git a/crates/omega-core/src/lib.rs b/crates/omega-core/src/lib.rs index 83f19300..6fdf664f 100644 --- a/crates/omega-core/src/lib.rs +++ b/crates/omega-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod graph; pub mod graph_executor; pub mod graph_risk; pub mod guardian; +pub mod hermes_sync; pub mod inbox; pub mod intent; pub mod lab; diff --git a/docs/ADR-lab-three-backends.md b/docs/ADR-lab-three-backends.md index 8c1e7c08..00601b9a 100644 --- a/docs/ADR-lab-three-backends.md +++ b/docs/ADR-lab-three-backends.md @@ -80,7 +80,8 @@ codex --sandbox workspace-write --ask-for-approval never Invalid: `--approve-for-me` together with `--sandbox` (CLI or `~/.codex/config.toml` `sandbox_mode`). -Hermes Home: `hermes chat --yolo` (and `HERMES_YOLO_MODE=1`). Never `-q` +Hermes Home: `hermes chat --yolo` (and `HERMES_YOLO_MODE=1` + `HERMES_HOME`). +`install.sh` installs the CLI; `omega sync` wires `~/.hermes`. Never `-q` for a pane launch. ## Follow-up diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 84dbcd42..11ba6e5c 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -28,6 +28,12 @@ then `agy` once to authenticate). Google ended Gemini CLI service for free, AI Pro, and Ultra individual accounts in June 2026; `gemini` remains supported for Gemini Code Assist Standard/Enterprise and paid API-key users. +The installer also provisions **Hermes as the Home stream** (`omega new --agent +hermes`). `omega sync` wires `~/.hermes` (SOUL.md pointer, AGENTS.md, curated +skills, `/omegaos` bundle). Hermes is not a dispatch writer — oracles and +workers stay on Codex/Claude. After install, run `hermes setup` or +`omega config set hermes.api_key …` if the CLI has no credentials yet. + ## Step 2 — Telegram remote control (recommended) Drive everything from your phone: dispatch missions, get reports, briefings @@ -148,7 +154,8 @@ local chat), and `omega attach -t ` (jump into any live agent). ## Optional extras - **More CLI agents**: - `omega install claude|antigravity|gemini|openrouter|pi|hermes|glm|kimi` + `omega install claude|antigravity|gemini|openrouter|pi|glm|kimi` + (Hermes Home is already installed by `./install.sh`.) (or Settings → Install agents in the TUI). All install user-space, no root. - **Global keybindings**: `omega install-bindings` (Ctrl+Space popup). diff --git a/docs/PROVIDER-COMPATIBILITY.md b/docs/PROVIDER-COMPATIBILITY.md index 09704841..df365c6b 100644 --- a/docs/PROVIDER-COMPATIBILITY.md +++ b/docs/PROVIDER-COMPATIBILITY.md @@ -16,7 +16,7 @@ omega install --force | Gemini CLI | 0.31.0 | `--prompt-interactive --yolo`, Enterprise/API-key accounts | | Antigravity (`agy`) | 1.1.8 | native Google auth, prompt-interactive | | Pi / OpenRouter | 0.84.3 | explicit provider/model, `--approve` (no tool-yolo on official CLI) | -| Hermes | 0.20.0 | Home TUI: `hermes chat --yolo`. Never `-q` in a pane. Never `dispatch --agent hermes`. | +| Hermes | 0.20.0 | Home TUI: `hermes chat --yolo` with `HERMES_HOME`. `install.sh` installs the CLI. `omega sync` wires `~/.hermes` (SOUL, AGENTS.md, skills, `/omegaos` bundle). Never `-q` in a pane. Never `dispatch --agent hermes`. | | Kimi Code | 0.38.0 | `--auto` on interactive and `--prompt` launches | | GLM | Claude Code 2.1.219 | Claude adapter pointed at Z.AI Anthropic endpoint | @@ -57,9 +57,10 @@ omega new home --agent hermes omega dispatch MyProject "mission" --agent codex ``` -Hermes is Home. Cloud (Cursor Cloud Agent) is the Cursor-side writer for -OmegaOS itself — it is not `omega dispatch`. See -`docs/ADR-lab-three-backends.md`. +Hermes is Home. `./install.sh` installs the Hermes CLI when missing, then +`omega sync` creates `~/.hermes` even before the first `hermes chat`. Cloud +(Cursor Cloud Agent) is the Cursor-side writer for OmegaOS itself — it is +not `omega dispatch`. See `docs/ADR-lab-three-backends.md`. The active global selection is mirrored in `~/.omega/state/active-model.json`. A mission-level `--agent` override takes diff --git a/docs/third-party-skills.md b/docs/third-party-skills.md index e8f6f4cc..59c4f047 100644 --- a/docs/third-party-skills.md +++ b/docs/third-party-skills.md @@ -1,10 +1,11 @@ # Third-party skill collections (superpowers + gstack) OmegaOS vendors two MIT-licensed third-party skill collections at pinned commit -SHAs. They install ALWAYS-ON during `./install.sh` (Phase 6.91) and are fully -reproducible: a fresh `git clone OmegaOS && ./install.sh` provisions the exact -same skills every time. The installer is additive by construction and never -regresses an existing skill or hook. Opt out with `OMEGA_SKIP_THIRD_PARTY=1`. +SHAs. They are **opt-in** during `./install.sh` (Phase 6.91): a default fresh +install does not clone them. Add them with `OMEGA_WITH_THIRD_PARTY=1 ./install.sh` +(or run `bash scripts/install-third-party-skills.sh` later). The installer is +additive by construction and never regresses an existing skill or hook. +`OMEGA_SKIP_THIRD_PARTY=1` still skips the step if both flags are set. Installer script: `scripts/install-third-party-skills.sh`. @@ -106,7 +107,8 @@ verify-install gate enforces that. ## 5. Opt-out and removal -Opt out of the whole step: set `OMEGA_SKIP_THIRD_PARTY=1` before `./install.sh`. +Default `./install.sh` skips this step. Opt in with `OMEGA_WITH_THIRD_PARTY=1`. +`OMEGA_SKIP_THIRD_PARTY=1` still skips even when the opt-in flag is set. To remove after install: diff --git a/install.sh b/install.sh index cd030c47..99c9bd43 100755 --- a/install.sh +++ b/install.sh @@ -418,13 +418,18 @@ fi ok "git found" # Check for an AI agent CLI (optional, warn if missing) +if command -v hermes &>/dev/null \ + || [[ -x "${HOME}/.local/bin/hermes" ]] \ + || [[ -x "${HOME}/.hermes/bin/hermes" ]]; then + ok "Hermes CLI found (Home stream)" +fi if command -v claude &>/dev/null; then ok "Claude Code CLI found" elif command -v codex &>/dev/null; then ok "Codex CLI found" else - info "No AI agent CLI found (claude/codex). You can add one later." - info "Set agent_command in ~/.omega/config.toml" + info "No writer CLI found yet (codex/claude). Hermes Home is installed in Phase 6.5." + info "Writers: set agent_command in ~/.omega/config.toml or run omega install codex" fi # ─── Phase 2.5: Prebuilt binaries (fast path) ──────────────────────────────── @@ -3157,7 +3162,7 @@ ok "Agent prompts installed" # visible (same constraint as rules export above — no silent empty sync). info "Syncing to LLM config directories..." if "$INSTALL_DIR/omega" sync 2>/dev/null; then - ok "LLM configs synced (Claude, Gemini, Codex)" + ok "LLM configs synced (Claude, Gemini, Codex, OpenCode, Hermes)" else warn "omega sync failed — LLM configs NOT synced (re-run later: omega sync)" fi @@ -3497,6 +3502,15 @@ if ! command -v claude >/dev/null 2>&1; then info "Claude Code CLI absent — installing the supported Claude/Telegram runtime..." omega_timeout 180 "$INSTALL_DIR/omega" install claude 2>/dev/null || info "Run 'omega install claude' (or install Claude Code manually), then authenticate with 'claude'." fi +if ! command -v hermes >/dev/null 2>&1 \ + && [[ ! -x "${HOME}/.local/bin/hermes" ]] \ + && [[ ! -x "${HOME}/.hermes/bin/hermes" ]]; then + info "Hermes CLI absent — installing the Home stream runtime..." + omega_timeout 360 "$INSTALL_DIR/omega" install hermes 2>/dev/null \ + || info "Run 'omega install hermes', then 'hermes setup' or 'omega config set hermes.api_key …'" +else + ok "Hermes CLI present (Home stream: omega new --agent hermes)" +fi # (e+f) Browser stack (Xvfb + Playwright + Chromium) for PDF generation and the # visual Quality Arsenal audits (uiux/flow/a11y/perf, browser-tester) + CDP. @@ -3548,17 +3562,16 @@ else fi # ─── Phase 6.91: Third-party skill collections (superpowers + gstack) ─────── -# Two MIT skill packs, pinned to reviewed SHAs, ALWAYS-ON (opt-out -# OMEGA_SKIP_THIRD_PARTY=1): obra/superpowers (14 process skills + SessionStart -# hook, additively merged into ~/.claude/settings.json) and garrytan/gstack -# (50+ gstack-* namespaced skills + the browse binary, built by its own setup). -# Best-effort: a network/chromium failure warns and never aborts the install. -# Update path + doctrine: docs/third-party-skills.md. +# Two MIT skill packs, pinned to reviewed SHAs, OPT-IN +# (OMEGA_WITH_THIRD_PARTY=1). Opt-out OMEGA_SKIP_THIRD_PARTY=1 still wins if +# both are set. obra/superpowers (14 process skills + SessionStart hook) and +# garrytan/gstack (50+ gstack-* skills + browse). Best-effort: a network +# failure warns and never aborts. Doctrine: docs/third-party-skills.md. step "Phase 6.91: Third-party skill collections (superpowers + gstack)" -if [[ "${OMEGA_SKIP_THIRD_PARTY:-0}" != "1" && -f "$OMEGA_SRC/scripts/install-third-party-skills.sh" ]]; then +if [[ "${OMEGA_WITH_THIRD_PARTY:-0}" == "1" && "${OMEGA_SKIP_THIRD_PARTY:-0}" != "1" && -f "$OMEGA_SRC/scripts/install-third-party-skills.sh" ]]; then bash "$OMEGA_SRC/scripts/install-third-party-skills.sh" || info "third-party skills step had warnings (non-fatal)" else - info "Third-party skill collections skipped (OMEGA_SKIP_THIRD_PARTY=1 or script missing)" + info "Third-party skill collections deferred (superpowers + gstack). Add: OMEGA_WITH_THIRD_PARTY=1 ./install.sh" fi # ─── Phase 6.915: Skill Atlas — discover every skill + how to run it ────────── diff --git a/scripts/install-third-party-skills.sh b/scripts/install-third-party-skills.sh index 6d6a08ec..b0f78ab7 100644 --- a/scripts/install-third-party-skills.sh +++ b/scripts/install-third-party-skills.sh @@ -21,8 +21,10 @@ # then re-run `bash scripts/install-third-party-skills.sh` (or ./install.sh). # Full doctrine + removal steps: docs/third-party-skills.md. # -# OPT-OUT -# OMEGA_SKIP_THIRD_PARTY=1 skips the whole step. +# OPT-IN / OPT-OUT +# install.sh only runs this script when OMEGA_WITH_THIRD_PARTY=1. +# OMEGA_SKIP_THIRD_PARTY=1 skips the whole step (wins if both are set). +# Running this script directly still installs (unless SKIP is set). # # ADDITIVE GUARANTEES (zero regression on existing skills/hooks) # - Never overwrites an existing non-superpowers skill dir (collision → skip). diff --git a/scripts/verify-install.sh b/scripts/verify-install.sh index 1e70201e..c5b6f5c5 100755 --- a/scripts/verify-install.sh +++ b/scripts/verify-install.sh @@ -398,13 +398,24 @@ if grep -q "fn provider_harness_block" crates/omega-core/src/rules.rs \ && grep -q 'opencode' crates/omega-core/src/orchestration.rs \ && grep -q 'link_policy_kernel' crates/omega-cli/src/main.rs \ && grep -q 'OpenCode' crates/omega-cli/src/main.rs \ - && grep -q 'OMEGAOS-KERNEL:START' crates/omega-cli/src/main.rs \ + && grep -q 'OMEGAOS-KERNEL:START' crates/omega-core/src/hermes_sync.rs \ + && grep -q 'sync_hermes_home' crates/omega-cli/src/main.rs \ && grep -q 'AISB protocols synced' crates/omega-cli/src/main.rs \ && grep -q 'provider: Option' crates/omega-cli/src/main.rs; then ok "provider harness overlay + OpenCode/Hermes/AISB-protocol sync wiring present" else bad "rules are not mapped per-harness (Claude/Codex/OpenCode/Hermes) or omega sync misses OpenCode/Hermes" fi +if grep -q 'omega install hermes' install.sh \ + && grep -q -- '--skip-setup' crates/omega-core/src/agents.rs \ + && grep -q 'HERMES_HOME=' crates/omega-core/src/agents.rs \ + && grep -q '.hermes/bin' crates/omega-core/src/agents.rs \ + && grep -q 'pub fn sync_hermes_home' crates/omega-core/src/hermes_sync.rs \ + && grep -q 'skill-bundles' crates/omega-core/src/hermes_sync.rs; then + ok "Hermes Home is installed by install.sh and fully synced (SOUL + skills + /omegaos bundle)" +else + bad "Hermes install/sync/stream wiring missing from install.sh or omega-core" +fi aisb_dead_ids=$(grep -RlnE '\*\*R-(18|19|21|28|35)\*\*|owns R-(18|19|21|28|35)' agents/aisb --include='*.md' | grep -v '_quality-kernel.md' | grep -v 'protocols/shared-protocol.md' | grep -v 'CLAUDE.md' || true) if [ -z "$aisb_dead_ids" ]; then ok "AISB prompts do not own retired R-18/R-19/R-21/R-28/R-35 IDs" @@ -509,8 +520,9 @@ fi # never claim a default install provisions it. if [ -f scripts/install-companion-tools.sh ] && grep -q "install-companion-tools.sh" install.sh && grep -q "OMEGA_WITH_COMPANION" install.sh; then ok "companion tools (planning-with-files/higgsfield/claude-mem/superpowers/mempalace/remotion) available OPT-IN (deferred by default — OMEGA_WITH_COMPANION=1)"; else bad "companion-tools installer not shipped/wired (opt-in branch) in install.sh"; fi # Third-party skill collections (obra/superpowers + garrytan/gstack): shipped, -# wired ALWAYS-ON (opt-out OMEGA_SKIP_THIRD_PARTY=1), pinned to full 40-hex SHAs. -if [ -f scripts/install-third-party-skills.sh ] && grep -q "install-third-party-skills.sh" install.sh && grep -q "OMEGA_SKIP_THIRD_PARTY" install.sh; then ok "third-party skill collections (superpowers + gstack) wired ALWAYS-ON (opt-out OMEGA_SKIP_THIRD_PARTY=1)"; else bad "third-party skills installer not shipped/wired (opt-out branch) in install.sh"; fi +# OPT-IN (OMEGA_WITH_THIRD_PARTY=1). Skip still wins if both are set. Pins are +# full 40-hex SHAs (checked below). +if [ -f scripts/install-third-party-skills.sh ] && grep -q "install-third-party-skills.sh" install.sh && grep -q "OMEGA_WITH_THIRD_PARTY" install.sh && grep -q "OMEGA_SKIP_THIRD_PARTY" install.sh; then ok "third-party skill collections (superpowers + gstack) OPT-IN (OMEGA_WITH_THIRD_PARTY=1)"; else bad "third-party skills installer not shipped/wired (opt-in branch) in install.sh"; fi if grep -qE 'SUPERPOWERS_PIN:-[0-9a-f]{40}' scripts/install-third-party-skills.sh && grep -qE 'GSTACK_PIN:-[0-9a-f]{40}' scripts/install-third-party-skills.sh; then ok "third-party pins are full 40-hex SHAs (reproducible)"; else bad "third-party pins are not full 40-hex SHAs (reproducibility broken)"; fi if [ -f docs/third-party-skills.md ] && grep -q "third-party-skills" docs/third-party-skills.md; then ok "third-party skills doctrine shipped (docs/third-party-skills.md)"; else bad "docs/third-party-skills.md missing"; fi # Browser engine for the Quality Arsenal audits (uiux/flow/a11y/perf, browser-tester) From 491d0d095a3b112beb5b3e79d220de7b6581cbc4 Mon Sep 17 00:00:00 2001 From: agentik-os Date: Wed, 26 Aug 2026 01:58:48 +0200 Subject: [PATCH 2/2] feat: wire the Hermes messaging gateway into OmegaOS Manage hermes gateway as a user service with omega on PATH, isolate it from the Atlas Telegram bot, and surface health in omega doctor. Co-authored-by: Cursor --- CHANGELOG.md | 4 + crates/omega-cli/src/main.rs | 135 +++++++++ crates/omega-core/src/doctor.rs | 58 ++++ crates/omega-core/src/hermes_gateway.rs | 353 ++++++++++++++++++++++++ crates/omega-core/src/hermes_sync.rs | 11 +- crates/omega-core/src/lib.rs | 1 + docs/GETTING-STARTED.md | 18 +- docs/PROVIDER-COMPATIBILITY.md | 9 +- install.sh | 9 + scripts/verify-install.sh | 12 +- 10 files changed, 596 insertions(+), 14 deletions(-) create mode 100644 crates/omega-core/src/hermes_gateway.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 71e6134c..34ea943d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ for [semantic versioning](https://semver.org) once it reaches 1.0. Until then, ## [Unreleased] +- Hermes messaging gateway is wired into OmegaOS: `omega hermes-gateway` + (install/setup/start/status), systemd PATH drop-in so `omega` is visible, + `omega doctor` health + `--fix` start, and a hard fail if Hermes reuses the + Atlas Telegram token. `install.sh` installs the unit after the Hermes CLI. - Hermes is a first-class Home stream: `install.sh` runs `omega install hermes` (non-interactive: `--skip-setup --skip-browser --skip-computer-use`), and `omega sync` always creates `~/.hermes` with a SOUL.md kernel pointer, diff --git a/crates/omega-cli/src/main.rs b/crates/omega-cli/src/main.rs index 9378f4e8..036a0039 100644 --- a/crates/omega-cli/src/main.rs +++ b/crates/omega-cli/src/main.rs @@ -237,6 +237,13 @@ enum Commands { action: TelegramAction, }, + /// Manage the Hermes messaging gateway (Telegram/Discord/… — not omega-gateway) + #[command(name = "hermes-gateway")] + HermesGateway { + #[command(subcommand)] + action: HermesGatewayAction, + }, + /// Generate a PDF report (whitepaper, audit, marketing, doc) Pdf { /// Template: whitepaper, audit, marketing, doc @@ -1078,6 +1085,7 @@ async fn main() -> Result<()> { } }, Some(Commands::Telegram { action }) => cmd_telegram(action).await, + Some(Commands::HermesGateway { action }) => cmd_hermes_gateway(action), Some(Commands::Pdf { template, data, @@ -3940,6 +3948,18 @@ fn cmd_install(agent_name: &str, dry_run: bool, force: bool) -> Result<()> { println!("\nSyncing OmegaOS config..."); let _ = cmd_sync(); + if agent == omega_core::agents::Agent::Hermes { + if let Some(home) = dirs::home_dir() { + match omega_core::hermes_gateway::install_unit(&home, false) { + Ok(()) => println!("[+] Hermes messaging gateway service unit installed"), + Err(error) => println!( + "[!] Hermes gateway unit not installed yet: {error} \ + (run omega hermes-gateway install after hermes setup)" + ), + } + } + } + Ok(()) } @@ -4633,6 +4653,121 @@ enum AuditAction { }, } +#[derive(Subcommand)] +enum HermesGatewayAction { + /// Show CLI, configured platforms, service, and Atlas token collision + Status, + /// Install the native hermes-gateway user service (systemd / launchd) + Install { + /// Reinstall the unit even if it already exists + #[arg(long)] + force: bool, + }, + /// Start the gateway (refuses if it shares the Omega Atlas Telegram token) + Start, + /// Stop the gateway + Stop, + /// Restart the gateway + Restart, + /// Interactive platform wizard (`hermes gateway setup`) + Setup, +} + +fn cmd_hermes_gateway(action: HermesGatewayAction) -> Result<()> { + let home = dirs::home_dir().context("HOME is required for Hermes gateway")?; + match action { + HermesGatewayAction::Status => { + let report = omega_core::hermes_gateway::inspect(&home); + match report.cli { + Some(path) => println!("[+] hermes CLI: {}", path.display()), + None => println!("[!] hermes CLI missing — run omega install hermes"), + } + if report.platforms.is_empty() { + println!("[!] no messaging platform configured — omega hermes-gateway setup"); + } else { + println!("[+] platforms: {}", report.platforms.join(", ")); + } + if report.telegram_collision { + println!( + "[x] TELEGRAM_BOT_TOKEN matches the Omega Atlas bot. \ + Create a second @BotFather token — two pollers on one token fight." + ); + } + match report.service { + omega_core::hermes_gateway::GatewayService::Running => { + println!("[+] service: running") + } + omega_core::hermes_gateway::GatewayService::Stopped => { + println!("[!] service: stopped — omega hermes-gateway start") + } + omega_core::hermes_gateway::GatewayService::Missing => { + println!("[!] service: not installed — omega hermes-gateway install") + } + } + println!( + " HERMES_HOME={} (omega is on the gateway PATH)", + report.home.display() + ); + Ok(()) + } + HermesGatewayAction::Install { force } => { + let _ = cmd_sync(); + omega_core::hermes_gateway::install_unit(&home, force)?; + println!("[+] hermes-gateway user service installed"); + let report = omega_core::hermes_gateway::inspect(&home); + if report.telegram_collision { + println!( + "[x] refused to start: Hermes Telegram token equals Omega Atlas. \ + Use a different bot." + ); + } else if report.configured() { + println!(" platforms ready — start with: omega hermes-gateway start"); + } else { + println!(" next: omega hermes-gateway setup"); + } + Ok(()) + } + HermesGatewayAction::Start => { + omega_core::hermes_gateway::start(&home)?; + println!("[+] hermes gateway started"); + Ok(()) + } + HermesGatewayAction::Stop => { + omega_core::hermes_gateway::stop(&home)?; + println!("[+] hermes gateway stopped"); + Ok(()) + } + HermesGatewayAction::Restart => { + omega_core::hermes_gateway::restart(&home)?; + println!("[+] hermes gateway restarted"); + Ok(()) + } + HermesGatewayAction::Setup => { + println!("Launching hermes gateway setup (interactive)…"); + println!("Use a DIFFERENT Telegram bot than Omega Atlas (`omega telegram setup`)."); + let bin = omega_core::hermes_gateway::find_hermes(&home) + .context("hermes CLI not found — run omega install hermes")?; + let status = std::process::Command::new(bin) + .args(["gateway", "setup"]) + .env("HERMES_HOME", omega_core::hermes_sync::hermes_home(&home)) + .env( + "PATH", + format!( + "{}:{}", + omega_core::hermes_gateway::gateway_path(&home), + std::env::var("PATH").unwrap_or_default() + ), + ) + .status() + .context("hermes gateway setup")?; + if !status.success() { + anyhow::bail!("hermes gateway setup exited {status}"); + } + Ok(()) + } + } +} + #[derive(Subcommand)] enum TelegramAction { /// Save bot token + chat id (+ optional sender allow-list) to ~/.omega/telegram.toml diff --git a/crates/omega-core/src/doctor.rs b/crates/omega-core/src/doctor.rs index d023fe00..31e94b9b 100644 --- a/crates/omega-core/src/doctor.rs +++ b/crates/omega-core/src/doctor.rs @@ -315,6 +315,45 @@ fn check_hooks(config: &OmegaConfig) -> Check { ) } +fn hermes_gateway_check() -> Check { + let Some(home) = dirs::home_dir() else { + return Check::warn("hermes gateway", "HOME unavailable"); + }; + let report = crate::hermes_gateway::inspect(&home); + if report.cli.is_none() { + return Check::warn( + "hermes gateway", + "hermes CLI missing — optional Home stream: omega install hermes", + ); + } + if report.telegram_collision { + return Check::fail( + "hermes gateway", + "TELEGRAM_BOT_TOKEN equals the Omega Atlas bot — create a second @BotFather token", + ); + } + if !report.configured() { + return Check::ok( + "hermes gateway", + "idle (omega hermes-gateway setup to connect Telegram/Discord)", + ); + } + let platforms = report.platforms.join(", "); + match report.service { + crate::hermes_gateway::GatewayService::Running => { + Check::ok("hermes gateway", format!("running ({platforms})")) + } + crate::hermes_gateway::GatewayService::Stopped => Check::warn( + "hermes gateway", + format!("{platforms} configured but stopped — omega hermes-gateway start"), + ), + crate::hermes_gateway::GatewayService::Missing => Check::warn( + "hermes gateway", + format!("{platforms} configured but unit missing — omega hermes-gateway install"), + ), + } +} + fn effective_containment( config: &OmegaConfig, providers: &crate::providers::ProvidersConfig, @@ -888,6 +927,10 @@ pub async fn run_all(config: &OmegaConfig) -> Vec { )), } + // 6a. Hermes messaging gateway (optional). Idle is fine; a shared + // Telegram token with Atlas is a hard fail. + checks.push(hermes_gateway_check()); + // 6b. Claude Code hooks installed + registered. checks.push(check_hooks(config)); @@ -1522,6 +1565,20 @@ fn fix_restart_tg_service() -> Vec { } } +fn fix_hermes_gateway() -> Vec { + let Some(home) = dirs::home_dir() else { + return Vec::new(); + }; + let report = crate::hermes_gateway::inspect(&home); + if report.telegram_collision || !report.configured() { + return Vec::new(); + } + match crate::hermes_gateway::start(&home) { + Ok(()) => vec!["started hermes messaging gateway".into()], + Err(_) => Vec::new(), + } +} + fn fix_refresh_usage() -> Vec { let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("omega")); let ok = std::process::Command::new(exe) @@ -1566,6 +1623,7 @@ pub fn auto_fix(checks: &[Check]) -> Vec { match c.name.as_str() { "telegram poller" => log.extend(fix_duplicate_pollers()), "telegram service" => log.extend(fix_restart_tg_service()), + "hermes gateway" => log.extend(fix_hermes_gateway()), "usage cache" => log.extend(fix_refresh_usage()), "claude oauth" => log.extend(fix_refresh_oauth()), _ => {} diff --git a/crates/omega-core/src/hermes_gateway.rs b/crates/omega-core/src/hermes_gateway.rs new file mode 100644 index 00000000..97a18cf1 --- /dev/null +++ b/crates/omega-core/src/hermes_gateway.rs @@ -0,0 +1,353 @@ +//! Hermes messaging gateway — service, PATH, and Telegram isolation. +//! +//! Hermes's gateway (`hermes gateway`) is a single background process that +//! talks to Telegram / Discord / Slack / …. It is not `omega-gateway` (the +//! OmegaOS HTTP API) and it is not the Omega Atlas Telegram bot. Two +//! getUpdates pollers on one BotFather token fight; this module refuses that. + +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use crate::hermes_sync::hermes_home; + +const PLATFORM_ENV: &[(&str, &str)] = &[ + ("TELEGRAM_BOT_TOKEN", "telegram"), + ("DISCORD_BOT_TOKEN", "discord"), + ("SLACK_BOT_TOKEN", "slack"), + ("SLACK_APP_TOKEN", "slack"), + ("WHATSAPP_TOKEN", "whatsapp"), + ("SIGNAL_ACCOUNT", "signal"), + ("EMAIL_ADDRESS", "email"), + ("MATRIX_ACCESS_TOKEN", "matrix"), + ("TEAMS_APP_ID", "teams"), + ("GATEWAY_RELAY_ID", "relay"), +]; + +pub const SYSTEMD_UNIT: &str = "hermes-gateway.service"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GatewayService { + Running, + Stopped, + Missing, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatewayReport { + pub home: PathBuf, + pub cli: Option, + pub platforms: Vec, + pub telegram_collision: bool, + pub service: GatewayService, +} + +impl GatewayReport { + pub fn configured(&self) -> bool { + !self.platforms.is_empty() + } +} + +pub fn inspect(user_home: &Path) -> GatewayReport { + let home = hermes_home(user_home); + let cli = find_hermes(user_home); + let env_text = std::fs::read_to_string(home.join(".env")).unwrap_or_default(); + let platforms = configured_platforms(&env_text); + let hermes_tg = env_value(&env_text, "TELEGRAM_BOT_TOKEN"); + let omega_tg = omega_telegram_token(); + let telegram_collision = tokens_collide(hermes_tg.as_deref(), omega_tg.as_deref()); + let service = if cli.is_some() { + service_state(user_home) + } else { + GatewayService::Missing + }; + GatewayReport { + home, + cli, + platforms, + telegram_collision, + service, + } +} + +pub fn configured_platforms(env_text: &str) -> Vec { + let mut out = Vec::new(); + for (key, name) in PLATFORM_ENV { + if env_value(env_text, key).is_some_and(|v| !v.is_empty()) && !out.iter().any(|n| n == name) + { + out.push((*name).to_string()); + } + } + out +} + +pub fn tokens_collide(hermes_token: Option<&str>, omega_token: Option<&str>) -> bool { + match (hermes_token, omega_token) { + (Some(a), Some(b)) => !a.is_empty() && a == b, + _ => false, + } +} + +pub fn env_value(env_text: &str, key: &str) -> Option { + for raw in env_text.lines() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some(rest) = line.strip_prefix(key) else { + continue; + }; + let rest = rest.trim_start(); + let Some(rest) = rest.strip_prefix('=') else { + continue; + }; + let value = rest.trim().trim_matches('"').trim_matches('\'').trim(); + if value.is_empty() { + return None; + } + return Some(value.to_string()); + } + None +} + +pub fn gateway_path(user_home: &Path) -> String { + format!( + "{home}/.local/bin:{home}/.hermes/bin:{home}/.hermes/hermes-agent/venv/bin:{home}/.bun/bin:{home}/.npm-global/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin", + home = user_home.display() + ) +} + +pub fn write_path_dropin(user_home: &Path) -> Result { + let dest = user_home + .join(".config") + .join("systemd") + .join("user") + .join(format!("{SYSTEMD_UNIT}.d")) + .join("omegaos-path.conf"); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let body = format!( + "# Written by omega sync / omega hermes-gateway install.\n\ + # Hermes launchd/systemd otherwise inherit a PATH without omega.\n\ + [Service]\n\ + Environment=HERMES_HOME={home}/.hermes\n\ + Environment=PATH={path}\n", + home = user_home.display(), + path = gateway_path(user_home) + ); + std::fs::write(&dest, body)?; + Ok(dest) +} + +pub fn find_hermes(user_home: &Path) -> Option { + let candidates = [ + user_home.join(".local/bin/hermes"), + user_home.join(".hermes/bin/hermes"), + user_home.join(".hermes/hermes-agent/venv/bin/hermes"), + ]; + for path in candidates { + if path.is_file() { + return Some(path); + } + } + which("hermes") +} + +pub fn run_hermes(user_home: &Path, args: &[&str], inherit: bool) -> Result { + let bin = find_hermes(user_home).context("hermes CLI not found — run omega install hermes")?; + let mut cmd = Command::new(bin); + cmd.args(args) + .env("HERMES_HOME", hermes_home(user_home)) + .env( + "PATH", + format!( + "{}:{}", + gateway_path(user_home), + std::env::var("PATH").unwrap_or_default() + ), + ); + if inherit { + cmd.stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + } + cmd.output().context("running hermes") +} + +pub fn install_unit(user_home: &Path, force: bool) -> Result<()> { + write_path_dropin(user_home)?; + let mut args = vec!["gateway", "install"]; + if force { + args.push("--force"); + } + let output = run_hermes(user_home, &args, false)?; + if !output.status.success() { + anyhow::bail!( + "hermes gateway install failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let _ = Command::new("systemctl") + .args(["--user", "daemon-reload"]) + .output(); + Ok(()) +} + +pub fn start(user_home: &Path) -> Result<()> { + let report = inspect(user_home); + if report.telegram_collision { + anyhow::bail!( + "Hermes TELEGRAM_BOT_TOKEN matches the Omega Atlas bot. \ + Create a second bot with @BotFather — two pollers on one token fight." + ); + } + if !report.configured() { + anyhow::bail!("no Hermes messaging platform configured — run omega hermes-gateway setup"); + } + hermes_ok(user_home, &["gateway", "start"]) +} + +pub fn stop(user_home: &Path) -> Result<()> { + hermes_ok(user_home, &["gateway", "stop"]) +} + +pub fn restart(user_home: &Path) -> Result<()> { + let report = inspect(user_home); + if report.telegram_collision { + anyhow::bail!( + "Hermes TELEGRAM_BOT_TOKEN matches the Omega Atlas bot. \ + Create a second bot with @BotFather." + ); + } + hermes_ok(user_home, &["gateway", "restart"]) +} + +fn hermes_ok(user_home: &Path, args: &[&str]) -> Result<()> { + let output = run_hermes(user_home, args, false)?; + if !output.status.success() { + anyhow::bail!( + "hermes {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +fn service_state(user_home: &Path) -> GatewayService { + if let Ok(output) = run_hermes(user_home, &["gateway", "status"], false) { + let text = format!( + "{} {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .to_ascii_lowercase(); + if text.contains("running") || text.contains("active") { + return GatewayService::Running; + } + if output.status.success() + || text.contains("inactive") + || text.contains("stopped") + || text.contains("not running") + { + return GatewayService::Stopped; + } + if text.contains("not installed") || text.contains("no service") { + return GatewayService::Missing; + } + } + systemd_fallback() +} + +fn systemd_fallback() -> GatewayService { + let out = Command::new("systemctl") + .args(["--user", "is-active", SYSTEMD_UNIT]) + .output(); + match out { + Ok(output) => { + let s = String::from_utf8_lossy(&output.stdout).trim().to_string(); + match s.as_str() { + "active" => GatewayService::Running, + "" => GatewayService::Missing, + _ => GatewayService::Stopped, + } + } + Err(_) => GatewayService::Missing, + } +} + +fn omega_telegram_token() -> Option { + crate::monitor::OmegaTelegramConfig::read() + .map(|cfg| cfg.bot_token.trim().to_string()) + .filter(|t| !t.is_empty()) +} + +fn which(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path) { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_parser_skips_comments_and_empty_values() { + let env = "\ +# TELEGRAM_BOT_TOKEN=nope\n\ +TELEGRAM_BOT_TOKEN=\n\ +DISCORD_BOT_TOKEN=\"abc\"\n\ +SLACK_BOT_TOKEN='xyz'\n"; + assert_eq!(env_value(env, "TELEGRAM_BOT_TOKEN"), None); + assert_eq!(env_value(env, "DISCORD_BOT_TOKEN").as_deref(), Some("abc")); + assert_eq!( + configured_platforms(env), + vec!["discord".to_string(), "slack".to_string()] + ); + } + + #[test] + fn inspect_without_hermes_is_idle_and_never_prints_tokens() { + let tmp = tempfile::TempDir::new().unwrap(); + let home = tmp.path(); + std::fs::create_dir_all(home.join(".hermes")).unwrap(); + std::fs::write( + home.join(".hermes/.env"), + "TELEGRAM_BOT_TOKEN=111:SECRETTOKENVALUE\n", + ) + .unwrap(); + let report = inspect(home); + assert_eq!(report.platforms, vec!["telegram".to_string()]); + assert!(!report.telegram_collision); + let debug = format!("{report:?}"); + assert!( + !debug.contains("SECRETTOKENVALUE"), + "gateway report must not leak tokens: {debug}" + ); + } + + #[test] + fn atlas_and_hermes_must_not_share_a_bot_token() { + assert!(!tokens_collide(None, Some("a"))); + assert!(!tokens_collide(Some("a"), None)); + assert!(!tokens_collide(Some("a"), Some("b"))); + assert!(tokens_collide(Some("same"), Some("same"))); + } + + #[test] + fn path_dropin_exports_omega_and_hermes_home() { + let tmp = tempfile::TempDir::new().unwrap(); + let dest = write_path_dropin(tmp.path()).unwrap(); + let body = std::fs::read_to_string(dest).unwrap(); + assert!(body.contains("HERMES_HOME=")); + assert!(body.contains(".local/bin")); + assert!(body.contains(".hermes/bin")); + } +} diff --git a/crates/omega-core/src/hermes_sync.rs b/crates/omega-core/src/hermes_sync.rs index 7e7f2061..233df1b8 100644 --- a/crates/omega-core/src/hermes_sync.rs +++ b/crates/omega-core/src/hermes_sync.rs @@ -31,10 +31,12 @@ pub const CORE_SKILLS: &[&str] = &[ "product-development-system", ]; -const SOUL_BODY: &str = - "You run under OmegaOS. Follow `~/.omega/AGENTS.md` (Laws L0–L6 + named rules). \ -Durable state is `omega progress` / `omega done`. Use Hermes native tools — \ -do not invent Claude TaskCreate, `/goal`, or Codex `update_plan`."; +const SOUL_BODY: &str = "You run under OmegaOS — Home TUI (`omega new --agent hermes`) and the \ +messaging gateway (`hermes gateway`) share this soul. Follow `~/.omega/AGENTS.md` \ +(Laws L0–L6 + named rules). Durable state is `omega progress` / `omega done` \ +(`omega` is on PATH). Use Hermes native tools — do not invent Claude TaskCreate, \ +`/goal`, or Codex `update_plan`. You are not the Omega Telegram Atlas bot; \ +never reuse its BotFather token."; const BUNDLE_YAML: &str = "name: omegaos\n\ description: OmegaOS Home loop — plan, build, verify, report.\n\ @@ -94,6 +96,7 @@ pub fn sync_hermes_home( std::fs::write(home.join("skill-bundles").join("omegaos.yaml"), BUNDLE_YAML)?; let linked = link_core_skills(&home, &omega_skills)?; + let _ = crate::hermes_gateway::write_path_dropin(user_home); Ok(HermesSyncReport { home, diff --git a/crates/omega-core/src/lib.rs b/crates/omega-core/src/lib.rs index 6fdf664f..ce3a7575 100644 --- a/crates/omega-core/src/lib.rs +++ b/crates/omega-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod graph; pub mod graph_executor; pub mod graph_risk; pub mod guardian; +pub mod hermes_gateway; pub mod hermes_sync; pub mod inbox; pub mod intent; diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 11ba6e5c..860faa68 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -30,9 +30,21 @@ for Gemini Code Assist Standard/Enterprise and paid API-key users. The installer also provisions **Hermes as the Home stream** (`omega new --agent hermes`). `omega sync` wires `~/.hermes` (SOUL.md pointer, AGENTS.md, curated -skills, `/omegaos` bundle). Hermes is not a dispatch writer — oracles and -workers stay on Codex/Claude. After install, run `hermes setup` or -`omega config set hermes.api_key …` if the CLI has no credentials yet. +skills, `/omegaos` bundle) and installs the **Hermes messaging gateway** unit. +Hermes is not a dispatch writer — oracles and workers stay on Codex/Claude. +After install, run `hermes setup` or `omega config set hermes.api_key …` if the +CLI has no credentials yet. + +To chat with Hermes from Telegram/Discord/Slack (separate from Omega Atlas): + +``` +omega hermes-gateway setup +omega hermes-gateway start +omega hermes-gateway status +``` + +Use a **different** @BotFather token than `omega telegram setup`. Two pollers on +the same token fight; `omega doctor` fails that collision. ## Step 2 — Telegram remote control (recommended) diff --git a/docs/PROVIDER-COMPATIBILITY.md b/docs/PROVIDER-COMPATIBILITY.md index df365c6b..d340f1a5 100644 --- a/docs/PROVIDER-COMPATIBILITY.md +++ b/docs/PROVIDER-COMPATIBILITY.md @@ -58,9 +58,12 @@ omega dispatch MyProject "mission" --agent codex ``` Hermes is Home. `./install.sh` installs the Hermes CLI when missing, then -`omega sync` creates `~/.hermes` even before the first `hermes chat`. Cloud -(Cursor Cloud Agent) is the Cursor-side writer for OmegaOS itself — it is -not `omega dispatch`. See `docs/ADR-lab-three-backends.md`. +`omega sync` creates `~/.hermes` even before the first `hermes chat`. The +messaging gateway (`hermes gateway`) is a user service managed by +`omega hermes-gateway {install,setup,start,status}`. It must not share a +Telegram bot token with Omega Atlas. Cloud (Cursor Cloud Agent) is the +Cursor-side writer for OmegaOS itself — it is not `omega dispatch`. See +`docs/ADR-lab-three-backends.md`. The active global selection is mirrored in `~/.omega/state/active-model.json`. A mission-level `--agent` override takes diff --git a/install.sh b/install.sh index 99c9bd43..bb7c94cc 100755 --- a/install.sh +++ b/install.sh @@ -3511,6 +3511,15 @@ if ! command -v hermes >/dev/null 2>&1 \ else ok "Hermes CLI present (Home stream: omega new --agent hermes)" fi +if command -v hermes >/dev/null 2>&1 \ + || [[ -x "${HOME}/.local/bin/hermes" ]] \ + || [[ -x "${HOME}/.hermes/bin/hermes" ]]; then + if [[ -x "$INSTALL_DIR/omega" ]]; then + omega_timeout 60 "$INSTALL_DIR/omega" hermes-gateway install 2>/dev/null \ + && ok "Hermes messaging gateway unit installed (setup: omega hermes-gateway setup)" \ + || info "Hermes gateway unit later: omega hermes-gateway install && omega hermes-gateway setup" + fi +fi # (e+f) Browser stack (Xvfb + Playwright + Chromium) for PDF generation and the # visual Quality Arsenal audits (uiux/flow/a11y/perf, browser-tester) + CDP. diff --git a/scripts/verify-install.sh b/scripts/verify-install.sh index c5b6f5c5..5332ebba 100755 --- a/scripts/verify-install.sh +++ b/scripts/verify-install.sh @@ -411,10 +411,14 @@ if grep -q 'omega install hermes' install.sh \ && grep -q 'HERMES_HOME=' crates/omega-core/src/agents.rs \ && grep -q '.hermes/bin' crates/omega-core/src/agents.rs \ && grep -q 'pub fn sync_hermes_home' crates/omega-core/src/hermes_sync.rs \ - && grep -q 'skill-bundles' crates/omega-core/src/hermes_sync.rs; then - ok "Hermes Home is installed by install.sh and fully synced (SOUL + skills + /omegaos bundle)" -else - bad "Hermes install/sync/stream wiring missing from install.sh or omega-core" + && grep -q 'skill-bundles' crates/omega-core/src/hermes_sync.rs \ + && grep -q 'hermes-gateway' crates/omega-cli/src/main.rs \ + && grep -q 'hermes-gateway install' install.sh \ + && grep -q 'fn hermes_gateway_check' crates/omega-core/src/doctor.rs \ + && grep -q 'telegram_collision' crates/omega-core/src/hermes_gateway.rs; then + ok "Hermes Home + messaging gateway are installed, synced, and isolated from Atlas Telegram" +else + bad "Hermes install/sync/gateway wiring missing from install.sh or omega-core" fi aisb_dead_ids=$(grep -RlnE '\*\*R-(18|19|21|28|35)\*\*|owns R-(18|19|21|28|35)' agents/aisb --include='*.md' | grep -v '_quality-kernel.md' | grep -v 'protocols/shared-protocol.md' | grep -v 'CLAUDE.md' || true) if [ -z "$aisb_dead_ids" ]; then