1use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8use std::sync::{Arc, Mutex};
9use std::time::Duration;
10
11use serde_json::{json, Value};
12use tracing::instrument::WithSubscriber;
13
14use crate::clock::{Clock, SystemClock};
15use crate::doctor::{
16 launcher_link_status, parse_source_file, windows_link_status, LinkStatus, SourceFile,
17};
18use crate::engine::{valid_session_id, Engine};
19use crate::error::{ErrorCode, ToolError};
20use crate::host::Host;
21use crate::liveness::{sentinel_alive, ProcessProbe, Sentinel};
22use crate::logging::{init_file_logging, LogHandle, LogScope};
23use crate::openapi::Operation;
24use crate::pairing::{self, claim_file, valid_pair_id, write_record_new, Record};
25use crate::tools::SessionCreateArgs;
26use crate::transport::{Lifecycle, Params, Transport, TransportError, WaitOutcome};
27
28pub mod posix;
29pub mod windows;
30
31pub use posix::{
32 quote_sh, select_terminal, system_parent_chain, tab_command, wrapper_script_posix, Terminal,
33 BOOT_ID_EXPR_POSIX, STARTTIME_EXPR_POSIX,
34};
35pub use windows::{
36 cmd_line, cmd_wrap, controller_command, controller_process, quote_ps, resolve_executable,
37 select_windows_shell, strip_verbatim, wrapper_script_ps1, wt_directory_arg, WindowsShell,
38};
39
40pub const NOT_OFFERED: &str = "not offered on the OpenCode host";
42
43pub const EXIT_NOTICE: &str = "The controller process that drove this session has exited. A human may be typing here now; treat further instructions as theirs. Do not resume unfinished controller work on your own.";
45
46#[derive(Debug, Clone, Default, PartialEq, Eq)]
47pub struct PairArgs {
48 pub directory: Option<PathBuf>,
49 pub model: Option<String>,
50 pub title: Option<String>,
51 pub controller: Option<String>,
52 pub no_tab: bool,
53 pub dry_run: bool,
54 pub controller_args: Vec<String>,
55}
56
57pub fn parse_pair_args(rest: &[String]) -> Result<PairArgs, ToolError> {
59 let bad = |msg: String| ToolError::new(ErrorCode::InvalidArgument, msg);
60 let mut args = PairArgs::default();
61 let mut it = rest.iter();
62 while let Some(w) = it.next() {
63 let (flag, inline) = match w.split_once('=') {
64 Some((f, v)) if f.starts_with("--") => (f, Some(v.to_string())),
65 _ => (w.as_str(), None),
66 };
67 let mut value = |name: &str| {
68 inline
69 .clone()
70 .or_else(|| it.next().cloned())
71 .ok_or_else(|| bad(format!("{name} needs a value")))
72 };
73 match flag {
74 "--" => {
75 args.controller_args = it.by_ref().cloned().collect();
76 break;
77 }
78 "--model" => args.model = Some(value("--model")?),
79 "--title" => args.title = Some(value("--title")?),
80 "--controller" => args.controller = Some(value("--controller")?),
81 "--no-tab" => args.no_tab = true,
82 "--dry-run" => args.dry_run = true,
83 f if f.starts_with('-') && f.len() > 1 => {
84 return Err(bad(format!("unknown option '{f}'")).hint(
85 "pair [directory] [--model m] [--title t] [--controller claude|qwen|agy] [--no-tab] [--dry-run] [-- <controller args>]",
86 ));
87 }
88 dir => {
89 if args.directory.replace(PathBuf::from(dir)).is_some() {
90 return Err(bad(format!("unexpected argument '{dir}'")));
91 }
92 }
93 }
94 }
95 Ok(args)
96}
97
98pub type SpawnAndWaitFn<'a> =
100 dyn Fn(&mut Command, &mut dyn FnMut(u32)) -> io::Result<std::process::ExitStatus> + 'a;
101
102pub struct LauncherCtx<'a> {
103 pub home: &'a Path,
105 pub bin_dir: &'a Path,
107 pub running_exe: &'a Path,
108 pub host: Host,
109 pub probe: &'a dyn ProcessProbe,
110 pub now_ms: &'a dyn Fn() -> i64,
111 pub path_has: &'a dyn Fn(&str) -> bool,
112 pub opencode_bin: Option<&'a Path>,
114 pub version: &'a str,
116 pub parent_chain: &'a dyn Fn() -> Vec<String>,
117 pub server_password: Option<&'a str>,
119 pub spawn_detached: &'a dyn Fn(&mut Command) -> io::Result<u32>,
121 pub spawn_and_wait: &'a SpawnAndWaitFn<'a>,
123 pub before_claim: Option<&'a dyn Fn()>,
125 pub out: &'a mut dyn Write,
126}
127
128pub struct WrapperInput<'a> {
131 pub home: &'a Path,
132 pub opencode_bin: &'a Path,
134 pub pair_id: &'a str,
135 pub generation: u32,
136 pub directory: &'a Path,
137 pub session_id: &'a str,
138 pub server_url: Option<&'a str>,
139 pub server_password: Option<&'a str>,
140 pub boot_id_expr: bool,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum WrapperKind {
147 Sh,
148 Ps1,
149}
150
151pub fn write_wrapper(path: &Path, script: &str, kind: WrapperKind) -> Result<bool, ToolError> {
154 let mut bytes = Vec::with_capacity(script.len() + 3);
155 if kind == WrapperKind::Ps1 {
156 bytes.extend_from_slice("\u{feff}".as_bytes());
157 }
158 bytes.extend_from_slice(script.as_bytes());
159 claim_file(path, &bytes, 0o700)
160}
161
162pub fn wrapper_path(home: &Path, pair_id: &str, generation: u32, windows: bool) -> PathBuf {
163 let ext = if windows { "ps1" } else { "sh" };
164 home.join(format!("pair.{pair_id}.g{generation}.wrapper.{ext}"))
165}
166
167fn pair_file(home: &Path, pair_id: &str, suffix: &str) -> PathBuf {
168 home.join(format!("pair.{pair_id}.{suffix}"))
169}
170
171fn invalid(msg: String) -> ToolError {
174 ToolError::new(ErrorCode::InvalidArgument, msg)
175}
176
177pub fn validate_launch_values(
181 directory: &Path,
182 session_id: Option<&str>,
183 pair_id: &str,
184 controller_args: &[String],
185 windows: bool,
186) -> Result<(), ToolError> {
187 if !valid_pair_id(pair_id) {
188 return Err(
189 invalid(format!("'{pair_id}' is not a pair id")).hint("pair ids match ^[0-9a-f]{12}$")
190 );
191 }
192 if let Some(s) = session_id {
193 if !valid_session_id(s) {
194 return Err(invalid("not a session id".to_string())
195 .hint("session ids match ^ses_[A-Za-z0-9]{26}$"));
196 }
197 }
198 let dir = directory.to_string_lossy();
199 if dir.contains(['\n', '\r', '\0']) {
200 return Err(invalid("the directory contains a newline".into()));
201 }
202 if windows && dir.contains(';') {
203 return Err(invalid(format!(
204 "the directory '{dir}' contains ';' (wt splits its command line on it)"
205 )));
206 }
207 if windows {
208 for a in controller_args {
209 if a.contains(['&', '|', '<', '>', '^', '"', '%', '\n', '\r']) {
210 return Err(invalid(format!(
211 "controller argument '{a}' contains a cmd.exe metacharacter (& | < > ^ \" %) or a newline"
212 ))
213 .hint("controller arguments travel through cmd.exe /c on Windows"));
214 }
215 }
216 }
217 Ok(())
218}
219
220pub fn controller_env(
224 session_id: &str,
225 directory: &Path,
226 server_url: Option<&str>,
227 pair_id: &str,
228) -> Vec<(String, String)> {
229 vec![
230 ("OPENCODE_CTL_SESSION".into(), session_id.into()),
231 (
232 "OPENCODE_CTL_DIR".into(),
233 directory.to_string_lossy().into_owned(),
234 ),
235 (
236 "OPENCODE_CTL_SERVER".into(),
237 server_url.unwrap_or_default().into(),
238 ),
239 ("OPENCODE_CTL_PAIR".into(), pair_id.into()),
240 ]
241}
242
243fn mint_pair_id() -> String {
244 uuid::Uuid::new_v4().simple().to_string()[..12].to_string()
245}
246
247fn utc_hhmm(ms: i64) -> String {
248 let minutes = ms.div_euclid(60_000).rem_euclid(1440);
249 format!("{:02}:{:02}", minutes / 60, minutes % 60)
250}
251
252fn resolve_directory(arg: Option<&Path>, cwd: &Path) -> Result<PathBuf, ToolError> {
253 let base = match arg {
254 Some(d) if d.is_absolute() => d.to_path_buf(),
255 Some(d) => cwd.join(d),
256 None => cwd.to_path_buf(),
257 };
258 let canon = std::fs::canonicalize(&base)
259 .map_err(|e| invalid(format!("directory {} is not usable: {e}", base.display())))?;
260 if !canon.is_dir() {
261 return Err(invalid(format!("{} is not a directory", base.display())));
262 }
263 if cfg!(windows) {
264 return Ok(PathBuf::from(strip_verbatim(&canon.to_string_lossy())));
265 }
266 Ok(canon)
267}
268
269fn resolve_controller(args: &PairArgs, host: Host) -> Result<String, ToolError> {
270 match args.controller.as_deref() {
271 Some(c @ ("claude" | "qwen" | "agy")) => Ok(c.to_string()),
272 Some(other) => Err(invalid(format!(
273 "unknown controller '{other}' (claude, qwen or agy)"
274 ))),
275 None => host
276 .controller()
277 .map(str::to_string)
278 .ok_or_else(|| invalid("no controller: pass --controller claude|qwen|agy".to_string())),
279 }
280}
281
282fn opencode_missing() -> ToolError {
283 ToolError::new(ErrorCode::OpencodeNotFound, "opencode executable not found")
284 .hint("install opencode or set OPENCODE_CTL_BIN")
285}
286
287fn say(ctx: &mut LauncherCtx, line: &str) {
288 let _ = writeln!(ctx.out, "{line}");
289}
290
291struct CaptureTransport {
296 body: Arc<Mutex<Option<Value>>>,
297}
298
299impl Transport for CaptureTransport {
300 async fn call(
301 &self,
302 _op: &Operation,
303 _params: &Params,
304 body: Option<&Value>,
305 _deadline: Duration,
306 ) -> Result<Value, TransportError> {
307 *self.body.lock().unwrap_or_else(|e| e.into_inner()) = body.cloned();
308 Ok(json!({"data": {"id": "ses_dryrundryrundryrundryrun00"}}))
309 }
310
311 async fn lifecycle(&self, _verb: Lifecycle) -> Result<String, TransportError> {
312 Ok(String::new())
313 }
314
315 async fn wait_idle(&self, _id: &str, _d: Duration) -> Result<WaitOutcome, TransportError> {
316 Ok(WaitOutcome::Idle)
317 }
318
319 async fn fetch_openapi(&self) -> Result<Value, TransportError> {
320 Err(TransportError {
321 code: ErrorCode::ServiceDown,
322 message: "dry run".into(),
323 hint: None,
324 details: None,
325 })
326 }
327
328 async fn cli_version(&self) -> Result<String, TransportError> {
329 Ok(String::new())
330 }
331}
332
333fn create_args(args: &PairArgs, directory: &Path, title: &str) -> SessionCreateArgs {
334 SessionCreateArgs {
335 title: Some(title.to_string()),
336 directory: Some(directory.to_string_lossy().into_owned()),
337 agent: None,
338 model: args.model.clone(),
339 permissions: None,
340 parent_id: None,
341 }
342}
343
344async fn dry_create_body<T: Transport, C: Clock>(
345 engine: &Engine<T, C>,
346 args: &PairArgs,
347 directory: &Path,
348 title: &str,
349) -> Result<Value, ToolError> {
350 let body = Arc::new(Mutex::new(None));
351 let capture = Engine::new(
352 engine.settings.clone(),
353 Vec::new(),
354 engine.host,
355 engine.env.clone(),
356 CaptureTransport { body: body.clone() },
357 SystemClock,
358 engine.probe.clone(),
359 );
360 capture
361 .session_create(create_args(args, directory, title))
362 .await?;
363 let sent = body.lock().unwrap_or_else(|e| e.into_inner()).clone();
364 sent.ok_or_else(|| ToolError::new(ErrorCode::CliError, "session.create sent no body"))
365}
366
367fn tab_argv_command(argv: &[String]) -> Command {
370 let mut cmd = Command::new(&argv[0]);
371 cmd.args(&argv[1..])
372 .stdin(Stdio::null())
373 .stdout(Stdio::null())
374 .stderr(Stdio::null());
375 cmd
376}
377
378fn is_missing_program(e: &io::Error) -> bool {
379 matches!(
380 e.kind(),
381 io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied
382 )
383}
384
385pub async fn pair<T: Transport, C: Clock>(
388 args: PairArgs,
389 ctx: &mut LauncherCtx<'_>,
390 engine: &Engine<T, C>,
391) -> Result<i32, ToolError> {
392 if !ctx.host.offers_launcher() {
393 return Err(ToolError::new(ErrorCode::RefusedOnHost, NOT_OFFERED));
394 }
395 if ctx.home != engine.env.home.as_path() {
396 return Err(ToolError::new(
397 ErrorCode::IoError,
398 "launcher home differs from the engine's home",
399 ));
400 }
401 let windows = cfg!(windows);
402 let pair_id = mint_pair_id();
403 let controller = resolve_controller(&args, ctx.host)?;
404 let directory = resolve_directory(args.directory.as_deref(), &engine.env.cwd)?;
405 validate_launch_values(&directory, None, &pair_id, &args.controller_args, windows)?;
406 let opencode_bin = ctx.opencode_bin.ok_or_else(opencode_missing)?.to_path_buf();
407 let basename = directory
408 .file_name()
409 .map_or_else(|| "root".to_string(), |n| n.to_string_lossy().into_owned());
410 let title = args
411 .title
412 .clone()
413 .unwrap_or_else(|| format!("pair {basename} {}Z", utc_hhmm((ctx.now_ms)())));
414 if args.dry_run {
415 return dry_run(&args, ctx, engine, &directory, &controller, &title).await;
416 }
417 let _log = open_pair_log(ctx.home, &pair_id);
418 run(
419 &args,
420 ctx,
421 engine,
422 &pair_id,
423 &directory,
424 &controller,
425 &title,
426 &opencode_bin,
427 )
428 .with_current_subscriber()
429 .await
430}
431
432fn open_pair_log(home: &Path, pair_id: &str) -> Option<LogHandle> {
435 let debug = crate::cli::env_nonempty("OPENCODE_CTL_LOG")
436 .is_some_and(|v| v.eq_ignore_ascii_case("debug"));
437 let log_path = home.join("logs").join(format!("pair-{pair_id}.log"));
438 match init_file_logging(&log_path, debug, LogScope::Scoped) {
439 Ok(h) => Some(h),
440 Err(e) => {
441 eprintln!("opencode-ctl: cannot open {}: {e}", log_path.display());
442 None
443 }
444 }
445}
446
447fn tab_plan(ctx: &LauncherCtx, args: &PairArgs) -> Option<Terminal> {
448 if args.no_tab {
449 return None;
450 }
451 let chain = (ctx.parent_chain)();
452 select_terminal(&chain, ctx.path_has, cfg!(windows))
453}
454
455async fn dry_run<T: Transport, C: Clock>(
456 args: &PairArgs,
457 ctx: &mut LauncherCtx<'_>,
458 engine: &Engine<T, C>,
459 directory: &Path,
460 controller: &str,
461 title: &str,
462) -> Result<i32, ToolError> {
463 let windows = cfg!(windows);
464 let body = dry_create_body(engine, args, directory, title).await?;
465 let mut lines = Vec::new();
466 if engine.settings.server_url.is_none() {
467 lines.push("would run: opencode service start".to_string());
468 }
469 lines.push("would run: gc".to_string());
470 lines.push(format!("would call: session.create {body}"));
471 if let Some(t) = tab_plan(ctx, args) {
472 let script = wrapper_path(ctx.home, "<pair-id>", 1, windows);
473 let shell = select_windows_shell(ctx.path_has);
474 let argv = tab_command(t, &script, directory, shell);
475 lines.push(format!("would run: {}", argv.join(" ")));
476 }
477 let argv = controller_command(
478 controller,
479 &args.controller_args,
480 windows,
481 crate::cli::env_nonempty("PATH").as_deref(),
482 crate::cli::env_nonempty("PATHEXT").as_deref(),
483 )
484 .ok_or_else(|| controller_missing(controller))?;
485 lines.push(format!("would run: {}", argv.join(" ")));
486 for l in lines {
487 say(ctx, &l);
488 }
489 Ok(0)
490}
491
492fn controller_missing(controller: &str) -> ToolError {
493 ToolError::new(
494 ErrorCode::ControllerNotFound,
495 format!("controller executable '{controller}' not found"),
496 )
497 .hint("install it, or pass --controller claude|qwen|agy")
498}
499
500async fn rollback<T: Transport, C: Clock>(
502 engine: &Engine<T, C>,
503 home: &Path,
504 pair_id: &str,
505 session_id: &str,
506) {
507 tracing::warn!("pair {pair_id}: rolling back session {session_id}");
508 let params = vec![("sessionID".to_string(), session_id.to_string())];
509 if let Err(e) = engine.call_json("session.remove", params, None).await {
510 tracing::warn!("pair {pair_id}: session.remove failed: {e}");
511 }
512 if let Err(e) = pairing::delete_pair_files(home, pair_id) {
513 tracing::warn!("pair {pair_id}: removing the pair files failed: {e}");
514 }
515}
516
517struct Staged {
518 terminal: Option<Terminal>,
519}
520
521#[allow(clippy::too_many_arguments)]
522async fn run<T: Transport, C: Clock>(
523 args: &PairArgs,
524 ctx: &mut LauncherCtx<'_>,
525 engine: &Engine<T, C>,
526 pair_id: &str,
527 directory: &Path,
528 controller: &str,
529 title: &str,
530 opencode_bin: &Path,
531) -> Result<i32, ToolError> {
532 let home = ctx.home;
533 tracing::info!("pair {pair_id}: directory {}", directory.display());
534 if engine.settings.server_url.is_none() {
535 let url = engine.lifecycle(Lifecycle::Start).await?;
536 tracing::info!("pair {pair_id}: service started at {url}");
537 } else {
538 tracing::info!("pair {pair_id}: service start skipped (server_url is set)");
539 }
540 let report = crate::gc::gc(engine, ctx.probe, (ctx.now_ms)(), None).await;
541 tracing::info!(
542 "gc: swept {}, pairs deleted {}, dead-seen written {}, cleared {}, skipped {}",
543 report.swept.len(),
544 report.pairs_deleted.len(),
545 report.dead_seen_written.len(),
546 report.dead_seen_cleared.len(),
547 report.skipped_service_down
548 );
549
550 let created = engine
551 .session_create(create_args(args, directory, title))
552 .await?;
553 let session_id = created
554 .get("session_id")
555 .and_then(Value::as_str)
556 .unwrap_or_default()
557 .to_string();
558 tracing::info!("pair {pair_id}: session created {session_id}");
559
560 let staged = stage(
561 args,
562 ctx,
563 pair_id,
564 directory,
565 controller,
566 &session_id,
567 opencode_bin,
568 engine.settings.server_url.as_deref(),
569 );
570 let staged = match staged {
571 Ok(s) => s,
572 Err(e) => {
573 tracing::warn!("pair {pair_id}: {e}");
574 rollback(engine, home, pair_id, &session_id).await;
575 return Err(e);
576 }
577 };
578 let server_url = engine.settings.server_url.as_deref();
579 match staged.terminal {
580 Some(t) => {
581 say(ctx, &format!("tui: {}", t.program()));
582 }
583 None => {
584 let why = if args.no_tab {
585 "--no-tab"
586 } else {
587 "no terminal found"
588 };
589 say(
590 ctx,
591 &format!("tui: none ({why}); open one later with: opencode-pair attach {pair_id}"),
592 );
593 }
594 }
595
596 step5(
597 ctx,
598 engine,
599 pair_id,
600 &session_id,
601 directory,
602 controller,
603 &args.controller_args,
604 server_url,
605 None,
606 )
607 .await
608}
609
610#[allow(clippy::too_many_arguments)]
613async fn step5<T: Transport, C: Clock>(
614 ctx: &mut LauncherCtx<'_>,
615 engine: &Engine<T, C>,
616 pair_id: &str,
617 session_id: &str,
618 directory: &Path,
619 controller: &str,
620 controller_args: &[String],
621 server_url: Option<&str>,
622 lock: Option<&Path>,
623) -> Result<i32, ToolError> {
624 let windows = cfg!(windows);
625 let home = ctx.home;
626 let argv = controller_command(
627 controller,
628 controller_args,
629 windows,
630 crate::cli::env_nonempty("PATH").as_deref(),
631 crate::cli::env_nonempty("PATHEXT").as_deref(),
632 );
633 let Some(argv) = argv else {
634 return Err(controller_unavailable(ctx, pair_id, controller, None));
635 };
636 let mut cmd = controller_process(&argv);
637 cmd.envs(controller_env(session_id, directory, server_url, pair_id));
638 tracing::info!("pair {pair_id}: starting controller {controller}");
639 let probe = ctx.probe;
640 let mut bind = |pid: u32| {
641 if bind_controller(home, pair_id, probe, pid) {
642 if let Some(l) = lock {
643 let _ = std::fs::remove_file(l);
644 }
645 }
646 };
647 let status = match (ctx.spawn_and_wait)(&mut cmd, &mut bind) {
648 Ok(s) => s,
649 Err(e) => return Err(controller_unavailable(ctx, pair_id, controller, Some(e))),
650 };
651 tracing::info!("pair {pair_id}: controller exited ({status})");
652 let exited = (ctx.now_ms)().to_string();
653 match claim_file(
654 &pair_file(home, pair_id, "claude-exited"),
655 exited.as_bytes(),
656 0o600,
657 ) {
658 Ok(_) => {}
659 Err(e) => tracing::warn!("pair {pair_id}: claude-exited not written: {e}"),
660 }
661 post_exit_notice(engine, pair_id, session_id).await;
662 print_closing(ctx, pair_id, session_id, directory, server_url);
663 Ok(status.code().unwrap_or(1))
664}
665
666fn controller_unavailable(
667 ctx: &mut LauncherCtx,
668 pair_id: &str,
669 controller: &str,
670 cause: Option<io::Error>,
671) -> ToolError {
672 let attach = format!("opencode-pair attach {pair_id}");
673 let resume = format!("opencode-pair resume {pair_id}");
674 say(
675 ctx,
676 &format!(
677 "worker session is ready; attach with `{attach}`, start a controller with `{resume}`"
678 ),
679 );
680 match cause {
681 Some(e) if !is_missing_program(&e) => {
682 tracing::warn!("pair {pair_id}: controller spawn failed: {e}");
683 ToolError::new(
684 ErrorCode::IoError,
685 format!("controller '{controller}' could not be started: {e}"),
686 )
687 }
688 _ => {
689 tracing::warn!("pair {pair_id}: controller {controller} not found");
690 controller_missing(controller)
691 }
692 }
693}
694
695#[allow(clippy::too_many_arguments)]
698fn stage(
699 args: &PairArgs,
700 ctx: &mut LauncherCtx<'_>,
701 pair_id: &str,
702 directory: &Path,
703 controller: &str,
704 session_id: &str,
705 opencode_bin: &Path,
706 server_url: Option<&str>,
707) -> Result<Staged, ToolError> {
708 let windows = cfg!(windows);
709 let home = ctx.home;
710 validate_launch_values(
711 directory,
712 Some(session_id),
713 pair_id,
714 &args.controller_args,
715 windows,
716 )?;
717 let terminal = tab_plan(ctx, args);
718 let record = Record {
719 pair_id: pair_id.to_string(),
720 session_id: session_id.to_string(),
721 directory: directory.to_path_buf(),
722 server_url: server_url.map(str::to_string),
723 created: (ctx.now_ms)(),
724 controller: Some(controller.to_string()),
725 generation: 1,
726 tab: terminal.is_some(),
727 };
728 write_record_new(home, &record)?;
729 tracing::info!("pair {pair_id}: record written (tab {})", record.tab);
730 let Some(t) = terminal else {
731 return Ok(Staged { terminal });
732 };
733 let input = WrapperInput {
734 home,
735 opencode_bin,
736 pair_id,
737 generation: 1,
738 directory,
739 session_id,
740 server_url,
741 server_password: ctx.server_password,
742 boot_id_expr: true,
743 };
744 let (script, kind) = wrapper_text(&input, windows);
745 let path = wrapper_path(home, pair_id, 1, windows);
746 if !write_wrapper(&path, &script, kind)? {
747 return Err(ToolError::new(
748 ErrorCode::IoError,
749 format!("{} already exists", path.display()),
750 ));
751 }
752 tracing::info!("pair {pair_id}: wrapper written {}", path.display());
753 let pid = open_tab(ctx, t, &path, directory)?;
754 tracing::info!("pair {pair_id}: tab spawned ({}, pid {pid})", t.program());
755 Ok(Staged { terminal })
756}
757
758fn wrapper_text(input: &WrapperInput, windows: bool) -> (String, WrapperKind) {
759 if windows {
760 (wrapper_script_ps1(input), WrapperKind::Ps1)
761 } else {
762 (wrapper_script_posix(input), WrapperKind::Sh)
763 }
764}
765
766fn open_tab(
768 ctx: &LauncherCtx,
769 t: Terminal,
770 script: &Path,
771 directory: &Path,
772) -> Result<u32, ToolError> {
773 let shell = select_windows_shell(ctx.path_has);
774 let argv = tab_command(t, script, directory, shell);
775 let mut cmd = tab_argv_command(&argv);
776 (ctx.spawn_detached)(&mut cmd).map_err(|e| {
777 ToolError::new(
778 ErrorCode::IoError,
779 format!("cannot open a {} tab: {e}", t.program()),
780 )
781 })
782}
783
784fn bind_controller(home: &Path, pair_id: &str, probe: &dyn ProcessProbe, pid: u32) -> bool {
786 let Some(starttime) = probe.starttime(pid) else {
787 tracing::warn!("pair {pair_id}: cannot read the start time of controller pid {pid}");
788 return false;
789 };
790 let sentinel = Sentinel {
791 pid,
792 boot_id: probe.boot_id(),
793 starttime,
794 };
795 match pairing::write_sentinel(&pair_file(home, pair_id, "claude-pid"), &sentinel) {
796 Ok(()) => {
797 tracing::info!("pair {pair_id}: controller started, claude-pid {sentinel}");
798 true
799 }
800 Err(e) => {
801 tracing::warn!("pair {pair_id}: claude-pid not written: {e}");
802 false
803 }
804 }
805}
806
807async fn post_exit_notice<T: Transport, C: Clock>(
810 engine: &Engine<T, C>,
811 pair_id: &str,
812 session_id: &str,
813) {
814 let body = json!({"text": EXIT_NOTICE, "resume": false});
815 for attempt in 0..2 {
816 let params = vec![("sessionID".to_string(), session_id.to_string())];
817 match engine
818 .call_json("session.synthetic", params, Some(body.clone()))
819 .await
820 {
821 Ok(_) => {
822 tracing::info!("pair {pair_id}: exit notice posted");
823 return;
824 }
825 Err(e)
826 if attempt == 0
827 && e.code == ErrorCode::Conflict
828 && e.message.starts_with("Synthetic input ID conflicts") =>
829 {
830 tracing::warn!("pair {pair_id}: exit notice id conflict, retrying once");
831 }
832 Err(e) => {
833 tracing::warn!("pair {pair_id}: exit notice not posted: {e}");
834 return;
835 }
836 }
837 }
838}
839
840fn print_closing(
841 ctx: &mut LauncherCtx,
842 pair_id: &str,
843 session_id: &str,
844 directory: &Path,
845 server_url: Option<&str>,
846) {
847 say(
848 ctx,
849 &format!(
850 "worker session {session_id} is still running; attach with `opencode-pair attach {pair_id}`, restart the controller with `opencode-pair resume {pair_id}`"
851 ),
852 );
853 for (name, value) in controller_env(session_id, directory, server_url, pair_id) {
854 let line = if cfg!(windows) {
855 format!("$env:{name} = {}", quote_ps(&value))
856 } else {
857 format!("export {name}={}", quote_sh(&value))
858 };
859 say(ctx, &line);
860 }
861}
862
863fn launcher_guard<T: Transport, C: Clock>(
866 ctx: &LauncherCtx,
867 engine: &Engine<T, C>,
868) -> Result<(), ToolError> {
869 if !ctx.host.offers_launcher() {
870 return Err(ToolError::new(ErrorCode::RefusedOnHost, NOT_OFFERED));
871 }
872 if ctx.home != engine.env.home.as_path() {
873 return Err(ToolError::new(
874 ErrorCode::IoError,
875 "launcher home differs from the engine's home",
876 ));
877 }
878 Ok(())
879}
880
881fn check_pair_id(id: &str) -> Result<(), ToolError> {
882 if valid_pair_id(id) {
883 Ok(())
884 } else {
885 Err(invalid(format!(
886 "'{}' is not a pair id",
887 crate::compact::redact_text(id)
888 ))
889 .hint("pair ids match ^[0-9a-f]{12}$"))
890 }
891}
892
893pub fn parse_resume_args(
895 rest: &[String],
896) -> Result<(String, Option<String>, Vec<String>), ToolError> {
897 let usage = "resume <pair-id> [--controller claude|qwen|agy] [-- <controller args>]";
898 let (mut id, mut controller) = (None::<String>, None::<String>);
899 let mut it = rest.iter();
900 let mut args = Vec::new();
901 while let Some(w) = it.next() {
902 match w.as_str() {
903 "--" => {
904 args = it.by_ref().cloned().collect();
905 break;
906 }
907 "--controller" => {
908 controller = Some(
909 it.next()
910 .cloned()
911 .ok_or_else(|| invalid("--controller needs a value".into()).hint(usage))?,
912 );
913 }
914 f if f.starts_with("--controller=") => {
915 controller = Some(f["--controller=".len()..].to_string());
916 }
917 f if f.starts_with('-') && f.len() > 1 => {
918 return Err(invalid(format!("unknown option '{f}'")).hint(usage));
919 }
920 other => {
921 if id.replace(other.to_string()).is_some() {
922 return Err(invalid(format!("unexpected argument '{other}'")).hint(usage));
923 }
924 }
925 }
926 }
927 let id = id.ok_or_else(|| invalid("resume needs a pair id".into()).hint(usage))?;
928 Ok((id, controller, args))
929}
930
931pub fn parse_attach_args(rest: &[String]) -> Result<String, ToolError> {
933 match rest {
934 [t] => Ok(t.clone()),
935 _ => Err(invalid("attach takes one session id or pair id".into())
936 .hint("attach <ses_... | pair-id>")),
937 }
938}
939
940struct LockGuard(PathBuf);
942
943impl Drop for LockGuard {
944 fn drop(&mut self) {
945 let _ = std::fs::remove_file(&self.0);
946 }
947}
948
949fn remove_if_present(path: &Path) -> Result<(), ToolError> {
950 match std::fs::remove_file(path) {
951 Ok(()) => Ok(()),
952 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
953 Err(e) => Err(e.into()),
954 }
955}
956
957fn live_controller(pair_id: &str) -> ToolError {
958 ToolError::new(
959 ErrorCode::LauncherConflict,
960 format!("pair {pair_id} already has a live controller"),
961 )
962 .hint("two controllers on one worker is not supported; end the running one first")
963}
964
965fn pick_controller(
967 flag: Option<&str>,
968 record: Option<&str>,
969 host: Host,
970) -> Result<String, ToolError> {
971 let args = PairArgs {
972 controller: flag.or(record).map(str::to_string),
973 ..PairArgs::default()
974 };
975 resolve_controller(&args, host)
976}
977
978pub async fn resume<T: Transport, C: Clock>(
980 pair_id: &str,
981 controller_args: &[String],
982 ctx: &mut LauncherCtx<'_>,
983 engine: &Engine<T, C>,
984) -> Result<i32, ToolError> {
985 resume_with(pair_id, None, controller_args, ctx, engine).await
986}
987
988pub async fn resume_with<T: Transport, C: Clock>(
990 pair_id: &str,
991 controller_flag: Option<&str>,
992 controller_args: &[String],
993 ctx: &mut LauncherCtx<'_>,
994 engine: &Engine<T, C>,
995) -> Result<i32, ToolError> {
996 launcher_guard(ctx, engine)?;
997 check_pair_id(pair_id)?;
998 let record = pairing::read_record(ctx.home, pair_id)?.ok_or_else(|| {
999 ToolError::new(
1000 ErrorCode::PairNotFound,
1001 format!("no pair record for {pair_id}"),
1002 )
1003 })?;
1004 let controller = pick_controller(controller_flag, record.controller.as_deref(), ctx.host)?;
1005 validate_launch_values(
1006 &record.directory,
1007 Some(&record.session_id),
1008 pair_id,
1009 controller_args,
1010 cfg!(windows),
1011 )?;
1012 let _log = open_pair_log(ctx.home, pair_id);
1013 resume_run(pair_id, &record, &controller, controller_args, ctx, engine)
1014 .with_current_subscriber()
1015 .await
1016}
1017
1018async fn resume_run<T: Transport, C: Clock>(
1019 pair_id: &str,
1020 record: &Record,
1021 controller: &str,
1022 controller_args: &[String],
1023 ctx: &mut LauncherCtx<'_>,
1024 engine: &Engine<T, C>,
1025) -> Result<i32, ToolError> {
1026 let home = ctx.home;
1027 tracing::info!("resume {pair_id}: controller {controller}");
1028 let params = vec![("sessionID".to_string(), record.session_id.clone())];
1029 engine.call_json("session.get", params, None).await?;
1030
1031 let pid_file = pair_file(home, pair_id, "claude-pid");
1032 if pairing::read_sentinel(&pid_file).is_some_and(|s| sentinel_alive(&s, ctx.probe)) {
1033 return Err(live_controller(pair_id));
1034 }
1035 if controller_command(
1037 controller,
1038 controller_args,
1039 cfg!(windows),
1040 crate::cli::env_nonempty("PATH").as_deref(),
1041 crate::cli::env_nonempty("PATHEXT").as_deref(),
1042 )
1043 .is_none()
1044 {
1045 return Err(controller_unavailable(ctx, pair_id, controller, None));
1046 }
1047
1048 let lock_path = pair_file(home, pair_id, "resume-lock");
1049 if let Some(owner) = pairing::read_sentinel(&lock_path) {
1052 if !sentinel_alive(&owner, ctx.probe) {
1053 if let Some(f) = ctx.before_claim {
1054 f(); }
1056 if pairing::read_sentinel(&lock_path).as_ref() == Some(&owner) {
1061 tracing::info!("resume {pair_id}: removing the dead lock of {owner}");
1062 remove_if_present(&lock_path)?;
1063 }
1064 }
1065 }
1066 let me = std::process::id();
1067 let own = Sentinel {
1068 pid: me,
1069 boot_id: ctx.probe.boot_id(),
1070 starttime: ctx.probe.starttime(me).ok_or_else(|| {
1071 ToolError::new(
1072 ErrorCode::IoError,
1073 format!("cannot read the start time of process {me}"),
1074 )
1075 })?,
1076 };
1077 if let Some(f) = ctx.before_claim {
1078 f(); }
1080 if !claim_file(&lock_path, own.to_string().as_bytes(), 0o600)? {
1081 return Err(ToolError::new(
1082 ErrorCode::LauncherConflict,
1083 format!("another resume of pair {pair_id} is in progress"),
1084 )
1085 .hint("wait for it, or remove pair.<id>.resume-lock when its owner is gone"));
1086 }
1087 let _lock = LockGuard(lock_path.clone());
1088 if pairing::read_sentinel(&pid_file).is_some_and(|s| sentinel_alive(&s, ctx.probe)) {
1091 return Err(live_controller(pair_id));
1092 }
1093 remove_if_present(&pair_file(home, pair_id, "claude-exited"))?;
1094 remove_if_present(&pair_file(home, pair_id, "dead-seen"))?;
1095 step5(
1096 ctx,
1097 engine,
1098 pair_id,
1099 &record.session_id,
1100 &record.directory,
1101 controller,
1102 controller_args,
1103 record.server_url.as_deref(),
1104 Some(&lock_path),
1105 )
1106 .await
1107}
1108
1109fn delete_older_generations(home: &Path, pair_id: &str, n: u32) {
1111 let prefix = format!("pair.{pair_id}.g");
1112 let Ok(entries) = std::fs::read_dir(home) else {
1113 return;
1114 };
1115 for entry in entries.flatten() {
1116 let name = entry.file_name();
1117 let Some(rest) = name.to_str().and_then(|n| n.strip_prefix(&prefix)) else {
1118 continue;
1119 };
1120 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
1121 if rest[digits.len()..].starts_with('.') && digits.parse::<u32>().is_ok_and(|k| k < n) {
1122 if let Err(e) = remove_if_present(&entry.path()) {
1123 tracing::warn!(
1124 "attach {pair_id}: cannot remove {}: {e}",
1125 entry.path().display()
1126 );
1127 }
1128 }
1129 }
1130}
1131
1132fn announce_tab(ctx: &mut LauncherCtx, terminal: Option<Terminal>, pair_id: &str, wrapper: &Path) {
1133 match terminal {
1134 Some(t) => say(ctx, &format!("tui: {}", t.program())),
1135 None => say(
1136 ctx,
1137 &format!(
1138 "tui: none (no terminal found); run {} by hand to open the worker for pair {pair_id}",
1139 wrapper.display()
1140 ),
1141 ),
1142 }
1143}
1144
1145pub async fn attach<T: Transport, C: Clock>(
1147 target: &str,
1148 ctx: &mut LauncherCtx<'_>,
1149 engine: &Engine<T, C>,
1150) -> Result<i32, ToolError> {
1151 launcher_guard(ctx, engine)?;
1152 if valid_pair_id(target) {
1153 attach_pair(target, ctx).await
1154 } else if target.starts_with("ses_") {
1155 if !valid_session_id(target) {
1156 return Err(
1157 invalid("not a session id".into()).hint("session ids match ^ses_[A-Za-z0-9]{26}$")
1158 );
1159 }
1160 attach_session(target, ctx, engine).await
1161 } else {
1162 Err(invalid(format!(
1163 "'{}' is neither a pair id nor a session id",
1164 crate::compact::redact_text(target)
1165 ))
1166 .hint("attach <ses_... | 12-hex pair id>"))
1167 }
1168}
1169
1170async fn attach_pair(pair_id: &str, ctx: &mut LauncherCtx<'_>) -> Result<i32, ToolError> {
1171 let record = pairing::read_record(ctx.home, pair_id)?.ok_or_else(|| {
1172 ToolError::new(
1173 ErrorCode::PairNotFound,
1174 format!("no pair record for {pair_id}"),
1175 )
1176 })?;
1177 let windows = cfg!(windows);
1178 validate_launch_values(
1179 &record.directory,
1180 Some(&record.session_id),
1181 pair_id,
1182 &[],
1183 windows,
1184 )?;
1185 let opencode_bin = ctx.opencode_bin.ok_or_else(opencode_missing)?.to_path_buf();
1186 let _log = open_pair_log(ctx.home, pair_id);
1187 attach_pair_run(pair_id, &record, &opencode_bin, ctx)
1188 .with_current_subscriber()
1189 .await
1190}
1191
1192async fn attach_pair_run(
1193 pair_id: &str,
1194 record: &Record,
1195 opencode_bin: &Path,
1196 ctx: &mut LauncherCtx<'_>,
1197) -> Result<i32, ToolError> {
1198 let windows = cfg!(windows);
1199 let home = ctx.home;
1200 let n = record.generation;
1201 let terminal = tab_plan(ctx, &PairArgs::default());
1202 tracing::info!("attach {pair_id}: generation {n}, terminal {terminal:?}");
1203 if let Some(f) = ctx.before_claim {
1204 f();
1205 }
1206 let mut claimed = None;
1209 for g in (1..=5).map(|k| n.saturating_add(k)) {
1210 let input = WrapperInput {
1211 home,
1212 opencode_bin,
1213 pair_id,
1214 generation: g,
1215 directory: &record.directory,
1216 session_id: &record.session_id,
1217 server_url: record.server_url.as_deref(),
1218 server_password: ctx.server_password,
1219 boot_id_expr: true,
1220 };
1221 let (script, kind) = wrapper_text(&input, windows);
1222 let path = wrapper_path(home, pair_id, g, windows);
1223 if write_wrapper(&path, &script, kind)? {
1224 claimed = Some((g, path));
1225 break;
1226 }
1227 tracing::info!("attach {pair_id}: generation {g} is taken");
1228 }
1229 let Some((g, wrapper)) = claimed else {
1230 return Err(ToolError::new(
1231 ErrorCode::LauncherConflict,
1232 format!(
1233 "generations {}..{} of pair {pair_id} are all taken",
1234 n + 1,
1235 n + 5
1236 ),
1237 )
1238 .hint("another attach is running; retry in a moment"));
1239 };
1240 tracing::info!("attach {pair_id}: claimed generation {g}");
1241 if let Err(e) = pairing::rewrite_record(home, pair_id, g, terminal.is_some(), None) {
1242 tracing::warn!("attach {pair_id}: record not rewritten: {e}");
1243 let _ = std::fs::remove_file(&wrapper);
1244 return Err(e);
1245 }
1246 if let Err(e) = remove_if_present(&pair_file(home, pair_id, "dead-seen")) {
1247 tracing::warn!("attach {pair_id}: dead-seen not removed: {e}");
1248 }
1249 delete_older_generations(home, pair_id, n);
1250 if let Some(t) = terminal {
1251 let pid = open_tab(ctx, t, &wrapper, &record.directory)?;
1252 tracing::info!("attach {pair_id}: tab spawned ({}, pid {pid})", t.program());
1253 }
1254 announce_tab(ctx, terminal, pair_id, &wrapper);
1255 say(ctx, &format!("pair {pair_id}: generation {g}"));
1256 Ok(0)
1257}
1258
1259async fn attach_session<T: Transport, C: Clock>(
1260 session_id: &str,
1261 ctx: &mut LauncherCtx<'_>,
1262 engine: &Engine<T, C>,
1263) -> Result<i32, ToolError> {
1264 let windows = cfg!(windows);
1265 let params = vec![("sessionID".to_string(), session_id.to_string())];
1266 let (data, _, _) = engine.call_json("session.get", params, None).await?;
1267 let directory = data
1268 .pointer("/location/directory")
1269 .and_then(Value::as_str)
1270 .filter(|d| !d.is_empty())
1271 .map(PathBuf::from)
1272 .ok_or_else(|| {
1273 ToolError::new(
1274 ErrorCode::IoError,
1275 format!("session {session_id} reports no directory"),
1276 )
1277 })?;
1278 let pair_id = mint_pair_id();
1279 validate_launch_values(&directory, Some(session_id), &pair_id, &[], windows)?;
1280 let opencode_bin = ctx.opencode_bin.ok_or_else(opencode_missing)?.to_path_buf();
1281 let _log = open_pair_log(ctx.home, &pair_id);
1282 let _scope = tracing::info_span!("attach").entered();
1283 tracing::info!("attach {pair_id}: new record for session {session_id}");
1284 let staged = stage_attached(
1285 ctx,
1286 &pair_id,
1287 session_id,
1288 &directory,
1289 &opencode_bin,
1290 engine.settings.server_url.as_deref(),
1291 );
1292 match staged {
1293 Ok((terminal, wrapper)) => {
1294 announce_tab(ctx, terminal, &pair_id, &wrapper);
1295 say(
1296 ctx,
1297 &format!(
1298 "pair {pair_id}: new record for session {session_id}; start a controller with `opencode-pair resume {pair_id}`"
1299 ),
1300 );
1301 Ok(0)
1302 }
1303 Err(e) => {
1304 tracing::warn!("attach {pair_id}: {e}");
1305 if let Err(d) = pairing::delete_pair_files(ctx.home, &pair_id) {
1307 tracing::warn!("attach {pair_id}: removing the pair files failed: {d}");
1308 }
1309 Err(e)
1310 }
1311 }
1312}
1313
1314fn stage_attached(
1317 ctx: &mut LauncherCtx<'_>,
1318 pair_id: &str,
1319 session_id: &str,
1320 directory: &Path,
1321 opencode_bin: &Path,
1322 server_url: Option<&str>,
1323) -> Result<(Option<Terminal>, PathBuf), ToolError> {
1324 let windows = cfg!(windows);
1325 let home = ctx.home;
1326 let terminal = tab_plan(ctx, &PairArgs::default());
1327 write_record_new(
1328 home,
1329 &Record {
1330 pair_id: pair_id.to_string(),
1331 session_id: session_id.to_string(),
1332 directory: directory.to_path_buf(),
1333 server_url: server_url.map(str::to_string),
1334 created: (ctx.now_ms)(),
1335 controller: None,
1336 generation: 1,
1337 tab: terminal.is_some(),
1338 },
1339 )?;
1340 let input = WrapperInput {
1341 home,
1342 opencode_bin,
1343 pair_id,
1344 generation: 1,
1345 directory,
1346 session_id,
1347 server_url,
1348 server_password: ctx.server_password,
1349 boot_id_expr: true,
1350 };
1351 let (script, kind) = wrapper_text(&input, windows);
1352 let path = wrapper_path(home, pair_id, 1, windows);
1353 if !write_wrapper(&path, &script, kind)? {
1354 return Err(ToolError::new(
1355 ErrorCode::IoError,
1356 format!("{} already exists", path.display()),
1357 ));
1358 }
1359 if let Some(t) = terminal {
1360 let pid = open_tab(ctx, t, &path, directory)?;
1361 tracing::info!("attach {pair_id}: tab spawned ({}, pid {pid})", t.program());
1362 }
1363 Ok((terminal, path))
1364}
1365
1366fn conflict(msg: String, hint: &str) -> ToolError {
1369 ToolError::new(ErrorCode::LauncherConflict, msg).hint(hint)
1370}
1371
1372fn ensure_bin_dir(dir: &Path) -> Result<(), ToolError> {
1373 #[cfg(unix)]
1374 {
1375 use std::os::unix::fs::DirBuilderExt;
1376 std::fs::DirBuilder::new()
1377 .recursive(true)
1378 .mode(0o700)
1379 .create(dir)?;
1380 }
1381 #[cfg(not(unix))]
1382 std::fs::create_dir_all(dir)?;
1383 Ok(())
1384}
1385
1386#[cfg(unix)]
1387fn replace_with_symlink(target: &Path, link: &Path) -> Result<(), ToolError> {
1388 let tmp = link.with_file_name(format!(".opencode-pair.tmp-{}", uuid::Uuid::new_v4()));
1389 std::os::unix::fs::symlink(target, &tmp)?;
1390 std::fs::rename(&tmp, link).map_err(|e| {
1391 let _ = std::fs::remove_file(&tmp);
1392 ToolError::from(e)
1393 })
1394}
1395
1396#[cfg(not(unix))]
1397fn replace_with_symlink(_target: &Path, _link: &Path) -> Result<(), ToolError> {
1398 Err(ToolError::new(
1399 ErrorCode::IoError,
1400 "symlinks are not used on this platform",
1401 ))
1402}
1403
1404fn install_windows_copy(dir: &Path, exe: &Path, version: &str) -> Result<(), ToolError> {
1407 let copy = dir.join("opencode-pair.exe");
1408 let old = dir.join("opencode-pair.exe.old");
1409 let moved = copy.exists();
1410 if moved {
1411 let _ = std::fs::remove_file(&old);
1412 std::fs::rename(©, &old)?;
1413 }
1414 if let Err(e) = std::fs::copy(exe, ©) {
1415 if moved {
1416 let _ = std::fs::rename(&old, ©);
1417 }
1418 return Err(e.into());
1419 }
1420 let source = format!("path={}\nversion={version}\n", exe.display());
1421 std::fs::write(dir.join("opencode-pair.exe.source"), source)?;
1422 Ok(())
1423}
1424
1425fn same_bytes(a: &Path, b: &Path) -> bool {
1426 match (std::fs::read(a), std::fs::read(b)) {
1427 (Ok(x), Ok(y)) => x == y,
1428 _ => false,
1429 }
1430}
1431
1432pub fn install_launcher(ctx: &mut LauncherCtx<'_>, force: bool) -> Result<(), ToolError> {
1433 install_launcher_as(ctx, force, cfg!(windows))
1434}
1435
1436pub fn install_launcher_as(
1438 ctx: &mut LauncherCtx<'_>,
1439 force: bool,
1440 windows: bool,
1441) -> Result<(), ToolError> {
1442 if !ctx.host.offers_launcher() {
1443 return Err(ToolError::new(ErrorCode::RefusedOnHost, NOT_OFFERED));
1444 }
1445 let (dir, exe) = (ctx.bin_dir, ctx.running_exe);
1446 ensure_bin_dir(dir)?;
1447 let link = dir.join(if windows {
1448 "opencode-pair.exe"
1449 } else {
1450 "opencode-pair"
1451 });
1452 let status = if windows {
1453 let source: Option<SourceFile> =
1454 std::fs::read_to_string(dir.join("opencode-pair.exe.source"))
1455 .ok()
1456 .and_then(|raw| parse_source_file(&raw));
1457 match windows_link_status(link.is_file(), source.as_ref(), exe, ctx.host, ctx.version) {
1458 LinkStatus::Fresh if !same_bytes(&link, exe) => LinkStatus::Stale,
1460 other => other,
1461 }
1462 } else {
1463 launcher_link_status(dir, exe, ctx.host, ctx.version)
1464 };
1465 let l = link.display().to_string();
1466 let install = |ctx: &mut LauncherCtx| -> Result<(), ToolError> {
1467 if windows {
1468 install_windows_copy(dir, exe, ctx.version)?;
1469 } else {
1470 replace_with_symlink(exe, &link)?;
1471 }
1472 say(ctx, &format!("installed: {l} -> {}", exe.display()));
1473 Ok(())
1474 };
1475 match status {
1476 LinkStatus::Missing | LinkStatus::Stale => install(ctx)?,
1477 LinkStatus::Fresh => say(ctx, &format!("already current: {l}")),
1478 LinkStatus::OtherHost if force => install(ctx)?,
1479 LinkStatus::OtherHost => {
1480 return Err(conflict(
1481 format!("{l} belongs to another host's binary"),
1482 "run `install-launcher --force` to take it over",
1483 ))
1484 }
1485 LinkStatus::NotALink if windows && force => install(ctx)?,
1486 LinkStatus::NotALink => {
1487 return Err(conflict(
1488 format!("{l} is not a link install-launcher made; it is left alone"),
1489 if windows {
1490 "run `install-launcher --force` to replace it"
1491 } else {
1492 "remove it by hand, or install into another directory"
1493 },
1494 ))
1495 }
1496 }
1497 if !(ctx.path_has)("opencode-pair") {
1498 say(
1499 ctx,
1500 &format!(
1501 "note: {} is not on PATH; add it to PATH to run `opencode-pair`",
1502 dir.display()
1503 ),
1504 );
1505 }
1506 Ok(())
1507}
1508
1509pub fn system_path_has(name: &str) -> bool {
1513 let Some(path) = crate::cli::env_nonempty("PATH") else {
1514 return false;
1515 };
1516 let exts: &[&str] = if cfg!(windows) {
1517 &["", ".exe", ".cmd", ".bat"]
1518 } else {
1519 &[""]
1520 };
1521 std::env::split_paths(&path).any(|dir| {
1522 !dir.as_os_str().is_empty()
1523 && exts
1524 .iter()
1525 .any(|e| dir.join(format!("{name}{e}")).is_file())
1526 })
1527}
1528
1529pub fn system_spawn_detached(cmd: &mut Command) -> io::Result<u32> {
1532 let mut child = cmd.spawn()?;
1533 let pid = child.id();
1534 std::thread::spawn(move || {
1535 let _ = child.wait();
1536 });
1537 Ok(pid)
1538}
1539
1540pub fn system_spawn_and_wait(
1542 cmd: &mut Command,
1543 on_pid: &mut dyn FnMut(u32),
1544) -> io::Result<std::process::ExitStatus> {
1545 let mut child = cmd.spawn()?;
1546 on_pid(child.id());
1547 child.wait()
1548}