Skip to main content

supercode_harness/
hermes_import.rs

1//! UNI-18: the Hermes write path, through Hermes's own door.
2//!
3//! Hermes 0.21.0 ships `hermes sessions import --from {claude,codex} <file>`:
4//! a per-session import that reads a foreign transcript and writes it into
5//! the Hermes session store with Hermes's OWN writer — its schema, its
6//! migrations, its WAL single-writer lock. supercode never opens a live
7//! Hermes `state.db` for writing: it renders the session as a Codex rollout
8//! (the format Hermes imports most faithfully) and hands the file to that
9//! door. The new session is Hermes's (a fresh Hermes id; `origin_json`
10//! records the rollout path and the foreign id), and `hermes --resume <id>`
11//! continues it.
12
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16use serde::Serialize;
17use supercode_interchange::{Session, SessionFormat};
18
19use crate::Result;
20
21/// What the door wrote.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
23pub struct HermesImported {
24    /// The Hermes session id the import minted; `hermes --resume <id>`.
25    pub session_id: String,
26    /// The Hermes home whose store now holds it.
27    pub home: PathBuf,
28    /// The Codex rollout Hermes read (kept: Hermes records it as provenance).
29    pub rollout: PathBuf,
30    /// The door.
31    pub via: &'static str,
32}
33
34/// `HERMES_HOME`, else `~/.hermes` — the same resolution Hermes itself uses.
35pub fn hermes_home() -> PathBuf {
36    std::env::var_os("HERMES_HOME")
37        .map(PathBuf::from)
38        .unwrap_or_else(|| {
39            let home = supercode_interchange::user_home()
40                .map(std::path::PathBuf::into_os_string)
41                .map(PathBuf::from)
42                .unwrap_or_default();
43            home.join(".hermes")
44        })
45}
46
47/// Write `session` into the Hermes store at `home` (default: [`hermes_home`])
48/// through `hermes sessions import`.
49pub fn import_into_hermes(session: &Session, home: Option<&Path>) -> Result<HermesImported> {
50    let home = home.map(Path::to_path_buf).unwrap_or_else(hermes_home);
51    let rollout_dir = home.join("imports").join("supercode");
52    std::fs::create_dir_all(&rollout_dir)?;
53    let stamp = std::time::SystemTime::now()
54        .duration_since(std::time::UNIX_EPOCH)
55        .map(|d| d.as_millis())
56        .unwrap_or(0);
57    let stem = session
58        .meta
59        .session_id
60        .clone()
61        .unwrap_or_else(|| "session".into());
62    let rollout = rollout_dir.join(format!("rollout-{stamp}-{stem}.jsonl"));
63    std::fs::write(&rollout, session.to_jsonl(SessionFormat::Codex)?)?;
64    let output = Command::new("hermes")
65        .args(["sessions", "import", "--from", "codex"])
66        .arg(&rollout)
67        .env("HERMES_HOME", &home)
68        .env("HERMES_NO_ONBOARDING", "1")
69        .env("HERMES_NONINTERACTIVE", "1")
70        .output()
71        .map_err(|e| {
72            crate::Error::Other(format!("`hermes sessions import` could not start: {e}"))
73        })?;
74    let stdout = String::from_utf8_lossy(&output.stdout);
75    let session_id = stdout
76        .lines()
77        .find_map(|line| line.split(" session as ").nth(1))
78        .map(|rest| rest.trim().to_string())
79        .filter(|s| !s.is_empty());
80    match (output.status.success(), session_id) {
81        (true, Some(session_id)) => Ok(HermesImported {
82            session_id,
83            home,
84            rollout,
85            via: "hermes sessions import --from codex",
86        }),
87        // Hermes's SessionDB waits for another process's write lock, then gives up with
88        // `database is locked` having written nothing (measured: a held lock delays the import
89        // until release; one held past Hermes's wait fails with the session count unchanged).
90        (false, _) if String::from_utf8_lossy(&output.stderr).contains("database is locked") => {
91            Err(crate::Error::Other(format!(
92                "Hermes's session database is locked by another Hermes process; Hermes waited, gave \
93                 up, and imported nothing. The converted rollout is kept at {}: run `hermes sessions \
94                 import --from codex {}` once Hermes is idle.",
95                rollout.display(),
96                rollout.display()
97            )))
98        }
99        _ => Err(crate::Error::Other(format!(
100            "`hermes sessions import --from codex {}` did not report a session id (exit {}): {}{}",
101            rollout.display(),
102            output.status,
103            stdout.trim(),
104            String::from_utf8_lossy(&output.stderr).trim()
105        ))),
106    }
107}