1use std::collections::HashSet;
4use std::ffi::{OsStr, OsString};
5use std::io::{Read, Seek};
6use std::os::unix::ffi::OsStrExt;
7use std::path::{Path, PathBuf};
8use std::process::{Command, Stdio};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::{Arc, Mutex};
11use std::thread::{self, JoinHandle};
12use std::time::{Duration, Instant};
13
14use thiserror::Error;
15use tracing::debug;
16
17use crate::process::CommandChild;
18
19pub(crate) mod antigravity;
20pub(crate) mod claude;
21pub(crate) mod codex;
22pub(crate) mod history;
23pub(crate) mod instructions;
24pub(crate) mod kiro;
25pub(crate) mod launch;
26pub(crate) mod models;
27pub(crate) mod opencode;
28pub(crate) mod pi;
29mod prompt_context;
30pub(crate) mod resume;
31pub(crate) mod steps;
32
33const LOGIN_SHELL_TIMEOUT: Duration = Duration::from_secs(5);
35const MAX_LOGIN_SHELL_OUTPUT: u64 = 64 * 1024;
36const PATH_START: &[u8] = b"__HANDSHAKE_PATH__";
37const PATH_END: &[u8] = b"__HANDSHAKE_END__";
38
39#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub enum HarnessId {
42 Codex,
43 ClaudeCode,
44 Pi,
45 Antigravity,
46 Omp,
47 Opencode,
48 Kiro,
49}
50
51#[derive(Debug)]
53pub(crate) struct HarnessDefinition {
54 pub name: &'static str,
55 pub binary: &'static str,
56 pub reads_at_prefix_as_file: bool,
58 pub prompt_option: Option<&'static str>,
60 pub subcommand: Option<&'static str>,
62}
63
64static CODEX: HarnessDefinition = HarnessDefinition {
65 name: "Codex",
66 binary: "codex",
67 reads_at_prefix_as_file: false,
68 prompt_option: None,
69 subcommand: None,
70};
71static CLAUDE_CODE: HarnessDefinition = HarnessDefinition {
72 name: "Claude Code",
73 binary: "claude",
74 reads_at_prefix_as_file: false,
75 prompt_option: None,
76 subcommand: None,
77};
78static PI: HarnessDefinition = HarnessDefinition {
79 name: "pi",
80 binary: "pi",
81 reads_at_prefix_as_file: true,
82 prompt_option: None,
83 subcommand: None,
84};
85static ANTIGRAVITY: HarnessDefinition = HarnessDefinition {
86 name: "Antigravity",
87 binary: "agy",
88 reads_at_prefix_as_file: false,
89 prompt_option: Some("--prompt-interactive"),
90 subcommand: None,
91};
92static OMP: HarnessDefinition = HarnessDefinition {
93 name: "OMP",
94 binary: "omp",
95 reads_at_prefix_as_file: false,
97 prompt_option: None,
98 subcommand: Some("launch"),
99};
100static OPENCODE: HarnessDefinition = HarnessDefinition {
101 name: "OpenCode",
102 binary: "opencode",
103 reads_at_prefix_as_file: false,
104 prompt_option: Some("--prompt"),
105 subcommand: Some("mini"),
106};
107
108static KIRO: HarnessDefinition = HarnessDefinition {
109 name: "Kiro CLI",
110 binary: "kiro-cli",
111 reads_at_prefix_as_file: true,
112 prompt_option: None,
113 subcommand: Some("chat"),
114};
115
116impl HarnessId {
117 pub(crate) fn definition(self) -> &'static HarnessDefinition {
118 match self {
119 Self::Codex => &CODEX,
120 Self::ClaudeCode => &CLAUDE_CODE,
121 Self::Pi => &PI,
122 Self::Antigravity => &ANTIGRAVITY,
123 Self::Omp => &OMP,
124 Self::Opencode => &OPENCODE,
125 Self::Kiro => &KIRO,
126 }
127 }
128}
129
130impl HarnessDefinition {
131 pub(crate) fn arguments(&self, prompt: &str) -> Vec<OsString> {
133 if self.binary == KIRO.binary {
136 return vec![OsString::from(format!(" {prompt}"))];
137 }
138 let prompt = if self.reads_at_prefix_as_file && prompt.starts_with('@') {
139 format!(" {prompt}")
140 } else {
141 prompt.to_owned()
142 };
143 match self.prompt_option {
144 Some(option) => vec![OsString::from(format!("{option}={prompt}"))],
145 None => vec![OsString::from("--"), OsString::from(prompt)],
146 }
147 }
148}
149
150#[derive(Debug, Eq, PartialEq)]
152pub(crate) struct LocatedHarness {
153 pub program: PathBuf,
154 pub path: OsString,
155}
156
157#[derive(Debug, Error)]
158pub enum HarnessError {
159 #[error(
160 "{name} wasn't found on your PATH. Install it or add `{binary}` to your PATH, then try again."
161 )]
162 NotFound {
163 name: &'static str,
164 binary: &'static str,
165 },
166}
167
168pub(crate) fn locate_in(
172 definition: &HarnessDefinition,
173 login_path: Option<&OsStr>,
174 process_path: Option<&OsStr>,
175) -> Result<LocatedHarness, HarnessError> {
176 let directories = unique_directories(login_path.into_iter().chain(process_path));
177 let program = directories
178 .iter()
179 .map(|directory| directory.join(definition.binary))
180 .find(|candidate| is_executable(candidate))
181 .ok_or(HarnessError::NotFound {
182 name: definition.name,
183 binary: definition.binary,
184 })?;
185 let path = std::env::join_paths(&directories).unwrap_or_default();
186 Ok(LocatedHarness { program, path })
187}
188
189fn unique_directories<'a>(paths: impl Iterator<Item = &'a OsStr>) -> Vec<PathBuf> {
190 let mut seen = HashSet::new();
191 paths
192 .flat_map(|path| std::env::split_paths(path))
193 .filter(|directory| !directory.as_os_str().is_empty() && seen.insert(directory.clone()))
194 .collect()
195}
196
197fn is_executable(path: &Path) -> bool {
198 use std::os::unix::fs::PermissionsExt;
199 std::fs::metadata(path)
200 .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
201}
202
203pub(crate) struct LoginPath {
206 state: Mutex<LoginPathState>,
207 cancelled: Arc<AtomicBool>,
208}
209
210enum LoginPathState {
211 Pending(JoinHandle<Option<OsString>>),
212 Ready(Option<OsString>),
213}
214
215impl LoginPath {
216 pub(crate) fn spawn(shell: PathBuf) -> Self {
217 let cancelled = Arc::new(AtomicBool::new(false));
218 let flag = Arc::clone(&cancelled);
219 match crate::blocking_worker::spawn("login-path".into(), move || {
220 login_shell_path(&shell, &flag)
221 }) {
222 Ok(handle) => Self {
223 state: Mutex::new(LoginPathState::Pending(handle)),
224 cancelled,
225 },
226 Err(error) => {
227 debug!(%error, "failed to start the login PATH lookup");
228 Self::ready(None)
229 }
230 }
231 }
232
233 pub(crate) fn ready(path: Option<OsString>) -> Self {
234 Self {
235 state: Mutex::new(LoginPathState::Ready(path)),
236 cancelled: Arc::new(AtomicBool::new(false)),
237 }
238 }
239
240 pub(crate) fn get(&self) -> Option<OsString> {
241 let mut state = self
242 .state
243 .lock()
244 .unwrap_or_else(std::sync::PoisonError::into_inner);
245 if let LoginPathState::Pending(_) = &*state {
246 let LoginPathState::Pending(handle) =
247 std::mem::replace(&mut *state, LoginPathState::Ready(None))
248 else {
249 unreachable!("state was just checked to be pending");
250 };
251 *state = LoginPathState::Ready(handle.join().unwrap_or(None));
252 }
253 if self.cancelled.load(Ordering::Relaxed) {
254 return None;
255 }
256 match &*state {
257 LoginPathState::Ready(path) => path.clone(),
258 LoginPathState::Pending(_) => None,
259 }
260 }
261
262 pub(crate) fn cancel(&self) {
263 self.cancelled.store(true, Ordering::Relaxed);
264 }
265
266 pub(crate) fn shutdown(&self) {
267 self.cancel();
268 let _ = self.get();
270 }
271}
272
273impl Drop for LoginPath {
274 fn drop(&mut self) {
275 self.shutdown();
276 }
277}
278
279fn login_shell_path(shell: &Path, cancelled: &AtomicBool) -> Option<OsString> {
282 if cancelled.load(Ordering::Relaxed) {
283 return None;
284 }
285 let mut output = tempfile::tempfile().ok()?;
287 let mut command = Command::new(shell);
288 command
289 .args(["-i", "-l", "-c"])
290 .arg("printf '\\n__HANDSHAKE_PATH__%s__HANDSHAKE_END__\\n' \"$(printenv PATH)\"")
291 .stdin(Stdio::null())
292 .stdout(output.try_clone().ok()?)
293 .stderr(Stdio::null());
294 let mut child = CommandChild::spawn(&mut command)
295 .map_err(|error| debug!(%error, "failed to start the login shell for PATH lookup"))
296 .ok()?;
297 let deadline = Instant::now() + LOGIN_SHELL_TIMEOUT;
298 loop {
299 if cancelled.load(Ordering::Relaxed) {
300 return None;
301 }
302 if output.metadata().ok()?.len() > MAX_LOGIN_SHELL_OUTPUT {
303 return None;
304 }
305 if child.has_exited().ok()? {
306 child.finish().ok()?;
308 break;
309 }
310 if Instant::now() >= deadline {
311 debug!("login shell PATH lookup timed out");
312 return None;
313 }
314 thread::sleep(Duration::from_millis(5));
315 }
316 output.rewind().ok()?;
317 let mut bytes = Vec::new();
318 output
319 .take(MAX_LOGIN_SHELL_OUTPUT)
320 .read_to_end(&mut bytes)
321 .ok()?;
322 parse_path(&bytes)
323}
324
325fn parse_path(output: &[u8]) -> Option<OsString> {
326 let start = find(output, PATH_START)? + PATH_START.len();
327 let end = start + find(&output[start..], PATH_END)?;
328 let path = &output[start..end];
329 (!path.is_empty()).then(|| OsStr::from_bytes(path).to_owned())
330}
331
332fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
333 haystack
334 .windows(needle.len())
335 .position(|window| window == needle)
336}
337
338#[cfg(test)]
339mod tests {
340 use std::os::unix::fs::PermissionsExt;
341
342 use super::*;
343 use crate::process::tests::FixtureProcess;
344
345 #[test]
346 fn prompt_follows_a_double_dash() {
347 let arguments = HarnessId::ClaudeCode.definition().arguments("-fix it");
348 assert_eq!(arguments, ["--", "-fix it"].map(OsString::from));
349 }
350
351 #[test]
352 fn kiro_passes_literal_initial_prompts_without_a_double_dash() {
353 for prompt in ["-fix it", "@README.md", "chat", "line one\nline two"] {
354 assert_eq!(
355 KIRO.arguments(prompt),
356 [OsString::from(format!(" {prompt}"))]
357 );
358 }
359 }
360
361 #[test]
362 fn pi_keeps_an_at_prompt_from_being_read_as_a_file() {
363 let pi = HarnessId::Pi.definition().arguments("@README.md summarize");
364 assert_eq!(pi, ["--", " @README.md summarize"].map(OsString::from));
365 let claude = HarnessId::ClaudeCode.definition().arguments("@README.md");
366 assert_eq!(claude, ["--", "@README.md"].map(OsString::from));
367 }
368
369 #[test]
370 fn antigravity_keeps_initial_prompts_interactive_and_literal() {
371 let definition = HarnessId::Antigravity.definition();
372 assert_eq!(definition.binary, "agy");
373 for prompt in ["-fix it", "@README.md summarize", "first line\nsecond line"] {
374 assert_eq!(
375 definition.arguments(prompt),
376 [OsString::from(format!("--prompt-interactive={prompt}"))]
377 );
378 }
379 }
380
381 #[test]
382 fn omp_treats_flag_file_and_command_shaped_prompts_as_literal_text() {
383 let definition = HarnessId::Omp.definition();
384 assert_eq!(definition.binary, "omp");
385 assert_eq!(definition.subcommand, Some("launch"));
386 for prompt in ["-fix it", "@README.md", "models", "line one\nline two"] {
387 assert_eq!(
388 definition.arguments(prompt),
389 ["--", prompt].map(OsString::from)
390 );
391 }
392 }
393
394 #[test]
395 fn opencode_keeps_prompts_out_of_its_directory_and_command_arguments() {
396 let definition = HarnessId::Opencode.definition();
397 assert_eq!(definition.binary, "opencode");
398 assert_eq!(definition.subcommand, Some("mini"));
399 for prompt in ["-fix it", "@README.md", "models", "line one\nline two"] {
400 assert_eq!(
401 definition.arguments(prompt),
402 [OsString::from(format!("--prompt={prompt}"))]
403 );
404 }
405 }
406
407 #[test]
408 fn parses_path_between_markers_and_ignores_banners() {
409 assert_eq!(
410 parse_path(b"PATH=/wrong\nbanner\n__HANDSHAKE_PATH__/a:/b__HANDSHAKE_END__\n"),
411 Some(OsString::from("/a:/b"))
412 );
413 assert_eq!(parse_path(b"PATH=/wrong\n"), None);
414 assert_eq!(parse_path(b"__HANDSHAKE_PATH____HANDSHAKE_END__"), None);
415 }
416
417 fn fake_shell(directory: &Path, body: &str) -> PathBuf {
418 let shell = directory.join("shell");
419 std::fs::write(&shell, format!("#!/bin/sh\n{body}\n")).unwrap();
420 std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap();
421 shell
422 }
423
424 #[test]
425 fn login_path_comes_from_an_interactive_login_shell() {
426 let directory = tempfile::tempdir().unwrap();
427 let shell = fake_shell(
429 directory.path(),
430 "echo banner PATH=/wrong\n[ \"$1 $2\" = '-i -l' ] && printf '\\n__HANDSHAKE_PATH__/interactive__HANDSHAKE_END__\\n'",
431 );
432
433 assert_eq!(
434 LoginPath::spawn(shell).get(),
435 Some(OsString::from("/interactive"))
436 );
437 }
438
439 #[test]
440 fn a_background_job_holding_the_output_does_not_delay_the_lookup() {
441 let directory = tempfile::tempdir().unwrap();
442 let pid_path = directory.path().join("child.pid");
443 let shell = fake_shell(
444 directory.path(),
445 &format!(
446 "sleep 60 & echo $! > '{}'; printf '\\n__HANDSHAKE_PATH__/fast__HANDSHAKE_END__\\n'",
447 pid_path.display()
448 ),
449 );
450
451 let started = Instant::now();
452 let path = login_shell_path(&shell, &AtomicBool::new(false));
453
454 assert_eq!(path, Some(OsString::from("/fast")));
455 assert!(started.elapsed() < Duration::from_secs(4));
456 FixtureProcess::read(&pid_path).assert_stopped();
457 }
458
459 #[test]
460 fn dropping_an_unused_login_path_stops_and_joins_its_shell() {
461 let directory = tempfile::tempdir().unwrap();
462 let pid_path = directory.path().join("shell.pid");
463 let shell = fake_shell(
464 directory.path(),
465 &format!("echo $$ > '{}'; exec sleep 60", pid_path.display()),
466 );
467 let path = LoginPath::spawn(shell);
468 let shell = FixtureProcess::read(&pid_path);
469 let started = Instant::now();
470 drop(path);
471 assert!(started.elapsed() < Duration::from_secs(2));
472 assert!(
473 !shell.is_running(),
474 "shutdown reaps the login shell before returning"
475 );
476 }
477
478 #[test]
479 fn locates_an_executable_and_reports_a_missing_one() {
480 let directory = tempfile::tempdir().expect("a directory should be available");
481 let binary = directory.path().join("handshake-test-harness");
482 std::fs::write(&binary, "#!/bin/sh\n").expect("binary should be written");
483 std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755))
484 .expect("permissions should be set");
485 let found = HarnessDefinition {
486 name: "Test",
487 binary: "handshake-test-harness",
488 reads_at_prefix_as_file: false,
489 prompt_option: None,
490 subcommand: None,
491 };
492 let missing = HarnessDefinition {
493 binary: "handshake-no-such-harness",
494 ..found
495 };
496 let path = Some(directory.path().as_os_str());
497
498 let located = locate_in(&found, None, path).expect("binary is on PATH");
499 let error = locate_in(&missing, None, path).expect_err("a missing binary is not found");
500
501 assert_eq!(located.program, binary);
502 assert!(error.to_string().contains("`handshake-no-such-harness`"));
503 }
504}