Skip to main content

supercode_harness/
teams.rs

1//! Where supercode-teams lives on this box, and the service unit that keeps
2//! its machine daemon up (`docs/plans/teams-server.md` §11).
3//!
4//! supercode does not implement teams; the `sdk/teams` package does. This
5//! module holds the two facts the Rust CLI needs about it:
6//!
7//! * **where its Node entry is** — [`teams_entry`], resolved exactly the way
8//!   [`crate::orchestrator::daemon_entry`] resolves the orchestrator's:
9//!   `SUPERCODE_TEAMS_ENTRY` first, then the checkout the running binary sits
10//!   in, then the checkout it was built from, then the globally installed
11//!   `@volter/supercode-teams` package (`npm root -g`).
12//! * **what a service unit for its node would say** — [`service_unit`] renders
13//!   the launchd plist / systemd unit / Windows Scheduled Task that runs
14//!   `node <entry> machine start`, written under `<home>/service/`;
15//!   [`install_service`] and [`uninstall_service`] drive `launchctl` /
16//!   `systemctl --user` / `schtasks` over it.
17//!
18//! Everything else about teams — its host key, log, contexts, enrollments — is the Node
19//! package's own state, written by its own CLI. There is no second writer of
20//! that home in this binary.
21
22use std::path::{Path, PathBuf};
23
24use crate::orchestrator::{absolute_program, resolve_program, ServiceState, ServiceUnit};
25
26/// The teams CLI entry inside the `sdk/teams` package.
27pub const TEAMS_ENTRY: &str = "bin/teams.mjs";
28
29/// The npm name the `sdk/teams` package is published under.
30pub const TEAMS_PACKAGE: &str = "@volter/supercode-teams";
31/// The native terminal library the Teams package's panes load. Its install script fetches the binary for darwin and
32/// win32 (its package ships only linux ones), and npm runs that script only for packages `--allow-scripts` names.
33pub const PTY_PACKAGE: &str = "@homebridge/node-pty-prebuilt-multiarch";
34
35/// Directory the rendered service unit is written into, relative to the home.
36pub const SERVICE_DIR: &str = "service";
37
38/// launchd label / systemd unit name for this machine's teams daemon.
39pub const SERVICE_NAME: &str = "dev.volter.supercode-teams-machine";
40
41/// Stable, context-scoped label for one workspace connector service.
42pub fn connector_service_name(server_id: &str, team_id: &str, context: &str) -> String {
43    // FNV-1a is sufficient here: this is a stable filesystem/service label,
44    // not an authorization decision or secret digest.
45    let mut hash = 0xcbf29ce484222325_u64;
46    for byte in [server_id, team_id, context].join("\0").bytes() {
47        hash ^= u64::from(byte);
48        hash = hash.wrapping_mul(0x100000001b3);
49    }
50    format!("dev.volter.supercode-teams-connector-{hash:016x}")
51}
52
53fn plist_text(value: &str) -> String {
54    value
55        .replace('&', "&amp;")
56        .replace('<', "&lt;")
57        .replace('>', "&gt;")
58}
59
60fn service_text(value: &str) -> Result<&str, TeamsError> {
61    if value.chars().any(char::is_control) {
62        return Err(TeamsError::Service {
63            action: "render",
64            detail: "service parameters cannot contain control characters".into(),
65        });
66    }
67    Ok(value)
68}
69
70// Preserve owner settings when regenerating a connector's launchd unit. The
71// install controls only its home, executable and search path; other values are
72// retained verbatim (not printed).
73fn connector_plist_environment(
74    path: &Path,
75    managed: &[(&str, &str)],
76) -> Result<String, TeamsError> {
77    let mut values = std::collections::BTreeMap::<String, String>::new();
78    if path.exists() {
79        let output = std::process::Command::new("/usr/bin/plutil")
80            .args(["-convert", "json", "-o", "-"])
81            .arg(path)
82            .output()
83            .map_err(|source| TeamsError::File {
84                path: path.to_path_buf(),
85                source,
86            })?;
87        let refused = || TeamsError::Service {
88            action: "render",
89            detail: format!(
90                "cannot read existing EnvironmentVariables in {}; service unchanged",
91                path.display()
92            ),
93        };
94        if !output.status.success() {
95            return Err(refused());
96        }
97        let old: serde_json::Value =
98            serde_json::from_slice(&output.stdout).map_err(|_| refused())?;
99        if let Some(env) = old.get("EnvironmentVariables") {
100            let env = env.as_object().ok_or_else(refused)?;
101            for (key, value) in env {
102                values.insert(key.clone(), value.as_str().ok_or_else(refused)?.to_owned());
103            }
104        }
105    }
106    for (key, value) in managed {
107        values.insert((*key).to_owned(), (*value).to_owned());
108    }
109    Ok(values
110        .into_iter()
111        .map(|(key, value)| {
112            format!(
113                "<key>{}</key><string>{}</string>",
114                plist_text(&key),
115                plist_text(&value)
116            )
117        })
118        .collect())
119}
120
121fn systemd_arg(value: &str) -> String {
122    format!(
123        "\"{}\"",
124        value
125            .replace('\\', "\\\\")
126            .replace('"', "\\\"")
127            .replace('%', "%%")
128            .replace('$', "$$")
129    )
130}
131
132/// Render the persistent foreground connector command for one saved context.
133pub fn connector_service_unit(
134    teams_home: &Path,
135    supercode_home: &Path,
136    entry: &Path,
137    node: &str,
138    supercode: &Path,
139    context: &str,
140    cwd: &Path,
141    server_id: &str,
142    team_id: &str,
143) -> Result<ServiceUnit, TeamsError> {
144    let teams_home_text = teams_home.display().to_string();
145    let supercode_home_text = supercode_home.display().to_string();
146    let entry_text = entry.display().to_string();
147    let supercode_text = supercode.display().to_string();
148    let workspace_text = cwd.display().to_string();
149    for value in [
150        teams_home_text.as_str(),
151        supercode_home_text.as_str(),
152        entry_text.as_str(),
153        node,
154        supercode_text.as_str(),
155        context,
156        workspace_text.as_str(),
157        server_id,
158        team_id,
159    ] {
160        service_text(value)?;
161    }
162    let label = connector_service_name(server_id, team_id, context);
163    let path = teams_home
164        .join(SERVICE_DIR)
165        .join(format!("{label}.{}", connector_unit_suffix()));
166    let node = absolute_program(node);
167    let entry = entry_text;
168    let workspace = workspace_text;
169    let home = supercode_home_text;
170    let supercode = supercode_text;
171    if cfg!(windows) {
172        let log_path = teams_home.join(SERVICE_DIR).join(format!("{label}.log"));
173        return windows_task(
174            &path,
175            &label,
176            &format!("supercode Teams connector ({context})"),
177            &service_env_path(teams_home, &label),
178            &[
179                ("SUPERCODE_HOME", home.as_str()),
180                ("SUPERCODE_BIN", supercode.as_str()),
181                ("SUPERCODE_TEAMS_LOG", &log_path.display().to_string()),
182            ],
183            &node,
184            &[
185                entry.as_str(),
186                "teams",
187                "connect",
188                "--foreground",
189                "--context",
190                context,
191                "--cwd",
192                workspace.as_str(),
193            ],
194            &workspace,
195        );
196    }
197    if cfg!(target_os = "macos") {
198        let node = plist_text(&node);
199        let entry = plist_text(&entry);
200        let workspace = plist_text(&workspace);
201        let environment = connector_plist_environment(
202            &path,
203            &[
204                ("SUPERCODE_HOME", &home),
205                ("SUPERCODE_BIN", &supercode),
206                ("PATH", &service_path()),
207            ],
208        )?;
209        let context = plist_text(context);
210        // `ProcessType Interactive`: without it launchd spawns the connector as a daemon-type job at background
211        // priority (20, against 31 for the user's own processes), and on a busy disk its throttled reads made a
212        // full session discovery of 4,300 sessions miss the 25 s bound (gemini alone 15 s against 2.7 s), so
213        // `discover --fleet` listed the machine unreachable. The connector answers people waiting on it.
214        let text = format!(
215            r#"<?xml version="1.0" encoding="UTF-8"?>
216<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
217<plist version="1.0"><dict>
218  <key>Label</key><string>{label}</string>
219  <key>ProgramArguments</key><array><string>{node}</string><string>{entry}</string><string>teams</string><string>connect</string><string>--context</string><string>{context}</string><string>--cwd</string><string>{workspace}</string></array>
220  <key>EnvironmentVariables</key><dict>{environment}</dict>
221  <key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ProcessType</key><string>Interactive</string>
222  <key>StandardOutPath</key><string>{}/service/{label}.out.log</string>
223  <key>StandardErrorPath</key><string>{}/service/{label}.err.log</string>
224</dict></plist>
225"#,
226            plist_text(&teams_home_text),
227            plist_text(&teams_home_text)
228        );
229        Ok(ServiceUnit {
230            kind: "launchd",
231            path: path.clone(),
232            text,
233            install_command: format!("launchctl bootstrap gui/$(id -u) {}", path.display()),
234            files: Vec::new(),
235        })
236    } else {
237        let environment_home = systemd_arg(&format!("SUPERCODE_HOME={home}"));
238        let environment_bin = systemd_arg(&format!("SUPERCODE_BIN={supercode}"));
239        let environment_path = systemd_arg(&format!("PATH={}", service_path()));
240        let node = systemd_arg(&node);
241        let entry = systemd_arg(&entry);
242        let workspace = systemd_arg(&workspace);
243        let context_description = context.replace('%', "%%").replace('$', "$$");
244        let context = systemd_arg(context);
245        // KillMode=process: the tmux server holding the machine's panes forks into this unit's cgroup, and a
246        // restart must end only the connector, never the panes.
247        let text = format!("[Unit]\nDescription=supercode Teams connector ({context_description})\nAfter=network.target\n\n[Service]\nEnvironment={environment_home}\nEnvironment={environment_bin}\nEnvironment={environment_path}\nExecStart={node} {entry} teams connect --context {context} --cwd {workspace}\nRestart=on-failure\nKillSignal=SIGTERM\nKillMode=process\n\n[Install]\nWantedBy=default.target\n");
248        Ok(ServiceUnit {
249            kind: "systemd",
250            path: path.clone(),
251            text,
252            install_command: format!(
253                "systemctl --user link {} && systemctl --user enable --now {label}",
254                path.display()
255            ),
256            files: Vec::new(),
257        })
258    }
259}
260
261/// The connector unit's file suffix on this platform.
262fn connector_unit_suffix() -> &'static str {
263    if cfg!(windows) {
264        "xml"
265    } else if cfg!(target_os = "macos") {
266        "plist"
267    } else {
268        "service"
269    }
270}
271
272/// The environment file a Windows service task hands to node (`--env-file`).
273fn service_env_path(teams_home: &Path, label: &str) -> PathBuf {
274    teams_home.join(SERVICE_DIR).join(format!("{label}.env"))
275}
276
277/// Render a per-user Scheduled Task that keeps `node --env-file=<env_path> <node_args…>` running, written to `path`
278/// with its environment file beside it. Shared by the connector and the machine daemon.
279#[allow(clippy::too_many_arguments)]
280fn windows_task(
281    path: &Path,
282    label: &str,
283    description: &str,
284    env_path: &Path,
285    env: &[(&str, &str)],
286    node: &str,
287    node_args: &[&str],
288    working_directory: &str,
289) -> Result<ServiceUnit, TeamsError> {
290    // A scheduled task sets no environment and keeps no output, so node reads both from a file beside the
291    // task (`--env-file`); the user's own PATH is the task's. `conhost --headless` runs it without a window,
292    // and hides node's exit code too, so restart-on-failure never sees one fail: a trigger every minute
293    // starts the service again instead, and while it runs the task's IgnoreNew makes that tick a no-op.
294    // The trigger's fixed past start keeps the rendered unit the same on every install.
295    let env_text = env
296        .iter()
297        .map(|(key, value)| env_file_value(value).map(|value| format!("{key}={value}\n")))
298        .collect::<Result<String, _>>()?;
299    let env_flag = format!("--env-file={}", env_path.display());
300    let mut arguments = vec![node, env_flag.as_str()];
301    arguments.extend_from_slice(node_args);
302    // Task Scheduler expands `%NAME%` in a task's arguments, and there is no escape for it.
303    for value in arguments.iter().chain([&working_directory]) {
304        if value.contains('%') {
305            return Err(TeamsError::Service {
306                action: "render",
307                detail: format!("a Windows task cannot carry a path containing `%`: {value}"),
308            });
309        }
310    }
311    let arguments = arguments
312        .iter()
313        .map(|argument| windows_arg(argument))
314        .collect::<Vec<_>>()
315        .join(" ");
316    let user = windows_user();
317    let conhost = Path::new(&std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into()))
318        .join(r"System32\conhost.exe")
319        .display()
320        .to_string();
321    // `<Priority>4</Priority>`: a task's default priority is 7, below normal for CPU and I/O. Not measured on
322    // Windows: the same throttling measured on macOS (see the launchd plist) made discovery miss its bound there.
323    let text = format!(
324        r#"<?xml version="1.0" encoding="UTF-16"?>
325<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
326  <RegistrationInfo><Description>{}</Description></RegistrationInfo>
327  <Triggers><LogonTrigger><Enabled>true</Enabled><UserId>{}</UserId></LogonTrigger><TimeTrigger><StartBoundary>2000-01-01T00:00:00</StartBoundary><Enabled>true</Enabled><Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition></TimeTrigger></Triggers>
328  <Principals><Principal id="Author"><UserId>{}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
329  <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><ExecutionTimeLimit>PT0S</ExecutionTimeLimit><Priority>4</Priority><RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure><StartWhenAvailable>true</StartWhenAvailable></Settings>
330  <Actions Context="Author"><Exec><Command>{}</Command><Arguments>--headless {}</Arguments><WorkingDirectory>{}</WorkingDirectory></Exec></Actions>
331</Task>
332"#,
333        xml_text(description),
334        xml_text(&user),
335        xml_text(&user),
336        xml_text(&conhost),
337        xml_text(&arguments),
338        xml_text(working_directory),
339    );
340    Ok(ServiceUnit {
341        kind: "schtasks",
342        path: path.to_path_buf(),
343        text,
344        install_command: format!("schtasks /Create /TN {label} /XML {} /F", path.display()),
345        files: vec![(env_path.to_path_buf(), env_text)],
346    })
347}
348
349/// Text inside a Task Scheduler XML element or attribute.
350fn xml_text(value: &str) -> String {
351    plist_text(value).replace('"', "&quot;")
352}
353
354/// One argument of a Windows command line, quoted so the C runtime (node.exe's) splits it back out whole:
355/// backslashes are literal except before a quote, where they and the quote are escaped.
356fn windows_arg(value: &str) -> String {
357    if !value.is_empty() && !value.contains([' ', '\t', '"']) {
358        return value.to_string();
359    }
360    let mut quoted = String::from("\"");
361    let mut backslashes = 0;
362    for character in value.chars() {
363        match character {
364            '\\' => backslashes += 1,
365            '"' => {
366                quoted.push_str(&"\\".repeat(backslashes * 2 + 1));
367                quoted.push('"');
368                backslashes = 0;
369            }
370            other => {
371                quoted.push_str(&"\\".repeat(backslashes));
372                quoted.push(other);
373                backslashes = 0;
374            }
375        }
376    }
377    quoted.push_str(&"\\".repeat(backslashes * 2));
378    quoted.push('"');
379    quoted
380}
381
382/// A value in a node `--env-file`, quoted with a quote character the value does not contain. Single and backtick
383/// quotes are literal; double quotes would turn a path's `\n` into a newline, so they are the last choice.
384fn env_file_value(value: &str) -> Result<String, TeamsError> {
385    ['\'', '`']
386        .into_iter()
387        .find(|quote| !value.contains(*quote))
388        .map(|quote| format!("{quote}{value}{quote}"))
389        .or_else(|| (!value.contains(['"', '\\'])).then(|| format!("\"{value}\"")))
390        .ok_or_else(|| TeamsError::Service {
391            action: "render",
392            detail: format!("cannot write `{value}` into a service's environment file"),
393        })
394}
395
396/// The Windows account a task runs as: `DOMAIN\user`, the one installing it.
397fn windows_user() -> String {
398    let user = std::env::var("USERNAME").unwrap_or_default();
399    match std::env::var("USERDOMAIN") {
400        Ok(domain) if !domain.is_empty() => format!("{domain}\\{user}"),
401        _ => user,
402    }
403}
404
405/// The binary an installed connector runs. On Windows a running `.exe` cannot be replaced, so a
406/// connector that ran the npm package's own binary made `npm install -g` fail with EBUSY for as long
407/// as it ran: there the service runs a copy kept per version under the teams service directory, and
408/// installing again after an upgrade moves it to the new copy. Copies of other versions that no
409/// process holds any more are removed. Everywhere else the binary itself is replaceable in place.
410pub fn connector_service_binary(
411    teams_home: &Path,
412    supercode: &Path,
413) -> Result<PathBuf, TeamsError> {
414    if !cfg!(windows) {
415        return Ok(supercode.to_path_buf());
416    }
417    let file = |path: &Path, source: std::io::Error| TeamsError::File {
418        path: path.to_path_buf(),
419        source,
420    };
421    let copies = teams_home.join(SERVICE_DIR).join("bin");
422    let version = env!("CARGO_PKG_VERSION");
423    let dir = copies.join(version);
424    let copy = dir.join(
425        supercode
426            .file_name()
427            .unwrap_or_else(|| "supercode.exe".as_ref()),
428    );
429    let size = |path: &Path| std::fs::metadata(path).map(|meta| meta.len()).ok();
430    if size(&copy).is_none() || size(&copy) != size(supercode) {
431        std::fs::create_dir_all(&dir).map_err(|source| file(&dir, source))?;
432        std::fs::copy(supercode, &copy).map_err(|source| file(&copy, source))?;
433    }
434    if let Ok(entries) = std::fs::read_dir(&copies) {
435        for entry in entries.flatten() {
436            if entry.file_name() != version {
437                let _ = std::fs::remove_dir_all(entry.path());
438            }
439        }
440    }
441    Ok(copy)
442}
443
444/// Why a teams verb could not do its work.
445#[derive(Debug, thiserror::Error)]
446pub enum TeamsError {
447    /// The Node teams entry could not be located.
448    #[error("no teams entry found (looked for `sdk/teams/{TEAMS_ENTRY}` under: {searched}); install it with `npm install -g --allow-scripts={PTY_PACKAGE} {TEAMS_PACKAGE}`")]
449    NoEntry {
450        /// The candidate paths that were searched, joined.
451        searched: String,
452    },
453    /// A service manager refused, or there is none on this platform.
454    #[error("teams service: {action} failed: {detail}")]
455    Service {
456        /// What was attempted (`install`, `uninstall`).
457        action: &'static str,
458        /// What the service manager (or this module) said about it.
459        detail: String,
460    },
461    /// A file under the teams home could not be written or removed.
462    #[error("teams file `{}`: {source}", path.display())]
463    File {
464        /// The path involved.
465        path: PathBuf,
466        /// The underlying I/O failure.
467        source: std::io::Error,
468    },
469}
470
471/// The search path a service runs with: the installing shell's own, so a
472/// service finds the same `tmux`, `node` and harness CLIs its installer did
473/// (a launchd or systemd default path has none of them).
474fn service_path() -> String {
475    std::env::var("PATH")
476        .ok()
477        .filter(|path| !path.trim().is_empty())
478        .unwrap_or_else(|| "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin".into())
479}
480
481/// The teams home: `SUPERCODE_TEAMS_HOME`, else `<SUPERCODE_HOME>/teams`.
482///
483/// The same precedence `sdk/volter-teams/home.mjs` uses, so a unit installed from
484/// here serves the home the Node CLI reads.
485pub fn teams_home() -> PathBuf {
486    if let Ok(home) = std::env::var("SUPERCODE_TEAMS_HOME") {
487        if !home.is_empty() {
488            return PathBuf::from(home);
489        }
490    }
491    crate::agent::global_instructions_dir().join("teams")
492}
493
494/// Locate the Node teams entry (`sdk/teams/bin/teams.mjs`).
495///
496/// Candidates, in order: `SUPERCODE_TEAMS_ENTRY` (an explicit override, which
497/// is also how a test points at a fake), the repo checkout the running binary
498/// sits in, the workspace this crate was built from,
499/// and the globally installed npm package — an installed binary has no
500/// checkout, so `npm install -g @volter/supercode-teams` is how a
501/// Machine gets its node. The current directory is never a candidate: the
502/// code a binary runs does not change with where it is run.
503pub fn teams_entry() -> Result<PathBuf, TeamsError> {
504    let mut searched = Vec::new();
505    if let Some(explicit) = std::env::var_os("SUPERCODE_TEAMS_ENTRY") {
506        let path = PathBuf::from(explicit);
507        if path.is_file() {
508            return Ok(path);
509        }
510        searched.push(path.display().to_string());
511    }
512    let mut roots: Vec<PathBuf> = Vec::new();
513    if let Ok(exe) = std::env::current_exe() {
514        // target/<profile>/supercode → the workspace root is two levels up.
515        roots.extend(exe.ancestors().skip(1).take(4).map(Path::to_path_buf));
516    }
517    // A locally built binary's target directory can live anywhere (a shared
518    // cargo build dir, another volume), so the checkout it was built from is
519    // the last candidate. On an installed binary this path simply does not
520    // exist and is skipped like any other miss.
521    if let Some(workspace) = crate::build_checkout() {
522        roots.push(workspace);
523    }
524    for root in roots {
525        let candidate = root.join("sdk/teams").join(TEAMS_ENTRY);
526        if candidate.is_file() {
527            return Ok(candidate);
528        }
529        searched.push(candidate.display().to_string());
530    }
531    // Installed from npm, this binary sits inside the global node_modules that
532    // also holds the Teams package, so that directory is found from the
533    // binary's own path first (`npm root -g` masks path segments it takes for
534    // secrets, a UUID among them).
535    if let Ok(exe) = std::env::current_exe() {
536        for modules in exe
537            .ancestors()
538            .filter(|dir| dir.file_name().is_some_and(|name| name == "node_modules"))
539        {
540            let candidate = modules.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
541            if candidate.is_file() {
542                return Ok(candidate);
543            }
544            searched.push(candidate.display().to_string());
545        }
546    }
547    if let Some(global) = global_npm_root() {
548        let candidate = global.join(TEAMS_PACKAGE).join(TEAMS_ENTRY);
549        if candidate.is_file() {
550            return Ok(candidate);
551        }
552        searched.push(candidate.display().to_string());
553    }
554    Err(TeamsError::NoEntry {
555        searched: searched.join(", "),
556    })
557}
558
559/// Where npm installs global packages (`npm root -g`), when npm is present.
560fn global_npm_root() -> Option<PathBuf> {
561    let output = std::process::Command::new(resolve_program("npm"))
562        .args(["root", "-g"])
563        .stdin(std::process::Stdio::null())
564        .stderr(std::process::Stdio::null())
565        .output()
566        .ok()?;
567    if !output.status.success() {
568        return None;
569    }
570    let text = String::from_utf8_lossy(&output.stdout);
571    let root = text.trim();
572    if root.is_empty() {
573        return None;
574    }
575    Some(PathBuf::from(root))
576}
577
578/// Render the per-platform service unit for this machine's teams daemon.
579///
580/// The node takes no `--root`: it serves the home its own environment
581/// resolves (`SUPERCODE_TEAMS_HOME`, else `<SUPERCODE_HOME>/teams`), so the
582/// unit names the listen address and nothing else. A port of `0` means the
583/// node picks one and publishes it in `<home>/node.json`.
584///
585/// On Windows it is a per-user Scheduled Task, rendered exactly as a connector's is (see [`windows_task`]).
586pub fn service_unit(home: &Path, entry: &Path, node: &str) -> Result<ServiceUnit, TeamsError> {
587    let home_display = home.display().to_string();
588    let entry_display = entry.display().to_string();
589    // A control character would break out of any unit's syntax (a plist string, a systemd line, task XML).
590    for value in [home_display.as_str(), entry_display.as_str(), node] {
591        service_text(value)?;
592    }
593    if cfg!(windows) {
594        let log_path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.log"));
595        return windows_task(
596            &home.join(SERVICE_DIR).join(unit_file_name()),
597            SERVICE_NAME,
598            &format!("supercode teams machine daemon ({home_display})"),
599            &service_env_path(home, SERVICE_NAME),
600            &[
601                ("SUPERCODE_TEAMS_HOME", home_display.as_str()),
602                ("SUPERCODE_TEAMS_LOG", &log_path.display().to_string()),
603            ],
604            node,
605            &[entry_display.as_str(), "machine", "start"],
606            &home_display,
607        );
608    }
609    if cfg!(target_os = "macos") {
610        let path = home.join(SERVICE_DIR).join(format!("{SERVICE_NAME}.plist"));
611        let text = format!(
612            r#"<?xml version="1.0" encoding="UTF-8"?>
613<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
614<plist version="1.0">
615<dict>
616  <key>Label</key><string>{SERVICE_NAME}</string>
617  <key>ProgramArguments</key>
618  <array>
619    <string>{node}</string>
620    <string>{entry_display}</string>
621    <string>machine</string>
622    <string>start</string>
623  </array>
624  <key>EnvironmentVariables</key>
625  <dict>
626    <key>SUPERCODE_TEAMS_HOME</key><string>{home_display}</string>
627  </dict>
628  <key>RunAtLoad</key><true/>
629  <key>KeepAlive</key><true/>
630  <key>ProcessType</key><string>Interactive</string>
631  <key>StandardOutPath</key><string>{home_display}/service/teams-machine.out.log</string>
632  <key>StandardErrorPath</key><string>{home_display}/service/teams-machine.err.log</string>
633</dict>
634</plist>
635"#
636        );
637        let install = format!("launchctl bootstrap gui/$(id -u) {}", path.display());
638        Ok(ServiceUnit {
639            kind: "launchd",
640            path,
641            text,
642            install_command: install,
643            files: Vec::new(),
644        })
645    } else {
646        let path = home
647            .join(SERVICE_DIR)
648            .join(format!("{SERVICE_NAME}.service"));
649        let text = format!(
650            "[Unit]\n\
651             Description=supercode teams machine daemon ({home_display})\n\
652             After=network.target\n\
653             \n\
654             [Service]\n\
655             Environment=SUPERCODE_TEAMS_HOME={home_display}\n\
656             ExecStart={node} {entry_display} machine start\n\
657             Restart=on-failure\n\
658             KillSignal=SIGTERM\n\
659             KillMode=process\n\
660             \n\
661             [Install]\n\
662             WantedBy=default.target\n"
663        );
664        let install = format!(
665            "systemctl --user link {} && systemctl --user enable --now {SERVICE_NAME}",
666            path.display()
667        );
668        Ok(ServiceUnit {
669            kind: "systemd",
670            path,
671            text,
672            install_command: install,
673            files: Vec::new(),
674        })
675    }
676}
677
678/// Write a rendered unit under `<home>/service/`.
679pub fn write_unit(unit: &ServiceUnit) -> Result<(), TeamsError> {
680    if let Some(parent) = unit.path.parent() {
681        std::fs::create_dir_all(parent).map_err(|source| TeamsError::File {
682            path: unit.path.clone(),
683            source,
684        })?;
685    }
686    std::fs::write(&unit.path, &unit.text).map_err(|source| TeamsError::File {
687        path: unit.path.clone(),
688        source,
689    })?;
690    for (path, text) in &unit.files {
691        std::fs::write(path, text).map_err(|source| TeamsError::File {
692            path: path.clone(),
693            source,
694        })?;
695    }
696    Ok(())
697}
698
699/// Run a service-manager command and return (success, stdout+stderr).
700fn run_tool(program: &str, args: &[&str]) -> Result<(bool, String), std::io::Error> {
701    let output = std::process::Command::new(program).args(args).output()?;
702    let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
703    text.push_str(&String::from_utf8_lossy(&output.stderr));
704    Ok((output.status.success(), text.trim().to_string()))
705}
706
707#[cfg(target_os = "macos")]
708fn gui_domain() -> String {
709    // SAFETY: `getuid` reads this process's own real user id and cannot fail.
710    format!("gui/{}", unsafe { libc::getuid() })
711}
712
713/// What the platform's service manager says about the teams daemon unit.
714///
715/// Never starts or installs anything.
716pub fn service_status() -> ServiceState {
717    platform_status()
718}
719
720#[cfg(target_os = "macos")]
721fn platform_status() -> ServiceState {
722    let label = SERVICE_NAME.to_string();
723    let target = format!("{}/{SERVICE_NAME}", gui_domain());
724    match run_tool("launchctl", &["print", &target]) {
725        Ok((true, text)) => ServiceState {
726            kind: "launchd",
727            label,
728            installed: true,
729            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
730            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
731        },
732        Ok((false, _)) => ServiceState {
733            kind: "launchd",
734            label,
735            installed: false,
736            pid: None,
737            detail: format!("not bootstrapped in {}", gui_domain()),
738        },
739        Err(error) => ServiceState {
740            kind: "launchd",
741            label,
742            installed: false,
743            pid: None,
744            detail: format!("launchctl unavailable: {error}"),
745        },
746    }
747}
748
749#[cfg(all(unix, not(target_os = "macos")))]
750fn platform_status() -> ServiceState {
751    let label = SERVICE_NAME.to_string();
752    match run_tool("systemctl", &["--user", "is-active", SERVICE_NAME]) {
753        Ok((active, text)) => {
754            let known = run_tool("systemctl", &["--user", "is-enabled", SERVICE_NAME])
755                .map(|(ok, _)| ok)
756                .unwrap_or(false);
757            ServiceState {
758                kind: "systemd",
759                label,
760                installed: active || known,
761                pid: None,
762                detail: if text.is_empty() {
763                    "unknown".into()
764                } else {
765                    text
766                },
767            }
768        }
769        Err(error) => ServiceState {
770            kind: "systemd",
771            label,
772            installed: false,
773            pid: None,
774            detail: format!("systemctl unavailable: {error}"),
775        },
776    }
777}
778
779#[cfg(not(unix))]
780fn platform_status() -> ServiceState {
781    named_service_status(SERVICE_NAME)
782}
783
784/// `key = value` out of a service manager's block output.
785#[cfg(target_os = "macos")]
786fn field_of(text: &str, key: &str) -> Option<String> {
787    text.lines()
788        .find_map(|line| line.trim().strip_prefix(key))
789        .map(|value| value.trim().to_string())
790}
791
792/// The file name the unit takes on this platform.
793fn unit_file_name() -> String {
794    if cfg!(windows) {
795        format!("{SERVICE_NAME}.xml")
796    } else if cfg!(target_os = "macos") {
797        format!("{SERVICE_NAME}.plist")
798    } else {
799        format!("{SERVICE_NAME}.service")
800    }
801}
802
803/// Render the unit, hand it to the platform's service manager, and start it.
804///
805/// Refuses a label the manager already holds rather than replacing it: two
806/// homes share one label, so an install that silently took it over would point
807/// a running node at a different folder.
808pub fn install_service(
809    home: &Path,
810    entry: &Path,
811    node: &str,
812) -> Result<(ServiceUnit, ServiceState), TeamsError> {
813    let existing = service_status();
814    if existing.installed {
815        return Err(TeamsError::Service {
816            action: "install",
817            detail: format!(
818                "`{}` is already installed ({}); `supercode teams machine uninstall` first",
819                existing.label, existing.detail
820            ),
821        });
822    }
823    let unit = service_unit(home, entry, &absolute_program(node))?;
824    write_unit(&unit)?;
825    platform_install(&unit)?;
826    Ok((unit, service_status()))
827}
828
829/// A persistent connector must not depend on a mutable checkout or Cargo output.
830/// Resolve symlinks too: an npm-linked SDK is still source, not an installed copy.
831pub fn validate_connector_install_paths(entry: &Path, binary: &Path) -> Result<(), TeamsError> {
832    for (label, path) in [("Teams entry", entry), ("supercode binary", binary)] {
833        let resolved = std::fs::canonicalize(path).map_err(|source| TeamsError::File {
834            path: path.to_path_buf(),
835            source,
836        })?;
837        // npm installs can live inside a checkout of their package manager (Homebrew),
838        // or a user's dotfiles repository. Only ancestors within the installed
839        // package count. Canonicalization above still exposes npm-linked source.
840        let checkout = resolved.ancestors().any(|dir| dir.join(".git").exists());
841        let installed_root = resolved.ancestors().find(|dir| {
842            let Some(scope) = dir.parent() else {
843                return false;
844            };
845            if scope.file_name().and_then(|n| n.to_str()) != Some("@volter")
846                || scope
847                    .parent()
848                    .and_then(Path::file_name)
849                    .and_then(|n| n.to_str())
850                    != Some("node_modules")
851            {
852                return false;
853            }
854            let Ok(text) = std::fs::read_to_string(dir.join("package.json")) else {
855                return false;
856            };
857            let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
858                return false;
859            };
860            let Some(name) = value.get("name").and_then(|v| v.as_str()) else {
861                return false;
862            };
863            let expected = if label == "Teams entry" {
864                name == "@volter/supercode-teams"
865            } else {
866                name.starts_with("@volter/supercode-cli-")
867            };
868            expected && dir.file_name().and_then(|n| n.to_str()) == name.strip_prefix("@volter/")
869        });
870        let checkout = checkout
871            && installed_root.is_none_or(|root| {
872                resolved
873                    .ancestors()
874                    .take_while(|dir| dir.starts_with(root))
875                    .any(|dir| dir.join(".git").exists())
876            });
877        let cargo_output = resolved
878            .parent()
879            .is_some_and(|dir| dir.join("deps").is_dir());
880        if checkout || cargo_output {
881            return Err(TeamsError::Service {
882                action: "install",
883                detail: format!(
884                    "{label} is source/build output at {}; the connector service was not changed. Use an installed package and binary, or teams connect --foreground for source work",
885                    resolved.display()
886                ),
887            });
888        }
889    }
890    Ok(())
891}
892
893/// Install a rendered context connector. An identical installed unit is an
894/// idempotent success. A different unit in this home's own service folder is
895/// this home's connector with new parameters (a new build, PATH or folder), so
896/// it is replaced and restarted; a label the manager holds with no unit here
897/// belongs to another home and is refused.
898pub fn install_connector_service(
899    unit: &ServiceUnit,
900    label: &str,
901) -> Result<ServiceState, TeamsError> {
902    let existing = named_service_status(label);
903    if unit.path.exists() {
904        let old = std::fs::read_to_string(&unit.path).map_err(|source| TeamsError::File {
905            path: unit.path.clone(),
906            source,
907        })?;
908        // A Windows unit's environment lives in its companion file, so that is compared too.
909        let same = old == unit.text
910            && unit
911                .files
912                .iter()
913                .all(|(path, text)| std::fs::read_to_string(path).is_ok_and(|old| &old == text));
914        if same && existing.installed {
915            return Ok(existing);
916        }
917        if !same && existing.installed {
918            named_platform_uninstall(label)?;
919        }
920    } else if existing.installed {
921        return Err(TeamsError::Service {
922            action: "install",
923            detail: format!(
924                "service manager already owns `{label}` without its expected unit file"
925            ),
926        });
927    }
928    write_unit(unit)?;
929    named_platform_install(unit, label)?;
930    Ok(named_service_status(label))
931}
932
933/// Give every installed harness supercode's messaging tools: register
934/// `<supercode> message mcp` as a user-scope MCP server named `supercode`
935/// through each harness's own `mcp add`. An entry that runs another binary
936/// (an older install, a build that is gone) is replaced through the harness's
937/// own `mcp remove`: a session loads only a server that starts. A harness
938/// whose CLI is absent is left as it is. One line per harness says what
939/// happened.
940pub fn register_message_tools(supercode: &Path) -> Vec<String> {
941    let program = supercode.display().to_string();
942    let mut report = Vec::new();
943    for (harness, get, remove, add) in [
944        (
945            "claude",
946            vec!["mcp", "get", "supercode"],
947            vec!["mcp", "remove", "--scope", "user", "supercode"],
948            vec![
949                "mcp",
950                "add",
951                "--scope",
952                "user",
953                "supercode",
954                "--",
955                &program,
956                "message",
957                "mcp",
958            ],
959        ),
960        (
961            "codex",
962            vec!["mcp", "get", "supercode"],
963            vec!["mcp", "remove", "supercode"],
964            vec!["mcp", "add", "supercode", "--", &program, "message", "mcp"],
965        ),
966    ] {
967        let run = |args: &[&str]| {
968            std::process::Command::new(resolve_program(harness))
969                .args(args)
970                .stdin(std::process::Stdio::null())
971                .output()
972        };
973        // Paths compare as text, and Windows paths without regard to case.
974        let names_program = |text: &str| {
975            if cfg!(windows) {
976                text.to_lowercase().contains(&program.to_lowercase())
977            } else {
978                text.contains(&program)
979            }
980        };
981        let replaced = match run(&get) {
982            Err(_) => {
983                report.push(format!("{harness}: not installed"));
984                continue;
985            }
986            Ok(found)
987                if found.status.success()
988                    && names_program(&String::from_utf8_lossy(&found.stdout)) =>
989            {
990                report.push(format!("{harness}: already has supercode's tools"));
991                continue;
992            }
993            Ok(found) if found.status.success() => {
994                let _ = run(&remove);
995                true
996            }
997            Ok(_) => false,
998        };
999        match run(&add) {
1000            Ok(added) if added.status.success() => report.push(if replaced {
1001                format!("{harness}: supercode's tools now run {program} (new sessions load them)")
1002            } else {
1003                format!("{harness}: supercode's tools added (new sessions load them)")
1004            }),
1005            Ok(added) => report.push(format!(
1006                "{harness}: could not add supercode's tools: {}",
1007                String::from_utf8_lossy(&added.stderr).trim()
1008            )),
1009            Err(error) => report.push(format!(
1010                "{harness}: could not add supercode's tools: {error}"
1011            )),
1012        }
1013    }
1014    report
1015}
1016
1017/// Stop and remove exactly one context connector service.
1018pub fn uninstall_connector_service(
1019    teams_home: &Path,
1020    label: &str,
1021) -> Result<ServiceState, TeamsError> {
1022    named_platform_uninstall(label)?;
1023    let unit = teams_home
1024        .join(SERVICE_DIR)
1025        .join(format!("{label}.{}", connector_unit_suffix()));
1026    for path in [unit, service_env_path(teams_home, label)] {
1027        match std::fs::remove_file(&path) {
1028            Ok(()) => {}
1029            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1030            Err(source) => return Err(TeamsError::File { path, source }),
1031        }
1032    }
1033    Ok(named_service_status(label))
1034}
1035
1036/// Read-only service-manager status for one context connector.
1037pub fn connector_service_status(label: &str) -> ServiceState {
1038    named_service_status(label)
1039}
1040
1041/// Whether a service manager runs this OS user's machine daemon: the machine
1042/// unit, or a context connector unit written under the teams home, is loaded
1043/// by its manager. Such a daemon is brought back by its service manager;
1044/// nothing else should start one in its place. Never starts or installs anything.
1045///
1046/// A unit file alone does not count: after a reboot nothing loads a unit kept
1047/// under the teams home, and a session waiting on it would leave the machine
1048/// with no daemon. The gap of a connector's own restart is covered from the
1049/// other side: a service connector whose socket a linkless daemon took stops
1050/// that daemon and restarts onto the socket.
1051pub fn service_owns_daemon() -> bool {
1052    if service_status().installed {
1053        return true;
1054    }
1055    let Ok(entries) = std::fs::read_dir(teams_home().join(SERVICE_DIR)) else {
1056        return false;
1057    };
1058    let suffix = format!(".{}", connector_unit_suffix());
1059    entries.flatten().any(|entry| {
1060        let name = entry.file_name().to_string_lossy().into_owned();
1061        // `<label>.plist` only: a copy kept beside it (`<label>.<note>.plist`) is no unit.
1062        name.strip_suffix(&suffix).is_some_and(|label| {
1063            label
1064                .strip_prefix("dev.volter.supercode-teams-connector-")
1065                .is_some_and(|id| !id.is_empty() && !id.contains('.'))
1066                && connector_service_status(label).installed
1067        })
1068    })
1069}
1070
1071#[cfg(target_os = "macos")]
1072fn named_service_status(label: &str) -> ServiceState {
1073    let target = format!("{}/{label}", gui_domain());
1074    match run_tool("launchctl", &["print", &target]) {
1075        Ok((true, text)) => ServiceState {
1076            kind: "launchd",
1077            label: label.into(),
1078            installed: true,
1079            pid: field_of(&text, "pid = ").and_then(|value| value.parse().ok()),
1080            detail: field_of(&text, "state = ").unwrap_or_else(|| "loaded".into()),
1081        },
1082        Ok((false, _)) => ServiceState {
1083            kind: "launchd",
1084            label: label.into(),
1085            installed: false,
1086            pid: None,
1087            detail: format!("not bootstrapped in {}", gui_domain()),
1088        },
1089        Err(error) => ServiceState {
1090            kind: "launchd",
1091            label: label.into(),
1092            installed: false,
1093            pid: None,
1094            detail: format!("launchctl unavailable: {error}"),
1095        },
1096    }
1097}
1098
1099#[cfg(all(unix, not(target_os = "macos")))]
1100fn named_service_status(label: &str) -> ServiceState {
1101    match run_tool("systemctl", &["--user", "is-active", label]) {
1102        Ok((active, text)) => {
1103            let known = run_tool("systemctl", &["--user", "is-enabled", label])
1104                .map(|(ok, _)| ok)
1105                .unwrap_or(false);
1106            ServiceState {
1107                kind: "systemd",
1108                label: label.into(),
1109                installed: active || known,
1110                pid: None,
1111                detail: if text.is_empty() {
1112                    "unknown".into()
1113                } else {
1114                    text
1115                },
1116            }
1117        }
1118        Err(error) => ServiceState {
1119            kind: "systemd",
1120            label: label.into(),
1121            installed: false,
1122            pid: None,
1123            detail: format!("systemctl unavailable: {error}"),
1124        },
1125    }
1126}
1127
1128#[cfg(not(unix))]
1129fn named_service_status(label: &str) -> ServiceState {
1130    match run_tool("schtasks", &["/Query", "/TN", label, "/FO", "CSV", "/NH"]) {
1131        // `"TaskName","Next Run Time","Status"`: the last field is the task's state, in the system's language.
1132        Ok((true, text)) => ServiceState {
1133            kind: "schtasks",
1134            label: label.into(),
1135            installed: true,
1136            pid: None,
1137            detail: text
1138                .lines()
1139                .next()
1140                .and_then(|line| line.rsplit(',').next())
1141                .map(|state| state.trim_matches('"').to_string())
1142                .filter(|state| !state.is_empty())
1143                .unwrap_or_else(|| "registered".into()),
1144        },
1145        Ok((false, _)) => ServiceState {
1146            kind: "schtasks",
1147            label: label.into(),
1148            installed: false,
1149            pid: None,
1150            detail: "no scheduled task".into(),
1151        },
1152        Err(error) => ServiceState {
1153            kind: "schtasks",
1154            label: label.into(),
1155            installed: false,
1156            pid: None,
1157            detail: format!("schtasks unavailable: {error}"),
1158        },
1159    }
1160}
1161
1162#[cfg(target_os = "macos")]
1163fn named_platform_install(unit: &ServiceUnit, _label: &str) -> Result<(), TeamsError> {
1164    platform_install(unit)
1165}
1166#[cfg(all(unix, not(target_os = "macos")))]
1167fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
1168    let path = unit.path.display().to_string();
1169    for args in [
1170        vec!["--user", "link", path.as_str()],
1171        vec!["--user", "enable", "--now", label],
1172    ] {
1173        let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1174            action: "install",
1175            detail: format!("systemctl: {error}"),
1176        })?;
1177        if !ok {
1178            return Err(TeamsError::Service {
1179                action: "install",
1180                detail: format!("systemctl {}: {text}", args.join(" ")),
1181            });
1182        }
1183    }
1184    Ok(())
1185}
1186#[cfg(not(unix))]
1187fn named_platform_install(unit: &ServiceUnit, label: &str) -> Result<(), TeamsError> {
1188    // Task Scheduler reads task XML only as UTF-16; the unit itself stays UTF-8 so an install can compare it.
1189    let task = unit.path.with_extension("utf16.xml");
1190    let bytes: Vec<u8> = [0xFF, 0xFE]
1191        .into_iter()
1192        .chain(unit.text.encode_utf16().flat_map(u16::to_le_bytes))
1193        .collect();
1194    std::fs::write(&task, bytes).map_err(|source| TeamsError::File {
1195        path: task.clone(),
1196        source,
1197    })?;
1198    let task_path = task.display().to_string();
1199    let created = run_tool(
1200        "schtasks",
1201        &["/Create", "/TN", label, "/XML", task_path.as_str(), "/F"],
1202    )
1203    .map_err(|error| TeamsError::Service {
1204        action: "install",
1205        detail: format!("schtasks: {error}"),
1206    })
1207    .and_then(|(ok, text)| {
1208        if ok {
1209            Ok(())
1210        } else {
1211            Err(TeamsError::Service {
1212                action: "install",
1213                detail: format!("schtasks /Create: {text}"),
1214            })
1215        }
1216    });
1217    if let Err(error) = created {
1218        let _ = std::fs::remove_file(&task);
1219        return Err(error);
1220    }
1221    // A task already running keeps its old instance: Task Scheduler refuses a
1222    // second one (0x800710E0) while `/Run` still reports success, so the
1223    // replaced connector would go on running the old code. End it first; a
1224    // task that is not running answers an error here, which is fine.
1225    let _ = run_tool("schtasks", &["/End", "/TN", label]);
1226    let result = [vec!["/Run", "/TN", label]].iter().try_for_each(|args| {
1227        let (ok, text) = run_tool("schtasks", args).map_err(|error| TeamsError::Service {
1228            action: "install",
1229            detail: format!("schtasks: {error}"),
1230        })?;
1231        if ok {
1232            Ok(())
1233        } else {
1234            Err(TeamsError::Service {
1235                action: "install",
1236                detail: format!("schtasks {}: {text}", args[0]),
1237            })
1238        }
1239    });
1240    let _ = std::fs::remove_file(&task);
1241    result
1242}
1243
1244#[cfg(target_os = "macos")]
1245fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1246    let target = format!("{}/{label}", gui_domain());
1247    let (ok, text) =
1248        run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1249            action: "uninstall",
1250            detail: format!("launchctl: {error}"),
1251        })?;
1252    if !ok && !text.contains("No such process") && !text.contains("not find") {
1253        return Err(TeamsError::Service {
1254            action: "uninstall",
1255            detail: format!("launchctl bootout {target}: {text}"),
1256        });
1257    }
1258    // bootout returns before launchd has let the label go, and a bootstrap
1259    // in that window fails with an I/O error; wait for it to be released.
1260    for _ in 0..50 {
1261        if !named_service_status(label).installed {
1262            break;
1263        }
1264        std::thread::sleep(std::time::Duration::from_millis(100));
1265    }
1266    Ok(())
1267}
1268#[cfg(all(unix, not(target_os = "macos")))]
1269fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1270    let _ = run_tool("systemctl", &["--user", "disable", "--now", label]);
1271    Ok(())
1272}
1273#[cfg(not(unix))]
1274fn named_platform_uninstall(label: &str) -> Result<(), TeamsError> {
1275    // The task goes first, so its minute trigger cannot start the service again while it is being stopped.
1276    let (ok, text) = run_tool("schtasks", &["/Delete", "/TN", label, "/F"]).map_err(|error| {
1277        TeamsError::Service {
1278            action: "uninstall",
1279            detail: format!("schtasks: {error}"),
1280        }
1281    })?;
1282    if !ok && named_service_status(label).installed {
1283        return Err(TeamsError::Service {
1284            action: "uninstall",
1285            detail: format!("schtasks /Delete: {text}"),
1286        });
1287    }
1288    // Deleting a task leaves what it started running, so the service is stopped by the one thing on its command
1289    // line that is its own: the environment file, on a node or its conhost. The path rides in the environment, not
1290    // the command line, so this query does not match itself.
1291    let env_path = service_env_path(&teams_home(), label);
1292    let stopped = std::process::Command::new("powershell.exe")
1293        .args([
1294            "-NoProfile",
1295            "-NonInteractive",
1296            "-Command",
1297            "Get-CimInstance Win32_Process -Filter \"Name='node.exe' OR Name='conhost.exe'\" | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($env:SUPERCODE_SERVICE_ENV_FILE) } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
1298        ])
1299        .env("SUPERCODE_SERVICE_ENV_FILE", env_path.display().to_string())
1300        .stdin(std::process::Stdio::null())
1301        .output();
1302    match stopped {
1303        Ok(output) if output.status.success() => Ok(()),
1304        Ok(output) => Err(TeamsError::Service {
1305            action: "uninstall",
1306            detail: format!(
1307                "the task is gone, but what it started may still run: {}",
1308                String::from_utf8_lossy(&output.stderr).trim()
1309            ),
1310        }),
1311        Err(error) => Err(TeamsError::Service {
1312            action: "uninstall",
1313            detail: format!(
1314                "the task is gone, but what it started may still run: powershell: {error}"
1315            ),
1316        }),
1317    }
1318}
1319
1320#[cfg(target_os = "macos")]
1321fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1322    let path = unit.path.display().to_string();
1323    let (ok, text) =
1324        run_tool("launchctl", &["bootstrap", &gui_domain(), &path]).map_err(|error| {
1325            TeamsError::Service {
1326                action: "install",
1327                detail: format!("launchctl: {error}"),
1328            }
1329        })?;
1330    if !ok {
1331        return Err(TeamsError::Service {
1332            action: "install",
1333            detail: format!("launchctl bootstrap {}: {text}", gui_domain()),
1334        });
1335    }
1336    Ok(())
1337}
1338
1339/// Untested on this box (the receipt is macOS); these are the commands
1340/// `service_unit` prints as its `install_command`.
1341#[cfg(all(unix, not(target_os = "macos")))]
1342fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1343    let path = unit.path.display().to_string();
1344    for args in [
1345        vec!["--user", "link", path.as_str()],
1346        vec!["--user", "enable", "--now", SERVICE_NAME],
1347    ] {
1348        let (ok, text) = run_tool("systemctl", &args).map_err(|error| TeamsError::Service {
1349            action: "install",
1350            detail: format!("systemctl: {error}"),
1351        })?;
1352        if !ok {
1353            return Err(TeamsError::Service {
1354                action: "install",
1355                detail: format!("systemctl {}: {text}", args.join(" ")),
1356            });
1357        }
1358    }
1359    Ok(())
1360}
1361
1362#[cfg(not(unix))]
1363fn platform_install(unit: &ServiceUnit) -> Result<(), TeamsError> {
1364    named_platform_install(unit, SERVICE_NAME)
1365}
1366
1367/// Stop and unregister the unit, and remove the rendered file.
1368///
1369/// Idempotent: a unit the manager does not hold is not an error, because the
1370/// state the operator asked for is the state they get.
1371pub fn uninstall_service(home: &Path) -> Result<ServiceState, TeamsError> {
1372    platform_uninstall()?;
1373    let unit_path = home.join(SERVICE_DIR).join(unit_file_name());
1374    // A Windows unit's environment file goes with it; elsewhere there is none and its absence is fine.
1375    for path in [unit_path, service_env_path(home, SERVICE_NAME)] {
1376        match std::fs::remove_file(&path) {
1377            Ok(()) => {}
1378            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1379            Err(source) => return Err(TeamsError::File { path, source }),
1380        }
1381    }
1382    // `launchctl bootout` returns before the job is torn down, so the state
1383    // this reports is the settled one, not the manager mid-teardown.
1384    let mut state = service_status();
1385    for _ in 0..40 {
1386        if !state.installed {
1387            break;
1388        }
1389        std::thread::sleep(std::time::Duration::from_millis(100));
1390        state = service_status();
1391    }
1392    Ok(state)
1393}
1394
1395#[cfg(target_os = "macos")]
1396fn platform_uninstall() -> Result<(), TeamsError> {
1397    let target = format!("{}/{SERVICE_NAME}", gui_domain());
1398    let (ok, text) =
1399        run_tool("launchctl", &["bootout", &target]).map_err(|error| TeamsError::Service {
1400            action: "uninstall",
1401            detail: format!("launchctl: {error}"),
1402        })?;
1403    // `bootout` on a label nobody holds says so and exits non-zero.
1404    if !ok && !text.contains("No such process") && !text.contains("not find") {
1405        return Err(TeamsError::Service {
1406            action: "uninstall",
1407            detail: format!("launchctl bootout {target}: {text}"),
1408        });
1409    }
1410    Ok(())
1411}
1412
1413#[cfg(all(unix, not(target_os = "macos")))]
1414fn platform_uninstall() -> Result<(), TeamsError> {
1415    let _ = run_tool("systemctl", &["--user", "disable", "--now", SERVICE_NAME]);
1416    Ok(())
1417}
1418
1419#[cfg(not(unix))]
1420fn platform_uninstall() -> Result<(), TeamsError> {
1421    named_platform_uninstall(SERVICE_NAME)
1422}