Skip to main content

opencode_ctl/launcher/
mod.rs

1//! The launcher: `pair`, the tab wrappers, the terminal walk, `install-launcher` and the pure
2//! Windows resolvers (spec "Launcher"), and `resume` and `attach` (Task 19), which reuse
3//! `claim_file`, `write_wrapper`, `controller_env` and `LauncherCtx::before_claim`.
4
5use 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
40/// What `pair`, `resume`, `attach` and `install-launcher` print on the OpenCode host.
41pub const NOT_OFFERED: &str = "not offered on the OpenCode host";
42
43/// The notice posted into the worker session when the controller exits (spec Launcher step 5).
44pub 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
57/// `pair [directory] [--model m] [--title t] [--controller c] [--no-tab] [--dry-run] [-- <controller args>]`.
58pub 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
98/// `dyn Fn(&mut Command, &mut dyn FnMut(u32)) -> io::Result<ExitStatus>`.
99pub type SpawnAndWaitFn<'a> =
100    dyn Fn(&mut Command, &mut dyn FnMut(u32)) -> io::Result<std::process::ExitStatus> + 'a;
101
102pub struct LauncherCtx<'a> {
103    /// `= engine.env.home`; the launcher asserts equality.
104    pub home: &'a Path,
105    /// `~/.local/bin` or `%LOCALAPPDATA%\opencode-ctl`; tests pass a temp dir.
106    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    /// The RESOLVED opencode binary for the wrapper; `None` = `OPENCODE_NOT_FOUND` for `pair` and `attach`.
113    pub opencode_bin: Option<&'a Path>,
114    /// The front's `CARGO_PKG_VERSION`: the `version=` line of `opencode-pair.exe.source`.
115    pub version: &'a str,
116    pub parent_chain: &'a dyn Fn() -> Vec<String>,
117    /// `OPENCODE_SERVER_PASSWORD` at launch (empty = absent).
118    pub server_password: Option<&'a str>,
119    /// The terminal tab: spawn and return the pid.
120    pub spawn_detached: &'a dyn Fn(&mut Command) -> io::Result<u32>,
121    /// The controller: calls the callback with the pid right after spawn, then waits.
122    pub spawn_and_wait: &'a SpawnAndWaitFn<'a>,
123    /// Test failpoint for `attach`: called after the record is read, before the first claim.
124    pub before_claim: Option<&'a dyn Fn()>,
125    pub out: &'a mut dyn Write,
126}
127
128// ------------------------------------------------------------------ wrapper files
129
130pub struct WrapperInput<'a> {
131    pub home: &'a Path,
132    /// The launcher's resolved binary.
133    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    /// True: embed the runtime expressions for `boot_id` and `starttime`; false (tests only): the
141    /// literals `BOOT` and `START`.
142    pub boot_id_expr: bool,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum WrapperKind {
147    Sh,
148    Ps1,
149}
150
151/// The ONE wrapper writer: `claim_file(path, bytes, 0o700)`, with U+FEFF prepended for a `.ps1`
152/// (`powershell.exe` 5.1 decodes a BOM-less script as the ANSI code page). `Ok(false)`: it exists.
153pub 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
171// ------------------------------------------------------------------ values
172
173fn invalid(msg: String) -> ToolError {
174    ToolError::new(ErrorCode::InvalidArgument, msg)
175}
176
177/// "Values that reach a shell" (spec Launcher): ids match their patterns, a newline is refused
178/// everywhere, `;` in the directory on Windows (`wt` splits its command line on it), and on Windows
179/// a controller argument with a `cmd.exe` metacharacter. Nothing is written before this passes.
180pub 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
220/// Spec step 5's environment, shared with `resume`. `OPENCODE_CTL_SERVER` is the EMPTY string when
221/// there is no `server_url` (never omitted: `Command` inherits the parent environment, and a stale
222/// value would point the controller at another server; empty = the background service).
223pub 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
291// ------------------------------------------------------------------ dry-run body capture
292
293/// A transport that records the body of the one `session.create` call, so the dry run shows exactly
294/// what `Engine::session_create` (policy, model, lock rules and all) would send.
295struct 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
367// ------------------------------------------------------------------ pair
368
369fn 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
385/// `pair`: spec Launcher steps 1-5. The order at the top is: mint the id, validate, `OPENCODE_NOT_FOUND`
386/// when there is no binary, open the log, then step 1; `--dry-run` writes, spawns and calls nothing.
387pub 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
432/// `<home>/logs/pair-<id>.log`, scoped to this thread; a failed open is a stderr warning (plan
433/// decision 10) and the run continues.
434fn 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
500/// Remove what a failed step 3 or 4 left: the session, then every `pair.<id>.*` file.
501async 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/// Step 5: the controller, as a child with inherited stdio. Shared with `resume`, which passes the
611/// `resume-lock` it holds: deleted once `claude-pid` is written.
612#[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/// Steps 3 and 4: the terminal, the record (once), the wrapper and the tab. A failure is returned
696/// for the caller to roll back.
697#[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
766/// Spec step 4's last action: the wrapper in a new tab of `t`; returns the tab process's pid.
767fn 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
784/// `claude-pid` from the child's pid, boot id and start time.
785fn 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
807/// `session.synthetic` `{text, resume: false}` with no client id; one retry with the same body on
808/// the id-collision conflict. Failure is logged, never fatal.
809async 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
863// ------------------------------------------------------------------ resume and attach
864
865fn 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
893/// `resume <pair-id> [--controller c] [-- <controller args>]` -> (pair id, controller, args).
894pub 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
931/// `attach <session-id | pair-id>` -> the target.
932pub 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
940/// Deletes the `resume-lock` it was handed on drop, whatever way `resume` ends.
941struct 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
965/// `--controller`, else the record's, else the host constant (an `attach` record has `None`).
966fn 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
978/// `resume <pair-id>` with the record's (else the host's) controller.
979pub 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
988/// `resume` with an explicit `--controller` (which wins over the record and the host constant).
989pub 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    // Nothing changes when the controller cannot be started at all.
1036    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    // A lock whose owner is dead (a crash between its create and the claude-pid write) is swept
1050    // here too, so it never wedges the pair. An empty or unparsable lock may be mid-creation.
1051    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(); // test failpoint: between the dead-lock read and the unlink
1055            }
1056            // Re-read right before the unlink: delete only the exact owner judged dead. A lock that
1057            // now names someone else is a fresh winner; it stays and the create below loses. The
1058            // residual window between this re-read and the unlink is inherent to the spec's
1059            // delete-then-create and is covered by the claude-pid re-check after the claim.
1060            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(); // test failpoint: after the first claude-pid check, before the lock claim
1079    }
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    // Re-check under the lock: another resume may have started a controller and dropped its lock
1089    // between the first check and our claim. Nothing is touched before this.
1090    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
1109/// `pair.<id>.g<k>.*` for every `k < n`: the tabs of older generations.
1110fn 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
1145/// `attach <session-id | pair-id>`.
1146pub 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    // The exclusive create of the wrapper is the claim token; orphans of a crashed attach are
1207    // skipped.
1208    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            // Neither a session (attach created none) nor anything else to remove.
1306            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
1314/// The new record (`controller: null`, generation 1, `tab` from the terminal walk), then the
1315/// wrapper, then the tab.
1316fn 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
1366// ------------------------------------------------------------------ install-launcher
1367
1368fn 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
1404/// Windows: an in-use copy is first renamed to `.old` (which `gc` deletes), then the running exe is
1405/// copied over and the `.source` file written.
1406fn 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(&copy, &old)?;
1413    }
1414    if let Err(e) = std::fs::copy(exe, &copy) {
1415        if moved {
1416            let _ = std::fs::rename(&old, &copy);
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
1436/// `install-launcher [--force]` for a platform flag (so the Windows copy rules run on every leg).
1437pub 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            // Same path and version, but a rebuilt binary: replace the copy.
1459            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
1509// ------------------------------------------------------------------ real-process seams
1510
1511/// `name` (or `name.exe`, `.cmd`, `.bat` on Windows) found as a file in a directory of `PATH`.
1512pub 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
1529/// The terminal tab: spawned and left to run; the child is reaped on a thread so it never lingers
1530/// as a zombie of this long-lived process.
1531pub 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
1540/// The controller: spawn with inherited stdio, hand the pid to `on_pid`, wait.
1541pub 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}