1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum HarnessKind {
6 Generic,
8 ClaudeCode,
10 OpenCode,
12 Codex,
14 GitHubCopilot,
16 Pi,
18}
19
20impl HarnessKind {
21 pub fn canonical_name(self) -> &'static str {
23 match self {
24 Self::Generic => "generic",
25 Self::ClaudeCode => "claude-code",
26 Self::OpenCode => "opencode",
27 Self::Codex => "codex",
28 Self::GitHubCopilot => "github-copilot",
29 Self::Pi => "pi",
30 }
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct HarnessSessionEnv {
37 pub env_var: &'static str,
39 pub session_id: String,
41 pub harness: HarnessKind,
43}
44
45#[derive(Debug, Clone, Copy)]
46struct HarnessSessionProbe {
47 env_var: &'static str,
48 harness: HarnessKind,
49}
50
51const HARNESS_SESSION_PROBES: &[HarnessSessionProbe] = &[
52 HarnessSessionProbe {
53 env_var: "ITO_HARNESS_SESSION_ID",
54 harness: HarnessKind::Generic,
55 },
56 HarnessSessionProbe {
57 env_var: "CLAUDE_SESSION_ID",
58 harness: HarnessKind::ClaudeCode,
59 },
60 HarnessSessionProbe {
61 env_var: "OPENCODE_SESSION_ID",
62 harness: HarnessKind::OpenCode,
63 },
64 HarnessSessionProbe {
65 env_var: "CODEX_SESSION_ID",
66 harness: HarnessKind::Codex,
67 },
68 HarnessSessionProbe {
69 env_var: "GITHUB_COPILOT_CHAT_SESSION_ID",
70 harness: HarnessKind::GitHubCopilot,
71 },
72 HarnessSessionProbe {
73 env_var: "PI_SESSION_ID",
74 harness: HarnessKind::Pi,
75 },
76];
77
78pub const HARNESS_SESSION_ENV_VARS: &[&str] = &[
80 "ITO_HARNESS_SESSION_ID",
81 "CLAUDE_SESSION_ID",
82 "OPENCODE_SESSION_ID",
83 "CODEX_SESSION_ID",
84 "GITHUB_COPILOT_CHAT_SESSION_ID",
85 "PI_SESSION_ID",
86];
87
88pub fn resolve_harness_session_env() -> Option<HarnessSessionEnv> {
90 resolve_harness_session_env_with(|env_var| std::env::var(env_var).ok())
91}
92
93fn resolve_harness_session_env_with(
94 mut get_env: impl FnMut(&str) -> Option<String>,
95) -> Option<HarnessSessionEnv> {
96 for HarnessSessionProbe { env_var, harness } in HARNESS_SESSION_PROBES {
97 let Some(session_id) = get_env(env_var) else {
98 continue;
99 };
100 let session_id = session_id.trim();
101 if session_id.is_empty() {
102 continue;
103 }
104 let session_id = session_id.to_owned();
105 return Some(HarnessSessionEnv {
106 env_var,
107 session_id,
108 harness: *harness,
109 });
110 }
111
112 None
113}
114
115pub fn resolve_harness_session_id() -> Option<String> {
117 resolve_harness_session_env().map(|env| env.session_id)
118}
119
120pub fn detect_harness_name() -> &'static str {
122 resolve_harness_session_env()
123 .map(|env| env.harness.canonical_name())
124 .unwrap_or("unknown")
125}
126
127#[cfg(test)]
128mod tests {
129 use super::{
130 HARNESS_SESSION_ENV_VARS, HARNESS_SESSION_PROBES, HarnessSessionEnv,
131 resolve_harness_session_env_with,
132 };
133
134 fn resolve_from(values: &[(&str, &str)]) -> Option<HarnessSessionEnv> {
135 resolve_harness_session_env_with(|env_var| {
136 values
137 .iter()
138 .find(|(key, _value)| *key == env_var)
139 .map(|(_key, value)| (*value).to_string())
140 })
141 }
142
143 #[test]
144 fn detects_canonical_harness_names() {
145 let cases = [
146 ("ITO_HARNESS_SESSION_ID", "generic"),
147 ("CLAUDE_SESSION_ID", "claude-code"),
148 ("OPENCODE_SESSION_ID", "opencode"),
149 ("CODEX_SESSION_ID", "codex"),
150 ("GITHUB_COPILOT_CHAT_SESSION_ID", "github-copilot"),
151 ("PI_SESSION_ID", "pi"),
152 ];
153
154 for (env_var, expected) in cases {
155 let env = resolve_from(&[(env_var, "test-session")]).expect("harness env");
156 assert_eq!(env.harness.canonical_name(), expected);
157 assert_eq!(env.env_var, env_var);
158 }
159 }
160
161 #[test]
162 fn generic_session_id_takes_precedence() {
163 let env = resolve_from(&[
164 ("OPENCODE_SESSION_ID", "opencode-session"),
165 ("ITO_HARNESS_SESSION_ID", "generic-session"),
166 ])
167 .expect("harness env");
168 assert_eq!(env.env_var, "ITO_HARNESS_SESSION_ID");
169 assert_eq!(env.session_id, "generic-session");
170 assert_eq!(env.harness.canonical_name(), "generic");
171 }
172
173 #[test]
174 fn returns_unknown_without_harness_env() {
175 assert_eq!(resolve_from(&[]), None);
176 }
177
178 #[test]
179 fn public_env_var_list_matches_probe_order() {
180 let probe_env_vars: Vec<&str> = HARNESS_SESSION_PROBES
181 .iter()
182 .map(|probe| probe.env_var)
183 .collect();
184 assert_eq!(probe_env_vars, HARNESS_SESSION_ENV_VARS);
185 }
186}